Compare commits

...

12 Commits

Author SHA1 Message Date
Eric Traut
93e21ccdc9 Refactor TUI external config migration startup 2026-04-15 20:04:31 -07:00
alexsong-oai
46d199e702 m 2026-04-15 18:09:32 -07:00
alexsong-oai
6cad0a38c5 lint 2026-04-15 18:02:33 -07:00
alexsong-oai
d9a96f5974 updates 2026-04-15 17:54:25 -07:00
alexsong-oai
d417e09fc8 Merge branch 'main' into alexs/tui-migrate 2026-04-15 15:07:12 -07:00
alexsong-oai
9521fa3497 Merge branch 'main' into alexs/tui-migrate 2026-04-15 14:35:25 -07:00
alexsong-oai
6759e3266b temp 2026-04-15 14:33:42 -07:00
alexsong-oai
369fc518d4 gen 2026-04-14 21:42:20 -07:00
alexsong-oai
e3105f2b8e a 2026-04-14 21:37:22 -07:00
alexsong-oai
14ec2b0b8f [TUI] add external config migration prompt when start TUI 2026-04-14 21:33:02 -07:00
alexsong-oai
522b8e5a51 Merge branch 'main' into alexs/tui-migrate 2026-04-14 19:39:52 -07:00
alexsong-oai
a9a977ed8b Add migrate external config to codex TUI support 2026-04-14 00:10:41 -07:00
17 changed files with 2539 additions and 48 deletions

View File

@@ -579,7 +579,23 @@ const fn default_true() -> bool {
/// Settings for notices we display to users via the tui and app-server clients
/// (primarily the Codex IDE extension). NOTE: these are different from
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExternalConfigMigrationPrompts {
/// Tracks whether home-level external config migration prompts are hidden.
pub home: Option<bool>,
/// Tracks the last time the home-level external config migration prompt was shown.
pub home_last_prompted_at: Option<i64>,
/// Tracks which project paths have opted out of external config migration prompts.
#[serde(default)]
pub projects: BTreeMap<String, bool>,
/// Tracks the last time a project-level external config migration prompt was shown.
#[serde(default)]
pub project_last_prompted_at: BTreeMap<String, i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct Notice {
/// Tracks whether the user has acknowledged the full access warning prompt.
pub hide_full_access_warning: Option<bool>,
@@ -595,6 +611,9 @@ pub struct Notice {
/// Tracks acknowledged model migrations as old->new model slug mappings.
#[serde(default)]
pub model_migrations: BTreeMap<String, String>,
/// Tracks scopes where external config migration prompts should be suppressed.
#[serde(default)]
pub external_config_migration_prompts: ExternalConfigMigrationPrompts,
}
pub use crate::skills_config::BundledSkillsConfig;

View File

@@ -389,6 +389,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"external_migrate": {
"type": "boolean"
},
"fast_mode": {
"type": "boolean"
},
@@ -630,6 +633,39 @@
},
"type": "object"
},
"ExternalConfigMigrationPrompts": {
"additionalProperties": false,
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"properties": {
"home": {
"description": "Tracks whether home-level external config migration prompts are hidden.",
"type": "boolean"
},
"home_last_prompted_at": {
"description": "Tracks the last time the home-level external config migration prompt was shown.",
"format": "int64",
"type": "integer"
},
"project_last_prompted_at": {
"additionalProperties": {
"format": "int64",
"type": "integer"
},
"default": {},
"description": "Tracks the last time a project-level external config migration prompt was shown.",
"type": "object"
},
"projects": {
"additionalProperties": {
"type": "boolean"
},
"default": {},
"description": "Tracks which project paths have opted out of external config migration prompts.",
"type": "object"
}
},
"type": "object"
},
"FeatureToml_for_MultiAgentV2ConfigToml": {
"anyOf": [
{
@@ -1132,8 +1168,22 @@
"type": "object"
},
"Notice": {
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"additionalProperties": false,
"properties": {
"external_config_migration_prompts": {
"allOf": [
{
"$ref": "#/definitions/ExternalConfigMigrationPrompts"
}
],
"default": {
"home": null,
"home_last_prompted_at": null,
"project_last_prompted_at": {},
"projects": {}
},
"description": "Tracks scopes where external config migration prompts should be suppressed."
},
"hide_full_access_warning": {
"description": "Tracks whether the user has acknowledged the full access warning prompt.",
"type": "boolean"
@@ -2252,6 +2302,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"external_migrate": {
"type": "boolean"
},
"fast_mode": {
"type": "boolean"
},

View File

@@ -43,6 +43,14 @@ pub enum ConfigEdit {
SetWindowsWslSetupAcknowledged(bool),
/// Toggle the model migration prompt acknowledgement flag.
SetNoticeHideModelMigrationPrompt(String, bool),
/// Toggle the home external config migration prompt acknowledgement flag.
SetNoticeHideExternalConfigMigrationPromptHome(bool),
/// Record when the home external config migration prompt was last shown.
SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64),
/// Toggle the project external config migration prompt acknowledgement flag.
SetNoticeHideExternalConfigMigrationPromptProject(String, bool),
/// Record when the project external config migration prompt was last shown.
SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64),
/// Record that a migration prompt was shown for an old->new model mapping.
RecordModelMigrationSeen { from: String, to: String },
/// Replace the entire `[mcp_servers]` table.
@@ -411,6 +419,53 @@ impl ConfigDocument {
value(*acknowledged),
))
}
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"home",
],
value(*acknowledged),
)),
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"home_last_prompted_at",
],
value(*timestamp),
))
}
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project,
acknowledged,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"projects",
project.as_str(),
],
value(*acknowledged),
)),
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
project,
timestamp,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"project_last_prompted_at",
project.as_str(),
],
value(*timestamp),
)),
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
@@ -909,6 +964,28 @@ impl ConfigEditsBuilder {
self
}
pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
acknowledged,
));
self
}
pub fn set_hide_external_config_migration_prompt_project(
mut self,
project: &str,
acknowledged: bool,
) -> Self {
self.edits.push(
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project.to_string(),
acknowledged,
),
);
self
}
pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
from: from.to_string(),

View File

@@ -552,6 +552,130 @@ gpt-5 = "gpt-5.1"
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts]
home = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
"/Users/alexsong/code/skills".to_string(),
true,
),
],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts.projects]
"/Users/alexsong/code/skills" = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts]
home_last_prompted_at = 1760000000
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
"/Users/alexsong/code/skills".to_string(),
1_760_000_000,
),
],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts.project_last_prompted_at]
"/Users/alexsong/code/skills" = 1760000000
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_replace_mcp_servers_round_trips() {
let tmp = tempdir().expect("tmpdir");

View File

@@ -1,8 +1,16 @@
use crate::plugins::MarketplaceAddRequest;
use crate::plugins::MarketplacePluginInstallPolicy;
use crate::plugins::PluginId;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::add_marketplace;
use crate::plugins::find_marketplace_manifest_path;
use crate::plugins::load_marketplace;
use crate::plugins::marketplace_install_root;
use crate::plugins::parse_marketplace_source;
use crate::plugins::resolve_configured_marketplace_root;
use crate::plugins::validate_plugin_segment;
use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
@@ -15,8 +23,6 @@ use toml::Value as TomlValue;
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
// Installed marketplace roots always expose their manifest at this relative path.
const INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
@@ -137,22 +143,11 @@ impl ExternalAgentConfigService {
let cwd = migration_item.cwd;
let details = migration_item.details;
tokio::spawn(async move {
match service.import_plugins(cwd.as_deref(), details).await {
Ok(outcome) => {
tracing::info!(
succeeded_marketplaces = outcome.succeeded_marketplaces.len(),
succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(),
failed_marketplaces = outcome.failed_marketplaces.len(),
failed_plugin_ids = outcome.failed_plugin_ids.len(),
"external agent config plugin import completed"
);
}
Err(err) => {
tracing::warn!(
error = %err,
"external agent config plugin import failed"
);
}
if let Err(err) = service.import_plugins(cwd.as_deref(), details).await {
tracing::warn!(
error = %err,
"external agent config plugin import failed"
);
}
});
emit_migration_metric(
@@ -173,6 +168,8 @@ impl ExternalAgentConfigService {
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let configured_plugin_ids = configured_plugin_ids(&self.codex_home)?;
let configured_marketplace_plugins = configured_marketplace_plugins(&self.codex_home)?;
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.external_agent_home.join("settings.json"),
@@ -223,6 +220,8 @@ impl ExternalAgentConfigService {
source_settings.as_path(),
cwd.clone(),
settings.as_ref(),
&configured_plugin_ids,
&configured_marketplace_plugins,
items,
);
@@ -239,7 +238,7 @@ impl ExternalAgentConfigService {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}",
"Migrate skills from {} to {}",
source_skills.display(),
target_skills.display()
),
@@ -298,9 +297,17 @@ impl ExternalAgentConfigService {
source_settings: &Path,
cwd: Option<PathBuf>,
settings: Option<&JsonValue>,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) {
let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else {
let Some(plugin_details) = settings.and_then(|settings| {
extract_plugin_migration_details(
settings,
configured_plugin_ids,
configured_marketplace_plugins,
)
}) else {
return;
};
@@ -336,8 +343,13 @@ impl ExternalAgentConfigService {
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
let import_source =
read_marketplace_import_source(cwd, &self.external_agent_home, &marketplace_name)?;
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(".claude").join("settings.json"),
);
let import_source = read_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings).remove(&marketplace_name)
});
let Some(import_source) = import_source else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
@@ -351,12 +363,17 @@ impl ExternalAgentConfigService {
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
let Some(marketplace_path) = find_marketplace_manifest_path(
add_marketplace_outcome.installed_root.as_path(),
) else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
add_marketplace_outcome
.installed_root
.join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH)
marketplace_path
}
Err(_) => {
outcome.failed_marketplaces.push(marketplace_name);
@@ -525,12 +542,36 @@ fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
Ok(Some(settings))
}
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
fn extract_plugin_migration_details(
settings: &JsonValue,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<MigrationDetails> {
let loadable_marketplaces = collect_marketplace_import_sources(settings)
.into_iter()
.filter_map(|(marketplace_name, source)| {
parse_marketplace_source(&source.source, source.ref_name)
.ok()
.map(|_| marketplace_name)
})
.collect::<HashSet<_>>();
let mut plugins = BTreeMap::new();
for plugin_id in collect_enabled_plugins(settings) {
for plugin_id in collect_enabled_plugins(settings)
.into_iter()
.filter(|plugin_id| !configured_plugin_ids.contains(plugin_id))
{
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
if let Some(installable_plugins) =
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
{
if !installable_plugins.contains(&plugin_id.plugin_name) {
continue;
}
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
continue;
}
let plugin_group = plugins
.entry(plugin_id.marketplace_name.clone())
.or_insert_with(|| PluginsMigration {
@@ -579,6 +620,97 @@ fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
.collect()
}
fn configured_plugin_ids(codex_home: &Path) -> io::Result<HashSet<String>> {
let config_path = codex_home.join("config.toml");
if !config_path.is_file() {
return Ok(HashSet::new());
}
let raw_config = fs::read_to_string(&config_path)?;
if raw_config.trim().is_empty() {
return Ok(HashSet::new());
}
let config = toml::from_str::<TomlValue>(&raw_config)
.map_err(|err| invalid_data_error(format!("invalid config.toml: {err}")))?;
let Some(plugins) = config.get("plugins").and_then(TomlValue::as_table) else {
return Ok(HashSet::new());
};
Ok(plugins.keys().cloned().collect())
}
fn configured_marketplace_plugins(
codex_home: &Path,
) -> io::Result<BTreeMap<String, HashSet<String>>> {
let default_install_root = marketplace_install_root(codex_home);
let mut marketplace_roots = BTreeMap::new();
let config_path = codex_home.join("config.toml");
if !config_path.is_file() {
return Ok(BTreeMap::new());
}
let raw_config = fs::read_to_string(&config_path)?;
if raw_config.trim().is_empty() {
return Ok(BTreeMap::new());
}
let config = toml::from_str::<TomlValue>(&raw_config)
.map_err(|err| invalid_data_error(format!("invalid config.toml: {err}")))?;
let Some(marketplaces) = config.get("marketplaces").and_then(TomlValue::as_table) else {
return Ok(BTreeMap::new());
};
for (marketplace_name, marketplace) in marketplaces {
if validate_plugin_segment(marketplace_name, "marketplace name").is_err()
|| !marketplace.is_table()
{
continue;
}
let Some(root) = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
&default_install_root,
) else {
continue;
};
let root = if root.is_relative() {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(root)
} else {
root
};
marketplace_roots.insert(marketplace_name.clone(), root);
}
let mut marketplace_plugins = BTreeMap::new();
for (marketplace_name, root) in marketplace_roots {
let Some(manifest_path) = find_marketplace_manifest_path(&root) else {
continue;
};
let Ok(marketplace) = load_marketplace(&manifest_path) else {
continue;
};
let plugins =
marketplace
.plugins
.into_iter()
.filter(|plugin| {
plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable
&& plugin.policy.products.as_deref().is_none_or(|products| {
Product::Codex.matches_product_restriction(products)
})
})
.map(|plugin| plugin.name)
.collect::<HashSet<_>>();
marketplace_plugins.insert(marketplace_name, plugins);
}
Ok(marketplace_plugins)
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
) -> BTreeMap<String, MarketplaceImportSource> {
@@ -631,22 +763,6 @@ struct MarketplaceImportSource {
ref_name: Option<String>,
}
fn read_marketplace_import_source(
cwd: Option<&Path>,
external_agent_home: &Path,
marketplace_name: &str,
) -> io::Result<Option<MarketplaceImportSource>> {
let source_settings = cwd.map_or_else(
|| external_agent_home.join("settings.json"),
|cwd| cwd.join(".claude").join("settings.json"),
);
let Some(settings) = read_external_settings(&source_settings)? else {
return Ok(None);
};
Ok(collect_marketplace_import_sources(&settings).remove(marketplace_name))
}
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(None);

View File

@@ -59,7 +59,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}",
"Migrate skills from {} to {}",
claude_home.join("skills").display(),
agents_skills.display()
),
@@ -418,6 +418,11 @@ fn detect_home_lists_enabled_plugins_from_settings() {
"formatter@acme-tools": true,
"deployer@acme-tools": true,
"analyzer@security-plugins": false
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
@@ -449,6 +454,148 @@ fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[test]
fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"deployer@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write codex config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string()],
}],
}),
}]
);
}
#[test]
fn detect_repo_skips_plugins_that_are_disabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = false
"#,
)
.expect("write codex config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root]),
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
"#,
)
.expect("write codex config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root]),
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn import_plugins_requires_details() {
let (_root, claude_home, codex_home) = fixture_paths();
@@ -462,6 +609,242 @@ async fn import_plugins_requires_details() {
assert_eq!(err.to_string(), "plugins migration item is missing details");
}
#[test]
fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir");
fs::create_dir_all(repo_root.join(".codex")).expect("create repo codex dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write project codex config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
}),
}]
);
}
#[test]
fn detect_home_skips_plugins_without_marketplace_source() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::write(
claude_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
}
}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_home_skips_plugins_with_invalid_marketplace_source() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::write(
claude_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "github"
}
}
}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_repo_filters_plugins_against_installed_marketplace() {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
let marketplace_root = codex_home.join(".tmp").join("marketplaces").join("debug");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir");
fs::create_dir_all(marketplace_root.join(".agents").join("plugins"))
.expect("create marketplace manifest dir");
fs::create_dir_all(
marketplace_root
.join("plugins")
.join("sample")
.join(".codex-plugin"),
)
.expect("create sample plugin");
fs::create_dir_all(
marketplace_root
.join("plugins")
.join("available")
.join(".codex-plugin"),
)
.expect("create available plugin");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"sample@debug": true,
"available@debug": true,
"missing@debug": true
},
"extraKnownMarketplaces": {
"debug": {
"source": "owner/debug-marketplace"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[marketplaces.debug]
source_type = "git"
source = "owner/debug-marketplace"
"#,
)
.expect("write codex config");
fs::write(
marketplace_root
.join(".agents")
.join("plugins")
.join("marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"installation": "NOT_AVAILABLE"
}
},
{
"name": "available",
"source": {
"source": "local",
"path": "./plugins/available"
}
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
marketplace_root
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write sample plugin manifest");
fs::write(
marketplace_root
.join("plugins")
.join("available")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"available"}"#,
)
.expect("write available plugin manifest");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "debug".to_string(),
plugin_names: vec!["available".to_string()],
}],
}),
}]
);
}
#[tokio::test]
async fn import_plugins_requires_source_marketplace_details() {
let (_root, claude_home, codex_home) = fixture_paths();
@@ -542,6 +925,82 @@ async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace(
);
}
#[tokio::test]
async fn import_plugins_supports_claude_plugin_marketplace_layout() {
let (_root, claude_home, codex_home) = fixture_paths();
let marketplace_root = claude_home.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(marketplace_root.join(".claude-plugin"))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
claude_home.join("settings.json"),
format!(
r#"{{
"enabledPlugins": {{
"cloudflare@my-plugins": true
}},
"extraKnownMarketplaces": {{
"my-plugins": {{
"source": "local",
"path": "{}"
}}
}}
}}"#,
marketplace_root.display()
),
)
.expect("write settings");
fs::write(
marketplace_root
.join(".claude-plugin")
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(claude_home, codex_home.clone())
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
}
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[test]
fn import_skills_returns_only_new_skill_directory_count() {
let (_root, claude_home, codex_home) = fixture_paths();

View File

@@ -20,7 +20,7 @@ use metadata::find_marketplace_root_by_name;
use metadata::installed_marketplace_root_for_source;
use metadata::record_added_marketplace_entry;
use source::MarketplaceSource;
use source::parse_marketplace_source;
pub(crate) use source::parse_marketplace_source;
use source::stage_marketplace_source;
use source::validate_marketplace_source_root;

View File

@@ -5,7 +5,7 @@ use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum MarketplaceSource {
pub(crate) enum MarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
@@ -15,7 +15,7 @@ pub(super) enum MarketplaceSource {
},
}
pub(super) fn parse_marketplace_source(
pub(crate) fn parse_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<MarketplaceSource, MarketplaceAddError> {

View File

@@ -31,6 +31,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use installed_marketplaces::INSTALLED_MARKETPLACES_DIR;
pub use installed_marketplaces::marketplace_install_root;
pub(crate) use installed_marketplaces::resolve_configured_marketplace_root;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
@@ -58,11 +59,14 @@ pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginPolicy;
pub use marketplace::MarketplacePluginSource;
pub(crate) use marketplace::find_marketplace_manifest_path;
pub(crate) use marketplace::load_marketplace;
pub use marketplace::validate_marketplace_root;
pub use marketplace_add::MarketplaceAddError;
pub use marketplace_add::MarketplaceAddOutcome;
pub use marketplace_add::MarketplaceAddRequest;
pub use marketplace_add::add_marketplace;
pub(crate) use marketplace_add::parse_marketplace_source;
pub use remote::RemotePluginFetchError;
pub use remote::fetch_remote_featured_plugin_ids;
pub(crate) use render::render_explicit_plugin_instructions;

View File

@@ -152,6 +152,8 @@ pub enum Feature {
ToolSuggest,
/// Enable plugins.
Plugins,
/// Show the startup prompt for migrating external agent config into Codex.
ExternalMigrate,
/// Allow the model to invoke the built-in image generation tool.
ImageGeneration,
/// Allow prompting and installing missing MCP dependencies.
@@ -809,6 +811,16 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ExternalMigrate,
key: "external_migrate",
stage: Stage::Experimental {
name: "External migrate",
menu_description: "Show a startup prompt when Codex detects migratable external agent config for this machine or project.",
announcement: "",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::ImageGeneration,
key: "image_generation",

View File

@@ -103,6 +103,23 @@ fn guardian_approval_is_experimental_and_user_toggleable() {
assert_eq!(Feature::GuardianApproval.default_enabled(), false);
}
#[test]
fn external_migrate_is_experimental_and_disabled_by_default() {
let spec = Feature::ExternalMigrate.info();
let stage = spec.stage;
assert!(matches!(stage, Stage::Experimental { .. }));
assert_eq!(stage.experimental_menu_name(), Some("External migrate"));
assert_eq!(
stage.experimental_menu_description(),
Some(
"Show a startup prompt when Codex detects migratable external agent config for this machine or project."
)
);
assert_eq!(stage.experimental_announcement(), None);
assert_eq!(Feature::ExternalMigrate.default_enabled(), false);
}
#[test]
fn request_permissions_is_under_development() {
assert_eq!(

View File

@@ -28,6 +28,8 @@ use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::split_command_string;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_agent_config_migration_startup::ExternalAgentConfigMigrationStartupOutcome;
use crate::external_agent_config_migration_startup::handle_external_agent_config_migration_prompt_if_needed;
use crate::external_editor;
use crate::file_search::FileSearchManager;
use crate::history_cell;
@@ -3749,6 +3751,37 @@ impl App {
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let external_agent_config_migration_outcome =
handle_external_agent_config_migration_prompt_if_needed(
tui,
&mut app_server,
&mut config,
&cli_kv_overrides,
&harness_overrides,
)
.await?;
let external_agent_config_migration_message = match external_agent_config_migration_outcome
{
ExternalAgentConfigMigrationStartupOutcome::Continue { success_message } => {
success_message
}
ExternalAgentConfigMigrationStartupOutcome::ExitRequested => {
app_server
.shutdown()
.await
.inspect_err(|err| {
tracing::warn!("app-server shutdown failed: {err}");
})
.ok();
return Ok(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
};
let bootstrap = app_server.bootstrap(&config).await?;
let mut model = bootstrap.default_model;
let available_models = bootstrap.available_models;
@@ -3919,6 +3952,9 @@ impl App {
(ChatWidget::new_with_app_event(init), Some(forked))
}
};
if let Some(message) = external_agent_config_migration_message {
chat_widget.add_info_message(message, /*hint*/ None);
}
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);

View File

@@ -14,6 +14,11 @@ use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
@@ -285,6 +290,31 @@ impl AppServerSession {
.wrap_err("account/read failed during TUI bootstrap")
}
pub(crate) async fn external_agent_config_detect(
&mut self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
.await
.wrap_err("externalAgentConfig/detect failed during TUI startup")
}
pub(crate) async fn external_agent_config_import(
&mut self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> Result<ExternalAgentConfigImportResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ExternalAgentConfigImport {
request_id,
params: ExternalAgentConfigImportParams { migration_items },
})
.await
.wrap_err("externalAgentConfig/import failed during TUI startup")
}
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
self.client.next_event().await
}

View File

@@ -0,0 +1,976 @@
use crate::diff_render::display_path_for;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::selection_list::selection_option_row_with_dim;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::PluginsMigration;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::prelude::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExternalAgentConfigMigrationOutcome {
Proceed(Vec<ExternalAgentConfigMigrationItem>),
Skip,
SkipForever,
Exit,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FocusArea {
Items,
Actions,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActionMenuOption {
Proceed,
Skip,
SkipForever,
}
impl ActionMenuOption {
fn label(self) -> &'static str {
match self {
Self::Proceed => "Proceed with selected",
Self::Skip => "Skip for now",
Self::SkipForever => "Don't ask again for these locations",
}
}
fn previous(self) -> Option<Self> {
match self {
Self::Proceed => None,
Self::Skip => Some(Self::Proceed),
Self::SkipForever => Some(Self::Skip),
}
}
fn next(self) -> Option<Self> {
match self {
Self::Proceed => Some(Self::Skip),
Self::Skip => Some(Self::SkipForever),
Self::SkipForever => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MigrationSelection {
item: ExternalAgentConfigMigrationItem,
enabled: bool,
}
struct RenderLineEntry {
item_idx: Option<usize>,
kind: RenderLineKind,
line: Line<'static>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RenderLineKind {
Section,
Item,
ItemDetail,
}
pub(crate) async fn run_external_agent_config_migration_prompt(
tui: &mut Tui,
items: &[ExternalAgentConfigMigrationItem],
selected_items: &[ExternalAgentConfigMigrationItem],
error: Option<&str>,
) -> ExternalAgentConfigMigrationOutcome {
let alt = AltScreenGuard::enter(tui);
let mut screen = ExternalAgentConfigMigrationScreen::new(
alt.tui.frame_requester(),
items,
selected_items,
error.map(str::to_owned),
);
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = alt.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 => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.skip();
break;
}
}
screen.outcome()
}
struct ExternalAgentConfigMigrationScreen {
request_frame: FrameRequester,
items: Vec<MigrationSelection>,
selected_item_idx: Option<usize>,
scroll_top: usize,
focus: FocusArea,
highlighted_action: ActionMenuOption,
done: bool,
outcome: ExternalAgentConfigMigrationOutcome,
error: Option<String>,
}
impl ExternalAgentConfigMigrationScreen {
fn display_description(item: &ExternalAgentConfigMigrationItem) -> String {
let Some(cwd) = item.cwd.as_deref() else {
return item.description.clone();
};
fn reformat_description(
description: &str,
prefix: &str,
separator: &str,
cwd: &std::path::Path,
) -> Option<String> {
let remainder = description.strip_prefix(prefix)?;
let (left, right) = remainder.split_once(separator)?;
Some(format!(
"{prefix}{}{}{}",
display_path_for(std::path::Path::new(left), cwd),
separator,
display_path_for(std::path::Path::new(right), cwd)
))
}
if let Some(reformatted) =
reformat_description(&item.description, "Migrate ", " into ", cwd)
{
return reformatted;
}
if let Some(reformatted) =
reformat_description(&item.description, "Migrate skills from ", " to ", cwd)
{
return reformatted;
}
if let Some(reformatted) = reformat_description(&item.description, "Import ", " to ", cwd) {
return reformatted;
}
if let Some(source) = item
.description
.strip_prefix("Import enabled plugins from ")
{
let description = format!(
"Import enabled plugins from {}",
display_path_for(std::path::Path::new(source), cwd)
);
if let Some(details) = &item.details {
let marketplace_count = details.plugins.len();
let plugin_count = details
.plugins
.iter()
.map(|plugin_group| plugin_group.plugin_names.len())
.sum::<usize>();
return format!(
"{description} ({marketplace_count} {}, {plugin_count} {})",
if marketplace_count == 1 {
"marketplace"
} else {
"marketplaces"
},
if plugin_count == 1 {
"plugin"
} else {
"plugins"
}
);
}
return description;
}
item.description.clone()
}
fn new(
request_frame: FrameRequester,
items: &[ExternalAgentConfigMigrationItem],
selected_items: &[ExternalAgentConfigMigrationItem],
error: Option<String>,
) -> Self {
let items = items
.iter()
.cloned()
.map(|item| MigrationSelection {
enabled: selected_items.contains(&item),
item,
})
.collect::<Vec<_>>();
let selected_item_idx = (!items.is_empty()).then_some(0);
Self {
request_frame,
items,
selected_item_idx,
scroll_top: 0,
focus: FocusArea::Items,
highlighted_action: ActionMenuOption::Proceed,
done: false,
outcome: ExternalAgentConfigMigrationOutcome::Skip,
error,
}
}
fn plugin_detail_lines(plugin_groups: &[PluginsMigration]) -> Vec<Line<'static>> {
let mut lines = plugin_groups
.iter()
.take(3)
.map(|plugin_group| {
let mut plugin_names = plugin_group
.plugin_names
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>();
let hidden_plugin_count = plugin_group
.plugin_names
.len()
.saturating_sub(plugin_names.len());
if hidden_plugin_count > 0 {
plugin_names.push(format!("+{hidden_plugin_count} more"));
}
Line::from(format!(
"{}: {}",
plugin_group.marketplace_name,
plugin_names.join(", ")
))
})
.collect::<Vec<_>>();
let hidden_marketplace_count = plugin_groups.len().saturating_sub(lines.len());
if hidden_marketplace_count > 0 {
lines.push(Line::from(format!(
" • +{hidden_marketplace_count} more marketplaces"
)));
}
lines
}
fn is_done(&self) -> bool {
self.done
}
fn outcome(&self) -> ExternalAgentConfigMigrationOutcome {
self.outcome.clone()
}
fn finish_with(&mut self, outcome: ExternalAgentConfigMigrationOutcome) {
self.outcome = outcome;
self.done = true;
self.request_frame.schedule_frame();
}
fn proceed(&mut self) {
let selected = self.selected_items();
if selected.is_empty() {
self.error = Some("Select at least one item or choose a skip option.".to_string());
self.request_frame.schedule_frame();
return;
}
self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected));
}
fn skip(&mut self) {
self.finish_with(ExternalAgentConfigMigrationOutcome::Skip);
}
fn skip_forever(&mut self) {
self.finish_with(ExternalAgentConfigMigrationOutcome::SkipForever);
}
fn exit(&mut self) {
self.finish_with(ExternalAgentConfigMigrationOutcome::Exit);
}
fn selected_items(&self) -> Vec<ExternalAgentConfigMigrationItem> {
self.items
.iter()
.filter(|item| item.enabled)
.map(|item| item.item.clone())
.collect()
}
fn selected_count(&self) -> usize {
self.items.iter().filter(|item| item.enabled).count()
}
fn set_all_enabled(&mut self, enabled: bool) {
for item in &mut self.items {
item.enabled = enabled;
}
self.error = None;
self.request_frame.schedule_frame();
}
fn toggle_selected_item(&mut self) {
if self.focus != FocusArea::Items {
return;
}
let Some(selected_idx) = self.selected_item_idx else {
return;
};
let Some(item) = self.items.get_mut(selected_idx) else {
return;
};
item.enabled = !item.enabled;
self.error = None;
self.request_frame.schedule_frame();
}
fn move_up(&mut self) {
match self.focus {
FocusArea::Items => match self.selected_item_idx {
Some(0) => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
}
Some(idx) => {
self.selected_item_idx = Some(idx.saturating_sub(1));
}
None => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
}
},
FocusArea::Actions => {
if let Some(previous) = self.highlighted_action.previous() {
self.highlighted_action = previous;
} else {
self.focus = FocusArea::Items;
if !self.items.is_empty() {
self.selected_item_idx = Some(self.items.len() - 1);
}
}
}
}
self.ensure_selected_item_visible();
self.request_frame.schedule_frame();
}
fn move_down(&mut self) {
match self.focus {
FocusArea::Items => match self.selected_item_idx {
Some(idx) if idx + 1 < self.items.len() => {
self.selected_item_idx = Some(idx + 1);
}
_ => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::Proceed;
}
},
FocusArea::Actions => {
if let Some(next) = self.highlighted_action.next() {
self.highlighted_action = next;
} else {
self.focus = FocusArea::Items;
if !self.items.is_empty() {
self.selected_item_idx = Some(0);
}
}
}
}
self.ensure_selected_item_visible();
self.request_frame.schedule_frame();
}
fn confirm_selection(&mut self) {
match self.focus {
FocusArea::Items => self.toggle_selected_item(),
FocusArea::Actions => match self.highlighted_action {
ActionMenuOption::Proceed => self.proceed(),
ActionMenuOption::Skip => self.skip(),
ActionMenuOption::SkipForever => self.skip_forever(),
},
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if is_ctrl_exit_combo(key_event) {
self.exit();
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.move_down(),
KeyCode::Char('1') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::Proceed;
self.proceed();
}
KeyCode::Char('2') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::Skip;
self.skip();
}
KeyCode::Char('3') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
self.skip_forever();
}
KeyCode::Char(' ') => self.toggle_selected_item(),
KeyCode::Char('a') => self.set_all_enabled(/*enabled*/ true),
KeyCode::Char('n') => self.set_all_enabled(/*enabled*/ false),
KeyCode::Enter => self.confirm_selection(),
KeyCode::Esc => self.skip(),
_ => {}
}
}
fn ensure_selected_item_visible(&mut self) {
let Some(selected_idx) = self.selected_item_idx else {
self.scroll_top = 0;
return;
};
let selected_render_idx = self.selected_render_line_index(selected_idx);
let visible_rows = self.render_line_count().max(1);
if selected_render_idx < self.scroll_top {
self.scroll_top = selected_render_idx;
} else {
let bottom = self.scroll_top + visible_rows.saturating_sub(1);
if selected_render_idx > bottom {
self.scroll_top = selected_render_idx + 1 - visible_rows;
}
}
}
fn render_line_count(&self) -> usize {
self.build_render_lines().len()
}
fn selected_render_line_index(&self, selected_item_idx: usize) -> usize {
self.build_render_lines()
.iter()
.position(|entry| entry.item_idx == Some(selected_item_idx))
.unwrap_or(selected_item_idx)
}
fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> {
match cwd {
Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]),
None => Line::from("Home".bold()),
}
}
fn build_render_lines(&self) -> Vec<RenderLineEntry> {
let mut lines = Vec::new();
let mut current_scope: Option<Option<&std::path::Path>> = None;
for (idx, item) in self.items.iter().enumerate() {
let scope = item.item.cwd.as_deref();
if current_scope != Some(scope) {
if current_scope.is_some() {
lines.push(RenderLineEntry {
item_idx: None,
kind: RenderLineKind::Section,
line: Line::from(""),
});
}
lines.push(RenderLineEntry {
item_idx: None,
kind: RenderLineKind::Section,
line: Self::section_title(scope),
});
current_scope = Some(scope);
}
lines.push(RenderLineEntry {
item_idx: Some(idx),
kind: RenderLineKind::Item,
line: Line::from(format!(
" [{}] {}",
if item.enabled { "x" } else { " " },
Self::display_description(&item.item)
)),
});
if let Some(details) = &item.item.details {
for line in Self::plugin_detail_lines(&details.plugins) {
lines.push(RenderLineEntry {
item_idx: None,
kind: RenderLineKind::ItemDetail,
line,
});
}
}
}
lines
}
fn render_items(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let rows = self.build_render_lines();
let visible_rows = area.height as usize;
let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1));
if let Some(selected_item_idx) = self.selected_item_idx {
let selected_render_idx = self.selected_render_line_index(selected_item_idx);
if selected_render_idx < start_idx {
start_idx = selected_render_idx;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if selected_render_idx > bottom {
start_idx = selected_render_idx + 1 - visible_rows;
}
}
}
let mut y = area.y;
for entry in rows.iter().skip(start_idx).take(visible_rows) {
if y >= area.y + area.height {
break;
}
let selected =
self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx;
let mut line = entry.line.clone();
if selected {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.cyan().bold();
});
} else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize);
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
}
}
impl WidgetRef for &ExternalAgentConfigMigrationScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2));
let error_height = u16::from(self.error.is_some());
let fixed_height = 1u16 + 2u16 + error_height + 1u16 + 4u16 + 1u16;
let list_height =
self.render_line_count()
.max(1)
.min(inner_area.height.saturating_sub(fixed_height) as usize) as u16;
let [
header_area,
intro_area,
error_area,
list_area,
list_gap_area,
actions_area,
footer_area,
_spacer_area,
] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(error_height),
Constraint::Length(list_height),
Constraint::Length(1),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner_area);
let heading = Line::from(vec![
"> ".into(),
"Migratable external agent config detected".bold(),
]);
heading.render(header_area, buf);
Paragraph::new(vec![
Line::from("We found settings from another agent that you can add to this project."),
Line::from("Select what to import"),
])
.wrap(Wrap { trim: false })
.render(intro_area, buf);
if let Some(error) = &self.error {
Paragraph::new(error.clone().red().to_string())
.wrap(Wrap { trim: false })
.render(error_area, buf);
}
self.render_items(list_area, buf);
Clear.render(list_gap_area, buf);
let [
actions_intro_area,
proceed_area,
skip_area,
skip_forever_area,
] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(actions_area);
let actions_intro = format!(
"Selected {} of {} item(s).",
self.selected_count(),
self.items.len()
);
Paragraph::new(actions_intro)
.wrap(Wrap { trim: false })
.render(actions_intro_area, buf);
selection_option_row_with_dim(
/*index*/ 0,
ActionMenuOption::Proceed.label().to_string(),
self.focus == FocusArea::Actions
&& self.highlighted_action == ActionMenuOption::Proceed,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(proceed_area, buf);
selection_option_row_with_dim(
/*index*/ 1,
ActionMenuOption::Skip.label().to_string(),
self.focus == FocusArea::Actions && self.highlighted_action == ActionMenuOption::Skip,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(skip_area, buf);
selection_option_row_with_dim(
/*index*/ 2,
ActionMenuOption::SkipForever.label().to_string(),
self.focus == FocusArea::Actions
&& self.highlighted_action == ActionMenuOption::SkipForever,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(skip_forever_area, buf);
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".dim(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".dim(),
"1".cyan(),
"/".dim(),
"2".cyan(),
"/".dim(),
"3".cyan(),
" to choose, ".dim(),
"a".cyan(),
"/".dim(),
"n".cyan(),
" for all/none".dim(),
])
.render(footer_area, buf);
}
}
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
}
#[cfg(test)]
mod tests {
use super::ActionMenuOption;
use super::ExternalAgentConfigMigrationOutcome;
use super::ExternalAgentConfigMigrationScreen;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::PluginsMigration;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use std::path::PathBuf;
fn sample_items() -> Vec<ExternalAgentConfigMigrationItem> {
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description:
"Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml"
.to_string(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: "Import enabled plugins from /workspace/project/.claude/settings.json"
.to_string(),
cwd: Some(PathBuf::from("/workspace/project")),
details: Some(codex_app_server_protocol::MigrationDetails {
plugins: vec![
PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec![
"deployer".to_string(),
"formatter".to_string(),
"lint".to_string(),
],
},
PluginsMigration {
marketplace_name: "team-marketplace".to_string(),
plugin_names: vec!["asana".to_string()],
},
PluginsMigration {
marketplace_name: "debug".to_string(),
plugin_names: vec!["sample".to_string()],
},
PluginsMigration {
marketplace_name: "data-tools".to_string(),
plugin_names: vec!["warehouse".to_string()],
},
],
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "Import /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md"
.to_string(),
cwd: Some(PathBuf::from("/workspace/project")),
details: None,
},
]
}
fn render_screen(
screen: &ExternalAgentConfigMigrationScreen,
width: u16,
height: u16,
) -> String {
let backend = VT100Backend::new(width, height);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
{
let mut frame = terminal.get_frame();
frame.render_widget_ref(screen, frame.area());
}
terminal.flush().expect("flush");
terminal.backend().to_string()
}
#[test]
fn prompt_snapshot() {
let items = sample_items();
let screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20);
assert_snapshot!("external_agent_config_migration_prompt", rendered);
}
#[test]
fn proceed_returns_selected_items() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(
screen.outcome(),
ExternalAgentConfigMigrationOutcome::Proceed(items)
);
}
#[test]
fn toggle_item_then_proceed_keeps_remaining_selection() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(
screen.outcome(),
ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone()])
);
}
#[test]
fn escape_skips_prompt() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip);
}
#[test]
fn skip_forever_returns_skip_forever_outcome() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.move_down();
screen.move_down();
screen.move_down();
screen.move_down();
screen.confirm_selection();
assert_eq!(
screen.outcome(),
ExternalAgentConfigMigrationOutcome::SkipForever
);
}
#[test]
fn proceed_requires_at_least_one_selected_item() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!screen.is_done());
assert_eq!(screen.highlighted_action, ActionMenuOption::Proceed);
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20);
assert!(
rendered.contains("Select at least one item or choose a skip option."),
"expected inline validation error, got:\n{rendered}"
);
}
#[test]
fn numeric_shortcuts_choose_actions() {
let items = sample_items();
let mut proceed_screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
proceed_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(
proceed_screen.outcome(),
ExternalAgentConfigMigrationOutcome::Proceed(items.clone())
);
let mut skip_screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(
skip_screen.outcome(),
ExternalAgentConfigMigrationOutcome::Skip
);
let mut skip_forever_screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
skip_forever_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert_eq!(
skip_forever_screen.outcome(),
ExternalAgentConfigMigrationOutcome::SkipForever
);
}
}

View File

@@ -0,0 +1,541 @@
use crate::app_server_session::AppServerSession;
use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome;
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_features::Feature;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::BTreeSet;
use std::path::Path;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use toml::Value as TomlValue;
const EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS: i64 = 5 * 24 * 60 * 60;
pub(crate) enum ExternalAgentConfigMigrationStartupOutcome {
Continue { success_message: Option<String> },
ExitRequested,
}
fn external_config_migration_project_key(path: &Path) -> String {
path.display().to_string()
}
fn is_external_config_migration_scope_hidden(config: &Config, cwd: Option<&Path>) -> bool {
match cwd {
Some(cwd) => config
.notices
.external_config_migration_prompts
.projects
.get(&external_config_migration_project_key(cwd))
.copied()
.unwrap_or(false),
None => config
.notices
.external_config_migration_prompts
.home
.unwrap_or(false),
}
}
fn external_config_migration_last_prompted_at(config: &Config, cwd: Option<&Path>) -> Option<i64> {
match cwd {
Some(cwd) => config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.get(&external_config_migration_project_key(cwd))
.copied(),
None => {
config
.notices
.external_config_migration_prompts
.home_last_prompted_at
}
}
}
fn is_external_config_migration_scope_cooling_down(
config: &Config,
cwd: Option<&Path>,
now_unix_seconds: i64,
) -> bool {
external_config_migration_last_prompted_at(config, cwd).is_some_and(|last_prompted_at| {
last_prompted_at.saturating_add(EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS)
> now_unix_seconds
})
}
fn visible_external_agent_config_migration_items(
config: &Config,
items: Vec<ExternalAgentConfigMigrationItem>,
now_unix_seconds: i64,
) -> Vec<ExternalAgentConfigMigrationItem> {
items
.into_iter()
.filter(|item| {
!is_external_config_migration_scope_hidden(config, item.cwd.as_deref())
&& !is_external_config_migration_scope_cooling_down(
config,
item.cwd.as_deref(),
now_unix_seconds,
)
})
.collect()
}
fn external_agent_config_migration_success_message(
items: &[ExternalAgentConfigMigrationItem],
) -> String {
if items.iter().any(|item| {
item.item_type == codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Plugins
}) {
"External config migration completed. Plugin migration is still in progress and may take a few minutes."
.to_string()
} else {
"External config migration completed successfully.".to_string()
}
}
fn unix_seconds_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
async fn persist_external_agent_config_migration_prompt_shown(
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
now_unix_seconds: i64,
) -> Result<()> {
let mut edits = Vec::new();
if items.iter().any(|item| item.cwd.is_none()) {
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(now_unix_seconds),
);
}
for project in items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
{
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
project,
now_unix_seconds,
),
);
}
if edits.is_empty() {
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt timestamp")?;
if items.iter().any(|item| item.cwd.is_none()) {
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(now_unix_seconds);
}
for project in items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
{
config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.insert(project, now_unix_seconds);
}
Ok(())
}
async fn persist_external_agent_config_migration_prompt_dismissal(
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
) -> Result<()> {
let hide_home = items.iter().any(|item| item.cwd.is_none());
let projects = items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
.collect::<BTreeSet<_>>();
let mut edits = Vec::new();
if hide_home
&& !config
.notices
.external_config_migration_prompts
.home
.unwrap_or(false)
{
edits.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
));
}
for project in &projects {
if !config
.notices
.external_config_migration_prompts
.projects
.get(project)
.copied()
.unwrap_or(false)
{
edits.push(
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project.clone(),
true,
),
);
}
}
if edits.is_empty() {
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt preference")?;
if hide_home {
config.notices.external_config_migration_prompts.home = Some(true);
}
for project in projects {
config
.notices
.external_config_migration_prompts
.projects
.insert(project, true);
}
Ok(())
}
pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed(
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
config: &mut Config,
cli_kv_overrides: &[(String, TomlValue)],
harness_overrides: &ConfigOverrides,
) -> Result<ExternalAgentConfigMigrationStartupOutcome> {
if !config.features.enabled(Feature::ExternalMigrate) {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
let now_unix_seconds = unix_seconds_now();
let detected_items = match app_server
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![config.cwd.to_path_buf()]),
})
.await
{
Ok(response) => {
visible_external_agent_config_migration_items(config, response.items, now_unix_seconds)
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to detect external agent config migrations; continuing startup"
);
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
};
if detected_items.is_empty() {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
if let Err(err) = persist_external_agent_config_migration_prompt_shown(
config,
&detected_items,
now_unix_seconds,
)
.await
{
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to persist external config migration prompt timestamp"
);
}
let mut selected_items = detected_items.clone();
let mut error: Option<String> = None;
loop {
match run_external_agent_config_migration_prompt(
tui,
&detected_items,
&selected_items,
error.as_deref(),
)
.await
{
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
selected_items = items.clone();
match app_server.external_agent_config_import(items).await {
Ok(_) => {
let success_message =
external_agent_config_migration_success_message(&selected_items);
*config = ConfigBuilder::default()
.codex_home(config.codex_home.to_path_buf())
.cli_overrides(cli_kv_overrides.to_vec())
.harness_overrides(harness_overrides.clone())
.build()
.await
.wrap_err("Failed to reload config after external agent migration")?;
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: Some(success_message),
});
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to import external agent config migration items"
);
error = Some(format!("Migration failed: {err}"));
}
}
}
ExternalAgentConfigMigrationOutcome::Skip => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
ExternalAgentConfigMigrationOutcome::SkipForever => {
match persist_external_agent_config_migration_prompt_dismissal(
config,
&detected_items,
)
.await
{
Ok(()) => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to persist external config migration prompt dismissal"
);
error = Some(format!("Failed to save preference: {err}"));
}
}
}
ExternalAgentConfigMigrationOutcome::Exit => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::ExitRequested);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
#[tokio::test]
async fn visible_external_agent_config_migration_items_omits_hidden_scopes() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config.notices.external_config_migration_prompts.home = Some(true);
config
.notices
.external_config_migration_prompts
.projects
.insert("/tmp/project".to_string(), true);
let visible = visible_external_agent_config_migration_items(
&config,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: "home".to_string(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "project".to_string(),
cwd: Some(PathBuf::from("/tmp/project")),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
},
],
/*now_unix_seconds*/ 1_760_000_000,
);
assert_eq!(
visible,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
}]
);
}
#[tokio::test]
async fn visible_external_agent_config_migration_items_omits_recently_prompted_scopes() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(1_760_000_000);
config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.insert("/tmp/project".to_string(), 1_760_000_000);
let visible = visible_external_agent_config_migration_items(
&config,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: "home".to_string(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "project".to_string(),
cwd: Some(PathBuf::from("/tmp/project")),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
},
],
/*now_unix_seconds*/
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1,
);
assert_eq!(
visible,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
}]
);
}
#[tokio::test]
async fn external_config_migration_scope_cooldown_expires_after_five_days() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(1_760_000_000);
assert!(is_external_config_migration_scope_cooling_down(
&config,
/*cwd*/ None,
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1,
));
assert!(!is_external_config_migration_scope_cooling_down(
&config,
/*cwd*/ None,
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS,
));
}
#[test]
fn external_agent_config_migration_success_message_mentions_plugins_when_present() {
let message = external_agent_config_migration_success_message(&[
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
details: None,
},
]);
assert_eq!(
message,
"External config migration completed. Plugin migration is still in progress and may take a few minutes."
);
}
#[test]
fn external_agent_config_migration_success_message_omits_plugins_copy_when_absent() {
let message =
external_agent_config_migration_success_message(&[ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: None,
details: None,
}]);
assert_eq!(message, "External config migration completed successfully.");
}
}

View File

@@ -118,6 +118,8 @@ mod debug_config;
mod diff_render;
mod exec_cell;
mod exec_command;
mod external_agent_config_migration;
mod external_agent_config_migration_startup;
mod external_editor;
mod file_search;
mod frames;

View File

@@ -0,0 +1,25 @@
---
source: tui/src/external_agent_config_migration.rs
assertion_line: 757
expression: rendered
---
> Migratable external agent config detected
We found settings from another agent that you can add to this project.
Select what to import
Home
[x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con…
Project: /workspace/project
[x] Import enabled plugins from .claude/settings.json (4 marketplaces, 6 p…
• acme-tools: deployer, formatter, +1 more
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Import CLAUDE.md to AGENTS.md
Selected 3 of 3 item(s).
1. Proceed with selected
2. Skip for now
3. Don't ask again for these locations
Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none