mirror of
https://github.com/openai/codex.git
synced 2026-11-02 16:20:35 +00:00
[TUI] add external config migration prompt when start TUI (#17891)
- add a TUI startup migration prompt for external agent config - support migrating external configs including config, skills, AGENTS.md and plugins - gate the prompt behind features.external_migrate (default false) <img width="1037" height="480" alt="Screenshot 2026-04-14 at 9 29 14 PM" src="https://github.com/user-attachments/assets/6060849b-03cb-429a-9c13-c7bb46ad2e65" /> <img width="713" height="183" alt="Screenshot 2026-04-14 at 9 29 26 PM" src="https://github.com/user-attachments/assets/d13f177e-d4c4-479c-8736-ef29636081e1" /> --------- Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
1 parent
370bed4bf4
commit
93ff798e5b
14 files changed
+2003
-14
No files matched your search
@@ -592,7 +592,23 @@ const fn default_true() -> bool {
|
|||||||
/// Settings for notices we display to users via the tui and app-server clients
|
/// Settings for notices we display to users via the tui and app-server clients
|
||||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
/// 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)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||||
|
#[schemars(deny_unknown_fields)]
|
||||||
pub struct Notice {
|
pub struct Notice {
|
||||||
/// Tracks whether the user has acknowledged the full access warning prompt.
|
/// Tracks whether the user has acknowledged the full access warning prompt.
|
||||||
pub hide_full_access_warning: Option<bool>,
|
pub hide_full_access_warning: Option<bool>,
|
||||||
@@ -608,6 +624,9 @@ pub struct Notice {
|
|||||||
/// Tracks acknowledged model migrations as old->new model slug mappings.
|
/// Tracks acknowledged model migrations as old->new model slug mappings.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model_migrations: BTreeMap<String, String>,
|
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;
|
pub use crate::skills_config::BundledSkillsConfig;
|
||||||
|
|||||||
@@ -392,6 +392,9 @@
|
|||||||
"experimental_windows_sandbox": {
|
"experimental_windows_sandbox": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"external_migration": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"fast_mode": {
|
"fast_mode": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -636,6 +639,39 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"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": {
|
"FeatureToml_for_MultiAgentV2ConfigToml": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1153,8 +1189,22 @@
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"Notice": {
|
"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": {
|
"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": {
|
"hide_full_access_warning": {
|
||||||
"description": "Tracks whether the user has acknowledged the full access warning prompt.",
|
"description": "Tracks whether the user has acknowledged the full access warning prompt.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2288,6 +2338,9 @@
|
|||||||
"experimental_windows_sandbox": {
|
"experimental_windows_sandbox": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"external_migration": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"fast_mode": {
|
"fast_mode": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ pub enum ConfigEdit {
|
|||||||
SetWindowsWslSetupAcknowledged(bool),
|
SetWindowsWslSetupAcknowledged(bool),
|
||||||
/// Toggle the model migration prompt acknowledgement flag.
|
/// Toggle the model migration prompt acknowledgement flag.
|
||||||
SetNoticeHideModelMigrationPrompt(String, bool),
|
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.
|
/// Record that a migration prompt was shown for an old->new model mapping.
|
||||||
RecordModelMigrationSeen { from: String, to: String },
|
RecordModelMigrationSeen { from: String, to: String },
|
||||||
/// Replace the entire `[mcp_servers]` table.
|
/// Replace the entire `[mcp_servers]` table.
|
||||||
@@ -421,6 +429,53 @@ impl ConfigDocument {
|
|||||||
value(*acknowledged),
|
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(
|
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
|
||||||
Scope::Global,
|
Scope::Global,
|
||||||
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
|
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
|
||||||
@@ -919,6 +974,28 @@ impl ConfigEditsBuilder {
|
|||||||
self
|
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 {
|
pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
|
||||||
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
|
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
|
|||||||
@@ -552,6 +552,130 @@ gpt-5 = "gpt-5.1"
|
|||||||
assert_eq!(contents, expected);
|
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]
|
#[test]
|
||||||
fn blocking_replace_mcp_servers_round_trips() {
|
fn blocking_replace_mcp_servers_round_trips() {
|
||||||
let tmp = tempdir().expect("tmpdir");
|
let tmp = tempdir().expect("tmpdir");
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ impl ExternalAgentConfigService {
|
|||||||
items.push(ExternalAgentConfigMigrationItem {
|
items.push(ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
source_agents_md.display(),
|
source_agents_md.display(),
|
||||||
target_agents_md.display()
|
target_agents_md.display()
|
||||||
),
|
),
|
||||||
@@ -357,7 +357,7 @@ impl ExternalAgentConfigService {
|
|||||||
|
|
||||||
Some(ExternalAgentConfigMigrationItem {
|
Some(ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!("Import enabled plugins from {}", source_settings.display()),
|
description: format!("Migrate enabled plugins from {}", source_settings.display()),
|
||||||
cwd,
|
cwd,
|
||||||
details: Some(plugin_details),
|
details: Some(plugin_details),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async fn detect_home_lists_config_skills_and_agents_md() {
|
|||||||
ExternalAgentConfigMigrationItem {
|
ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
external_agent_home.join("CLAUDE.md").display(),
|
external_agent_home.join("CLAUDE.md").display(),
|
||||||
codex_home.join("AGENTS.md").display()
|
codex_home.join("AGENTS.md").display()
|
||||||
),
|
),
|
||||||
@@ -107,7 +107,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
|
|||||||
ExternalAgentConfigMigrationItem {
|
ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
repo_root.join("CLAUDE.md").display(),
|
repo_root.join("CLAUDE.md").display(),
|
||||||
repo_root.join("AGENTS.md").display(),
|
repo_root.join("AGENTS.md").display(),
|
||||||
),
|
),
|
||||||
@@ -117,7 +117,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
|
|||||||
ExternalAgentConfigMigrationItem {
|
ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
repo_root.join("CLAUDE.md").display(),
|
repo_root.join("CLAUDE.md").display(),
|
||||||
repo_root.join("AGENTS.md").display(),
|
repo_root.join("AGENTS.md").display(),
|
||||||
),
|
),
|
||||||
@@ -194,7 +194,7 @@ async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid(
|
|||||||
ExternalAgentConfigMigrationItem {
|
ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
repo_root.join(".claude").join("CLAUDE.md").display(),
|
repo_root.join(".claude").join("CLAUDE.md").display(),
|
||||||
repo_root.join("AGENTS.md").display(),
|
repo_root.join("AGENTS.md").display(),
|
||||||
),
|
),
|
||||||
@@ -566,7 +566,7 @@ async fn detect_repo_prefers_non_empty_external_agent_agents_source() {
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import {} to {}",
|
"Migrate {} to {}",
|
||||||
repo_root.join(".claude").join("CLAUDE.md").display(),
|
repo_root.join(".claude").join("CLAUDE.md").display(),
|
||||||
repo_root.join("AGENTS.md").display(),
|
repo_root.join("AGENTS.md").display(),
|
||||||
),
|
),
|
||||||
@@ -650,7 +650,7 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
external_agent_home.join("settings.json").display()
|
external_agent_home.join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
@@ -710,7 +710,7 @@ enabled = true
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
repo_root.join(".claude").join("settings.json").display()
|
repo_root.join(".claude").join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: Some(repo_root),
|
cwd: Some(repo_root),
|
||||||
@@ -868,7 +868,7 @@ enabled = true
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
repo_root.join(".claude").join("settings.json").display()
|
repo_root.join(".claude").join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: Some(repo_root),
|
cwd: Some(repo_root),
|
||||||
@@ -1048,7 +1048,7 @@ source = "owner/debug-marketplace"
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
repo_root.join(".claude").join("settings.json").display()
|
repo_root.join(".claude").join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: Some(repo_root),
|
cwd: Some(repo_root),
|
||||||
@@ -1275,7 +1275,7 @@ async fn detect_home_supports_relative_external_agent_plugin_marketplace_path()
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
external_agent_home.join("settings.json").display()
|
external_agent_home.join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
@@ -1426,7 +1426,7 @@ async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace
|
|||||||
vec![ExternalAgentConfigMigrationItem {
|
vec![ExternalAgentConfigMigrationItem {
|
||||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||||
description: format!(
|
description: format!(
|
||||||
"Import enabled plugins from {}",
|
"Migrate enabled plugins from {}",
|
||||||
repo_root.join(".claude").join("settings.json").display()
|
repo_root.join(".claude").join("settings.json").display()
|
||||||
),
|
),
|
||||||
cwd: Some(repo_root),
|
cwd: Some(repo_root),
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ pub enum Feature {
|
|||||||
ToolSuggest,
|
ToolSuggest,
|
||||||
/// Enable plugins.
|
/// Enable plugins.
|
||||||
Plugins,
|
Plugins,
|
||||||
|
/// Show the startup prompt for migrating external agent config into Codex.
|
||||||
|
ExternalMigration,
|
||||||
/// Allow the model to invoke the built-in image generation tool.
|
/// Allow the model to invoke the built-in image generation tool.
|
||||||
ImageGeneration,
|
ImageGeneration,
|
||||||
/// Allow prompting and installing missing MCP dependencies.
|
/// Allow prompting and installing missing MCP dependencies.
|
||||||
@@ -844,6 +846,16 @@ pub const FEATURES: &[FeatureSpec] = &[
|
|||||||
stage: Stage::Stable,
|
stage: Stage::Stable,
|
||||||
default_enabled: true,
|
default_enabled: true,
|
||||||
},
|
},
|
||||||
|
FeatureSpec {
|
||||||
|
id: Feature::ExternalMigration,
|
||||||
|
key: "external_migration",
|
||||||
|
stage: Stage::Experimental {
|
||||||
|
name: "External migration",
|
||||||
|
menu_description: "Show a startup prompt when Codex detects migratable external agent config for this machine or project.",
|
||||||
|
announcement: "",
|
||||||
|
},
|
||||||
|
default_enabled: false,
|
||||||
|
},
|
||||||
FeatureSpec {
|
FeatureSpec {
|
||||||
id: Feature::ImageGeneration,
|
id: Feature::ImageGeneration,
|
||||||
key: "image_generation",
|
key: "image_generation",
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ fn guardian_approval_is_experimental_and_user_toggleable() {
|
|||||||
assert_eq!(Feature::GuardianApproval.default_enabled(), false);
|
assert_eq!(Feature::GuardianApproval.default_enabled(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_migration_is_experimental_and_disabled_by_default() {
|
||||||
|
let spec = Feature::ExternalMigration.info();
|
||||||
|
let stage = spec.stage;
|
||||||
|
|
||||||
|
assert!(matches!(stage, Stage::Experimental { .. }));
|
||||||
|
assert_eq!(stage.experimental_menu_name(), Some("External migration"));
|
||||||
|
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::ExternalMigration.default_enabled(), false);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn request_permissions_is_under_development() {
|
fn request_permissions_is_under_development() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ use crate::cwd_prompt::CwdPromptAction;
|
|||||||
use crate::diff_render::DiffSummary;
|
use crate::diff_render::DiffSummary;
|
||||||
use crate::exec_command::split_command_string;
|
use crate::exec_command::split_command_string;
|
||||||
use crate::exec_command::strip_bash_lc_and_escape;
|
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::external_editor;
|
||||||
use crate::file_search::FileSearchManager;
|
use crate::file_search::FileSearchManager;
|
||||||
use crate::history_cell;
|
use crate::history_cell;
|
||||||
@@ -3861,6 +3863,7 @@ impl App {
|
|||||||
session_selection: SessionSelection,
|
session_selection: SessionSelection,
|
||||||
feedback: codex_feedback::CodexFeedback,
|
feedback: codex_feedback::CodexFeedback,
|
||||||
is_first_run: bool,
|
is_first_run: bool,
|
||||||
|
entered_trust_nux: bool,
|
||||||
should_prompt_windows_sandbox_nux_at_startup: bool,
|
should_prompt_windows_sandbox_nux_at_startup: bool,
|
||||||
remote_app_server_url: Option<String>,
|
remote_app_server_url: Option<String>,
|
||||||
remote_app_server_auth_token: Option<String>,
|
remote_app_server_auth_token: Option<String>,
|
||||||
@@ -3878,6 +3881,38 @@ impl App {
|
|||||||
|
|
||||||
let harness_overrides =
|
let harness_overrides =
|
||||||
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
|
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,
|
||||||
|
entered_trust_nux,
|
||||||
|
)
|
||||||
|
.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 bootstrap = app_server.bootstrap(&config).await?;
|
||||||
let mut model = bootstrap.default_model;
|
let mut model = bootstrap.default_model;
|
||||||
let available_models = bootstrap.available_models;
|
let available_models = bootstrap.available_models;
|
||||||
@@ -4048,6 +4083,9 @@ impl App {
|
|||||||
(ChatWidget::new_with_app_event(init), Some(forked))
|
(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
|
chat_widget
|
||||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
.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::ClientRequest;
|
||||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||||
use codex_app_server_protocol::ConfigWriteResponse;
|
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::GetAccountParams;
|
||||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||||
use codex_app_server_protocol::GetAccountResponse;
|
use codex_app_server_protocol::GetAccountResponse;
|
||||||
@@ -286,6 +291,31 @@ impl AppServerSession {
|
|||||||
.wrap_err("account/read failed during TUI bootstrap")
|
.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> {
|
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
||||||
self.client.next_event().await
|
self.client.next_event().await
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,567 @@
|
|||||||
|
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 should_show_external_agent_config_migration_prompt(
|
||||||
|
config: &Config,
|
||||||
|
entered_trust_nux: bool,
|
||||||
|
) -> bool {
|
||||||
|
entered_trust_nux && config.features.enabled(Feature::ExternalMigration)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
entered_trust_nux: bool,
|
||||||
|
) -> Result<ExternalAgentConfigMigrationStartupOutcome> {
|
||||||
|
if !should_show_external_agent_config_migration_prompt(config, entered_trust_nux) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_agent_config_migration_prompt_requires_trust_nux_entry() {
|
||||||
|
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");
|
||||||
|
let _ = config.features.enable(Feature::ExternalMigration);
|
||||||
|
|
||||||
|
assert!(!should_show_external_agent_config_migration_prompt(
|
||||||
|
&config, /*entered_trust_nux*/ false,
|
||||||
|
));
|
||||||
|
assert!(should_show_external_agent_config_migration_prompt(
|
||||||
|
&config, /*entered_trust_nux*/ true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,8 @@ mod debug_config;
|
|||||||
mod diff_render;
|
mod diff_render;
|
||||||
mod exec_cell;
|
mod exec_cell;
|
||||||
mod exec_command;
|
mod exec_command;
|
||||||
|
mod external_agent_config_migration;
|
||||||
|
mod external_agent_config_migration_startup;
|
||||||
mod external_editor;
|
mod external_editor;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
mod frames;
|
mod frames;
|
||||||
@@ -1452,6 +1454,7 @@ async fn run_ratatui_app(
|
|||||||
session_selection,
|
session_selection,
|
||||||
feedback,
|
feedback,
|
||||||
should_show_trust_screen, // Proxy to: is it a first run in this directory?
|
should_show_trust_screen, // Proxy to: is it a first run in this directory?
|
||||||
|
should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding
|
||||||
should_prompt_windows_sandbox_nux_at_startup,
|
should_prompt_windows_sandbox_nux_at_startup,
|
||||||
remote_url,
|
remote_url,
|
||||||
remote_auth_token,
|
remote_auth_token,
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/external_agent_config_migration.rs
|
||||||
|
assertion_line: 824
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
|
||||||
|
> 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] Migrate enabled plugins from /workspace/project/.claude/settings.json…
|
||||||
|
• acme-tools: deployer, formatter, +1 more
|
||||||
|
• team-marketplace: asana
|
||||||
|
• debug: sample
|
||||||
|
• +1 more marketplaces
|
||||||
|
[x] Migrate /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md
|
||||||
|
|
||||||
|
Selected 3 of 3 item(s).
|
||||||
|
1. Proceed with selected
|
||||||
|
2. Skip for now
|
||||||
|
3. Don't ask again
|
||||||
|
Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none
|
||||||
Reference in new issue
Block a user