mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
12 Commits
windows-sa
...
etraut/tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e21ccdc9 | ||
|
|
46d199e702 | ||
|
|
6cad0a38c5 | ||
|
|
d9a96f5974 | ||
|
|
d417e09fc8 | ||
|
|
9521fa3497 | ||
|
|
6759e3266b | ||
|
|
369fc518d4 | ||
|
|
e3105f2b8e | ||
|
|
14ec2b0b8f | ||
|
|
522b8e5a51 | ||
|
|
a9a977ed8b |
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
976
codex-rs/tui/src/external_agent_config_migration.rs
Normal file
976
codex-rs/tui/src/external_agent_config_migration.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
541
codex-rs/tui/src/external_agent_config_migration_startup.rs
Normal file
541
codex-rs/tui/src/external_agent_config_migration_startup.rs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user