[TUI] add external config migration prompt when start TUI

This commit is contained in:
alexsong-oai
2026-04-14 21:33:02 -07:00
parent 522b8e5a51
commit 14ec2b0b8f
10 changed files with 547 additions and 46 deletions
+63 -19
View File
@@ -138,22 +138,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(
@@ -174,6 +163,8 @@ impl ExternalAgentConfigService {
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let configured_enabled_plugins =
configured_enabled_plugin_ids(&self.codex_home, repo_root)?;
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.external_agent_home.join("settings.json"),
@@ -224,6 +215,7 @@ impl ExternalAgentConfigService {
source_settings.as_path(),
cwd.clone(),
settings.as_ref(),
&configured_enabled_plugins,
items,
);
@@ -299,9 +291,12 @@ impl ExternalAgentConfigService {
source_settings: &Path,
cwd: Option<PathBuf>,
settings: Option<&JsonValue>,
configured_enabled_plugins: &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_enabled_plugins)
}) else {
return;
};
@@ -537,9 +532,15 @@ 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_enabled_plugins: &HashSet<String>,
) -> Option<MigrationDetails> {
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_enabled_plugins.contains(plugin_id))
{
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
@@ -591,6 +592,49 @@ fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
.collect()
}
fn configured_enabled_plugin_ids(
codex_home: &Path,
repo_root: Option<&Path>,
) -> io::Result<HashSet<String>> {
let mut plugin_states = BTreeMap::new();
for config_path in [
Some(codex_home.join("config.toml")),
repo_root.map(|repo_root| repo_root.join(".codex").join("config.toml")),
]
.into_iter()
.flatten()
{
if !config_path.is_file() {
continue;
}
let raw_config = fs::read_to_string(&config_path)?;
if raw_config.trim().is_empty() {
continue;
}
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 {
continue;
};
for (plugin_id, enabled) in plugins.iter().filter_map(|(plugin_id, plugin_config)| {
plugin_config
.get("enabled")
.and_then(TomlValue::as_bool)
.map(|enabled| (plugin_id, enabled))
}) {
plugin_states.insert(plugin_id.clone(), enabled);
}
}
Ok(plugin_states
.into_iter()
.filter_map(|(plugin_id, enabled)| enabled.then_some(plugin_id))
.collect())
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
) -> BTreeMap<String, MarketplaceImportSource> {
@@ -449,6 +449,60 @@ fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[test]
fn detect_repo_skips_plugins_that_are_already_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,
"deployer@acme-tools": true
}
}"#,
)
.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()],
}],
}),
}]
);
}
#[tokio::test]
async fn import_plugins_requires_details() {
let (_root, claude_home, codex_home) = fixture_paths();