use super::*;use pretty_assertions::assert_eq;use std::io;use tempfile::TempDir;const EXTERNAL_AGENT_PROJECT_CONFIG_FILE: &str = ".claude.json";const EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR: &str = ".claude-plugin";const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";const SOURCE_EXTERNAL_AGENT_DISPLAY_NAME: &str = "Claude";const SOURCE_EXTERNAL_AGENT_PRODUCT_NAME: &str = "Claude Code";const SOURCE_EXTERNAL_AGENT_UPPER_NAME: &str = "CLAUDE";const SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME: &str = "CLAUDE-CODE";fn fixture_paths() -> (TempDir, PathBuf, PathBuf) { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); let codex_home = root.path().join(".codex"); (root, external_agent_home, codex_home)}fn service_for_paths( external_agent_home: PathBuf, codex_home: PathBuf,) -> ExternalAgentConfigService { ExternalAgentConfigService::new_for_test(codex_home, external_agent_home)}fn github_plugin_details() -> MigrationDetails { MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "acme-tools".to_string(), plugin_names: vec!["formatter".to_string()], }], ..Default::default() }}fn assert_single_plugin_raw_error( raw_errors: &[ExternalAgentConfigImportRawError], failure_stage: &str, source: &str,) { assert_eq!(raw_errors.len(), 1); let raw_error = &raw_errors[0]; assert_eq!( raw_error.item_type, ExternalAgentConfigMigrationItemType::Plugins ); assert_eq!(raw_error.failure_stage, failure_stage); assert_eq!(raw_error.error_type, None); assert_eq!(raw_error.cwd, None); assert_eq!(raw_error.source.as_deref(), Some(source)); assert!(!raw_error.message.is_empty());}fn import_success( item_type: ExternalAgentConfigMigrationItemType, cwd: Option<PathBuf>, source: impl Into<String>, target: impl Into<String>,) -> ExternalAgentConfigImportSuccess { ExternalAgentConfigImportSuccess { item_type, cwd, source: Some(source.into()), target: Some(target.into()), }}#[tokio::test]async fn detect_home_lists_config_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() .map(|parent| parent.join(".agents").join("skills")) .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills"); fs::write( external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_NAME} rules"), ) .expect("write external agent md"); fs::write( external_agent_home.join("settings.json"), format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","env":{{"FOO":"bar"}}}}"#), ) .expect("write settings"); let items = service_for_paths(external_agent_home.clone(), codex_home.clone()) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); let expected = vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: format!( "Migrate {} into {}", external_agent_home.join("settings.json").display(), codex_home.join("config.toml").display() ), cwd: None, details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: format!( "Migrate skills from {} to {}", external_agent_home.join("skills").display(), agents_skills.display() ), cwd: None, details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD).display(), codex_home.join("AGENTS.md").display() ), cwd: None, details: None, }, ]; assert_eq!(items, expected);}#[tokio::test]async fn detect_home_lists_recent_sessions() { let (root, external_agent_home, codex_home) = fixture_paths(); let project_root = root.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_path = external_agent_home .join("projects") .join("repo") .join("session.jsonl"); fs::create_dir_all(&project_root).expect("create project root"); fs::create_dir_all(session_path.parent().expect("session parent")).expect("create sessions"); fs::write( &session_path, serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(), ) .expect("write session"); let items = service_for_paths(external_agent_home.clone(), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Sessions, description: format!( "Migrate recent sessions from {}", external_agent_home.join("projects").display() ), cwd: None, details: Some(MigrationDetails { plugins: Vec::new(), sessions: vec![ExternalAgentSessionMigration { path: session_path, cwd: project_root, title: Some("first request".to_string()), }], ..Default::default() }), }] );}#[tokio::test]async fn detect_repo_lists_agents_md_for_each_cwd() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); let nested = repo_root.join("nested").join("child"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(&nested).expect("create nested"); fs::write( repo_root.join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write source"); let items = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![nested, repo_root.clone()]), }) .await .expect("detect"); let expected = vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(), repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(), repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root), details: None, }, ]; assert_eq!(items, expected);}#[tokio::test]async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); let codex_home = root.path().join(".codex"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all( repo_root .join(EXTERNAL_AGENT_DIR) .join("skills") .join("skill-a"), ) .expect("create repo skills"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write(codex_home.join("config.toml"), "this is not valid = [toml") .expect("write invalid codex config"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, ) .expect("write settings"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("skills") .join("skill-a") .join("SKILL.md"), format!( "Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities." ), ) .expect("write skill"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write agents"); let items = service_for_paths(root.path().join(EXTERNAL_AGENT_DIR), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: format!( "Migrate {} into {}", repo_root .join(EXTERNAL_AGENT_DIR) .join("settings.json") .display(), repo_root.join(".codex").join("config.toml").display() ), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: format!( "Migrate skills from {} to {}", repo_root.join(EXTERNAL_AGENT_DIR).join("skills").display(), repo_root.join(".agents").join("skills").display() ), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", repo_root .join(EXTERNAL_AGENT_DIR) .join(EXTERNAL_AGENT_CONFIG_MD) .display(), repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root), details: None, }, ] );}#[tokio::test]async fn detect_repo_lists_mcp_hooks_commands_and_subagents() { let root = TempDir::new().expect("create tempdir"); 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(EXTERNAL_AGENT_DIR) .join("commands") .join("pr"), ) .expect("create commands"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents"); fs::write( repo_root.join(".mcp.json"), r#"{"mcpServers":{"docs":{"command":"docs-server"}}}"#, ) .expect("write mcp"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"http","url":"https://example.invalid/hook"}]}]}}"#, ) .expect("write hooks"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("commands") .join("pr") .join("review.md"), "---\ndescription: Review PR\n---\nReview the pull request carefully.\n", ) .expect("write command"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("agents") .join("researcher.md"), "---\nname: researcher\ndescription: Research role\n---\nResearch carefully.\n", ) .expect("write subagent"); let items = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( "Migrate MCP servers from {} into {}", repo_root.display(), repo_root.join(".codex").join("config.toml").display() ), cwd: Some(repo_root.clone()), details: Some(MigrationDetails { mcp_servers: vec![NamedMigration { name: "docs".to_string(), }], ..Default::default() }), }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Hooks, description: format!( "Migrate hooks from {} to {}", repo_root.join(EXTERNAL_AGENT_DIR).display(), repo_root.join(".codex").join("hooks.json").display() ), cwd: Some(repo_root.clone()), details: Some(MigrationDetails { hooks: vec![NamedMigration { name: "PreToolUse".to_string(), }], ..Default::default() }), }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Commands, description: format!( "Migrate commands from {} to {}", repo_root .join(EXTERNAL_AGENT_DIR) .join("commands") .display(), repo_root.join(".agents").join("skills").display() ), cwd: Some(repo_root.clone()), details: Some(MigrationDetails { commands: vec![NamedMigration { name: "source-command-pr-review".to_string(), }], ..Default::default() }), }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Subagents, description: format!( "Migrate subagents from {} to {}", repo_root.join(EXTERNAL_AGENT_DIR).join("agents").display(), repo_root.join(".codex").join("agents").display() ), cwd: Some(repo_root), details: Some(MigrationDetails { subagents: vec![NamedMigration { name: "researcher".to_string(), }], ..Default::default() }), }, ] );}#[tokio::test]async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() { let root = TempDir::new().expect("create tempdir"); 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(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, ) .expect("write hooks"); let items = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root]), }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { let root = TempDir::new().expect("create tempdir"); 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(EXTERNAL_AGENT_DIR) .join("commands") .join("pr"), ) .expect("create commands"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents"); fs::write( repo_root.join(".mcp.json"), r#"{ "mcpServers": { "docs": { "command": "docs-server", "args": ["--stdio"], "headers": {"X-Ignored": "unsupported for stdio"}, "env": {"DOCS_TOKEN": "${DOCS_TOKEN}", "STATIC": "yes"} }, "api": { "url": "https://example.com/mcp", "args": ["ignored-for-http"], "env": {"IGNORED": "unsupported for http"}, "headers": { "Authorization": "Bearer ${API_TOKEN}", "X-Team": "${TEAM}" } } } }"#, ) .expect("write mcp"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"prompt","prompt":"skip"}]}],"Stop":[{"matcher":"ignored","hooks":[{"command":"echo done"}]}]}}"#, ) .expect("write hooks"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("commands") .join("pr") .join("review.md"), "---\ndescription: Review PR\n---\nReview the pull request carefully.\n", ) .expect("write command"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("agents") .join("researcher.md"), format!("---\nname: researcher\ndescription: Research role\npermissionMode: acceptEdits\nskills: [deep-research]\ntools: Bash, Read\ndisallowedTools: WebFetch\neffort: high\n---\nResearch with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} carefully.\n"), ) .expect("write subagent"); service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Hooks, description: String::new(), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Commands, description: String::new(), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Subagents, description: String::new(), cwd: Some(repo_root.clone()), details: None, }, ]) .await .expect("import"); let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), ) .expect("parse config"); let expected_config: TomlValue = toml::from_str( r#"[mcp_servers.api]url = "https://example.com/mcp"bearer_token_env_var = "API_TOKEN"[mcp_servers.api.env_http_headers]X-Team = "TEAM"[mcp_servers.docs]command = "docs-server"args = ["--stdio"]env_vars = ["DOCS_TOKEN"][mcp_servers.docs.env]STATIC = "yes""#, ) .expect("parse expected config"); assert_eq!(config, expected_config); let mcp_servers = config .get("mcp_servers") .cloned() .ok_or_else(|| io::Error::other("missing mcp_servers")) .expect("mcp servers"); let _supported_mcp_config: std::collections::HashMap< String, codex_config::types::McpServerConfig, > = mcp_servers .try_into() .expect("migrated MCP config should be supported"); let hooks: JsonValue = serde_json::from_str( &fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"), ) .expect("parse hooks"); let _supported_hooks: codex_config::HooksFile = serde_json::from_value(hooks.clone()).expect("migrated hooks should be supported"); assert_eq!( hooks, serde_json::json!({ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo external-agent", "timeout": 3 }] }], "Stop": [{ "hooks": [{ "type": "command", "command": "echo done" }] }] } }) ); assert!( !repo_root .join(".codex") .join("hooks.migration-notes.md") .exists() ); assert_eq!( fs::read_to_string( repo_root .join(".agents") .join("skills") .join("source-command-pr-review") .join("SKILL.md") ) .expect("read command skill"), "---\nname: \"source-command-pr-review\"\ndescription: \"Review PR\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request carefully.\n" ); let agent: TomlValue = toml::from_str( &fs::read_to_string( repo_root .join(".codex") .join("agents") .join("researcher.toml"), ) .expect("read agent"), ) .expect("parse agent"); let expected_agent: TomlValue = toml::from_str( r#"name = "researcher"description = "Research role"model_reasoning_effort = "high"sandbox_mode = "workspace-write"developer_instructions = """Research with Codex carefully.""""#, ) .expect("parse expected agent"); assert_eq!(agent, expected_agent);}#[tokio::test]async fn import_repo_mcp_preserves_existing_same_named_server() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::write( repo_root.join(".mcp.json"), r#"{ "mcpServers": { "mixedTransport": { "command": "mcp-remote-proxy", "args": [ "https://example.com/mixed-transport", "--transport", "http" ], "url": "https://example.com/mixed-transport" } } }"#, ) .expect("write mcp"); fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); let existing_config = r#"[mcp_servers.mixedTransport]url = "https://example.com/mixed-transport""#; fs::write( repo_root.join(".codex").join("config.toml"), existing_config, ) .expect("write config"); let service = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ); assert_eq!( service .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"), Vec::<ExternalAgentConfigMigrationItem>::new() ); service .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), existing_config );}#[tokio::test]async fn detect_repo_mcp_lists_only_missing_servers() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::write( repo_root.join(".mcp.json"), r#"{ "mcpServers": { "docs": {"command": "docs-server"}, "mixedTransport": {"command": "mcp-remote-proxy"} } }"#, ) .expect("write mcp"); fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); fs::write( repo_root.join(".codex").join("config.toml"), r#"[mcp_servers.mixedTransport]url = "https://example.com/mixed-transport""#, ) .expect("write config"); let items = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( "Migrate MCP servers from {} into {}", repo_root.display(), repo_root.join(".codex").join("config.toml").display() ), cwd: Some(repo_root), details: Some(MigrationDetails { mcp_servers: vec![NamedMigration { name: "docs".to_string(), }], ..Default::default() }), }] );}#[tokio::test]async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() .map(|parent| parent.join(".agents").join("skills")) .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills"); fs::write( external_agent_home.join("settings.json"), format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","permissions":{{"ask":["git push"]}},"env":{{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{{"x":1}}}},"sandbox":{{"enabled":true,"network":{{"allowLocalBinding":true}}}}}}"#), ) .expect("write settings"); fs::write( external_agent_home .join("skills") .join("skill-a") .join("SKILL.md"), format!( "Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities." ), ) .expect("write skill"); fs::write( external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write agents"); service_for_paths(external_agent_home, codex_home.clone()) .import(vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: None, details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: String::new(), cwd: None, details: None, }, ]) .await .expect("import"); assert_eq!( fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), "Codex guidance" ); let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) .expect("parse config"); let expected: TomlValue = toml::from_str( r#"sandbox_mode = "workspace-write"[shell_environment_policy]inherit = "core"[shell_environment_policy.set]CI = "false"FOO = "bar"MAX_RETRIES = "3"MY_TEAM = "codex""#, ) .expect("parse expected config"); assert_eq!(config, expected); assert_eq!( fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) .expect("read copied skill"), "Use Codex and Codex utilities." );}#[tokio::test]async fn import_home_config_uses_local_settings_over_project_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#, ) .expect("write project settings"); fs::write( external_agent_home.join("settings.local.json"), r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#, ) .expect("write local settings"); service_for_paths(external_agent_home, codex_home.clone()) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, details: None, }]) .await .expect("import"); let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) .expect("parse config"); let expected: TomlValue = toml::from_str( r#"sandbox_mode = "workspace-write"[shell_environment_policy]inherit = "core"[shell_environment_policy.set]FOO = "local"LOCAL_ONLY = "true"PROJECT_ONLY = "yes""#, ) .expect("parse expected config"); assert_eq!(config, expected);}#[tokio::test]async fn import_home_config_ignores_invalid_local_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#, ) .expect("write project settings"); fs::write( external_agent_home.join("settings.local.json"), "{invalid json", ) .expect("write local settings"); service_for_paths(external_agent_home, codex_home.clone()) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, details: None, }]) .await .expect("import"); assert_eq!( fs::read_to_string(codex_home.join("config.toml")).expect("read config"), "[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n" );}#[tokio::test]async fn import_home_skips_empty_config_migration() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","sandbox":{{"enabled":false}}}}"#), ) .expect("write settings"); let outcome = service_for_paths(external_agent_home, codex_home.clone()) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, details: None, }]) .await .expect("import"); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, success_count: 0, error_count: 0, successes: Vec::new(), raw_errors: Vec::new(), }] ); assert!(!codex_home.join("config.toml").exists());}#[tokio::test]async fn import_local_plugins_returns_completed_status() { let (_root, external_agent_home, codex_home) = fixture_paths(); let marketplace_root = external_agent_home.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( external_agent_home.join("settings.json"), serde_json::to_string_pretty(&serde_json::json!({ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "local", "path": marketplace_root } } })) .expect("serialize settings"), ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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(external_agent_home, codex_home.clone()) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: String::new(), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "my-plugins".to_string(), plugin_names: vec!["cloudflare".to_string()], }], ..Default::default() }), }]) .await .expect("import"); assert_eq!( outcome.pending_plugin_imports, Vec::<PendingPluginImport>::new() ); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: String::new(), cwd: None, success_count: 1, error_count: 0, successes: vec![ExternalAgentConfigImportSuccess { item_type: ExternalAgentConfigMigrationItemType::Plugins, cwd: None, source: Some("cloudflare@my-plugins".to_string()), target: Some("cloudflare@my-plugins".to_string()), }], raw_errors: 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"));}#[tokio::test]async fn import_git_plugins_returns_pending_async_status() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "owner/debug-marketplace" } } }"#, ) .expect("write settings"); let outcome = service_for_paths(external_agent_home, codex_home.clone()) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: String::new(), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "acme-tools".to_string(), plugin_names: vec!["formatter".to_string()], }], ..Default::default() }), }]) .await .expect("import"); assert_eq!( outcome.pending_plugin_imports, vec![PendingPluginImport { cwd: None, description: String::new(), details: MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "acme-tools".to_string(), plugin_names: vec!["formatter".to_string()], }], ..Default::default() }, }] ); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: String::new(), cwd: None, success_count: 0, error_count: 0, successes: Vec::new(), raw_errors: Vec::new(), }] ); assert!(!codex_home.join("config.toml").exists());}#[tokio::test]async fn detect_home_skips_config_when_target_already_has_supported_fields() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( external_agent_home.join("settings.json"), r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#, ) .expect("write settings"); fs::write( codex_home.join("config.toml"), r#" sandbox_mode = "workspace-write" [shell_environment_policy] inherit = "core" [shell_environment_policy.set] FOO = "bar" "#, ) .expect("write config"); let items = service_for_paths(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn detect_home_skips_skills_when_all_skill_directories_exist() { let (_root, external_agent_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() .map(|parent| parent.join(".agents").join("skills")) .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create source"); fs::create_dir_all(agents_skills.join("skill-a")).expect("create target"); let items = service_for_paths(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo-a"); let repo_with_existing_target = root.path().join("repo-b"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git"); fs::write( repo_root.join(EXTERNAL_AGENT_CONFIG_MD), format!( "{SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n{SOURCE_EXTERNAL_AGENT_NAME}\n{SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME}\nSee {EXTERNAL_AGENT_CONFIG_MD}\n" ), ) .expect("write source"); fs::write( repo_with_existing_target.join(EXTERNAL_AGENT_CONFIG_MD), "new source", ) .expect("write source"); fs::write( repo_with_existing_target.join("AGENTS.md"), "keep existing target", ) .expect("write target"); let outcome = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_with_existing_target.clone()), details: None, }, ]) .await .expect("import"); assert_eq!( outcome.item_results, vec![ ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, successes: vec![import_success( ExternalAgentConfigMigrationItemType::AgentsMd, Some(repo_root.clone()), repo_root .join(EXTERNAL_AGENT_CONFIG_MD) .display() .to_string(), repo_root.join("AGENTS.md").display().to_string(), )], raw_errors: Vec::new(), }, ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_with_existing_target.clone()), success_count: 0, error_count: 0, successes: Vec::new(), raw_errors: Vec::new(), }, ] ); assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), "Codex\nCodex\nCodex\nSee AGENTS.md\n" ); assert_eq!( fs::read_to_string(repo_with_existing_target.join("AGENTS.md")) .expect("read existing target"), "keep existing target" );}#[tokio::test]async fn import_repo_agents_md_overwrites_empty_targets() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); fs::write( repo_root.join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write source"); fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target"); let outcome = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, successes: vec![import_success( ExternalAgentConfigMigrationItemType::AgentsMd, Some(repo_root.clone()), repo_root .join(EXTERNAL_AGENT_CONFIG_MD) .display() .to_string(), repo_root.join("AGENTS.md").display().to_string(), )], raw_errors: Vec::new(), }] ); assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), "Codex guidance" );}#[tokio::test]async fn detect_repo_prefers_non_empty_external_agent_agents_source() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), " \n\t").expect("write empty root source"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write external agent source"); let items = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( "Migrate {} to {}", repo_root .join(EXTERNAL_AGENT_DIR) .join(EXTERNAL_AGENT_CONFIG_MD) .display(), repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root), details: None, }] );}#[tokio::test]async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { let root = TempDir::new().expect("create tempdir"); 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(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{"hooks":{"Stop":[{"hooks":[{"command":"echo done"}]}]}}"#, ) .expect("write hooks"); fs::write( repo_root.join(".codex").join("config.toml"), "[features]\ncodex_hooks = false\n", ) .expect("write config"); let outcome = service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Hooks, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::Hooks, description: String::new(), cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, successes: vec![import_success( ExternalAgentConfigMigrationItemType::Hooks, Some(repo_root.clone()), "Stop", "Stop", )], raw_errors: Vec::new(), }] ); assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), "[features]\ncodex_hooks = false\n" ); let hooks: JsonValue = serde_json::from_str( &fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"), ) .expect("parse hooks"); assert_eq!( hooks, serde_json::json!({ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "echo done" }] }] } }) );}#[tokio::test]async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{"disabledMcpjsonServers":["blocked"]}"#, ) .expect("write home settings"); fs::write( root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE), serde_json::json!({ "projects": { repo_root.display().to_string(): { "mcpServers": { "allowed": {"command": "allowed-server"}, "blocked": {"command": "blocked-server"} } } } }) .to_string(), ) .expect("write external agent project config"); let outcome = service_for_paths(external_agent_home, root.path().join(".codex")) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); assert_eq!( outcome.item_results, vec![ExternalAgentConfigImportItemResult { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), success_count: 1, error_count: 0, successes: vec![import_success( ExternalAgentConfigMigrationItemType::McpServerConfig, Some(repo_root.clone()), "allowed", "allowed", )], raw_errors: Vec::new(), }] ); let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), ) .expect("parse config"); let expected: TomlValue = toml::from_str( r#"[mcp_servers.allowed]command = "allowed-server""#, ) .expect("parse expected config"); assert_eq!(config, expected);}#[tokio::test]async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write( repo_root.join(".mcp.json"), r#"{ "mcpServers": { "project-disabled": {"command": "project-disabled-server"}, "local-disabled": {"command": "local-disabled-server"}, "local-enabled": {"command": "local-enabled-server"} } }"#, ) .expect("write mcp"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledMcpjsonServers": ["project-disabled", "local-disabled"], "disabledMcpjsonServers": ["project-disabled"] }"#, ) .expect("write project settings"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join("settings.local.json"), r#"{ "enabledMcpjsonServers": ["local-enabled", "local-disabled"], "disabledMcpjsonServers": ["local-disabled"] }"#, ) .expect("write local settings"); service_for_paths(external_agent_home, root.path().join(".codex")) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), ) .expect("parse config"); let expected: TomlValue = toml::from_str( r#"[mcp_servers.local-enabled]command = "local-enabled-server""#, ) .expect("parse expected config"); assert_eq!(config, expected);}#[tokio::test]async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write(external_agent_home.join("settings.json"), "{ invalid json") .expect("write invalid home settings"); fs::write( root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE), serde_json::json!({ "projects": { repo_root.display().to_string(): { "mcpServers": { "docs": {"command": "docs-server"} } } } }) .to_string(), ) .expect("write external agent project config"); service_for_paths(external_agent_home, root.path().join(".codex")) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), ) .expect("parse config"); let expected: TomlValue = toml::from_str( r#"[mcp_servers.docs]command = "docs-server""#, ) .expect("parse expected config"); assert_eq!(config, expected);}#[tokio::test]async fn import_repo_uses_non_empty_external_agent_agents_source() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "").expect("write empty root source"); fs::write( repo_root .join(EXTERNAL_AGENT_DIR) .join(EXTERNAL_AGENT_CONFIG_MD), format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), ) .expect("write external agent source"); service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), details: None, }]) .await .expect("import"); assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), "Codex guidance" );}#[tokio::test]async fn import_continues_after_failed_migration_item() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "Claude guidance").expect("write source"); service_for_paths( root.path().join(EXTERNAL_AGENT_DIR), root.path().join(".codex"), ) .import(vec![ ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: "invalid plugin migration".to_string(), cwd: Some(repo_root.clone()), details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: "valid agents migration".to_string(), cwd: Some(repo_root.clone()), details: None, }, ]) .await .expect("import continues"); assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), "Codex guidance" );}#[test]fn migration_metric_tags_for_skills_include_skills_count() { assert_eq!( migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)), vec![ ("migration_type", "skills".to_string()), ("skills_count", "3".to_string()), ] );}#[tokio::test]async fn detect_home_lists_enabled_plugins_from_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true, "deployer@acme-tools": true, "analyzer@security-plugins": false }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-plugins" } } }"#, ) .expect("write settings"); let items = service_for_paths(external_agent_home.clone(), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "acme-tools".to_string(), plugin_names: vec!["deployer".to_string(), "formatter".to_string()], }], ..Default::default() }), }] );}#[tokio::test]async fn detect_home_plugins_uses_local_settings_over_project_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true, "legacy@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-plugins" } } }"#, ) .expect("write project settings"); fs::write( external_agent_home.join("settings.local.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": false, "deployer@acme-tools": true } }"#, ) .expect("write local settings"); let items = service_for_paths(external_agent_home.clone(), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "acme-tools".to_string(), plugin_names: vec!["deployer".to_string(), "legacy".to_string()], }], ..Default::default() }), }] );}#[tokio::test]async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); 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(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true, "deployer@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-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(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", repo_root .join(EXTERNAL_AGENT_DIR) .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()], }], ..Default::default() }), }] );}#[tokio::test]async fn detect_repo_skips_plugins_that_are_disabled_in_codex() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); 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(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-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(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root]), }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); 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(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-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(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root]), }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn import_plugins_requires_details() { let (_root, external_agent_home, codex_home) = fixture_paths(); let err = service_for_paths(external_agent_home, codex_home) .import_plugins(/*cwd*/ None, /*details*/ None) .await .expect_err("expected missing details error"); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!(err.to_string(), "plugins migration item is missing details");}#[tokio::test]async fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); 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(EXTERNAL_AGENT_DIR)).expect("create repo external agent 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(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "acme-corp/external-agent-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(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", repo_root .join(EXTERNAL_AGENT_DIR) .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()], }], ..Default::default() }), }] );}#[tokio::test]async fn detect_home_skips_plugins_without_marketplace_source() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true } }"#, ) .expect("write settings"); let items = service_for_paths(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn detect_home_skips_plugins_with_invalid_marketplace_source() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "github" } } }"#, ) .expect("write settings"); let items = service_for_paths(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());}#[tokio::test]async fn detect_repo_filters_plugins_against_installed_marketplace() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); 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(EXTERNAL_AGENT_DIR)).expect("create repo external agent 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(EXTERNAL_AGENT_DIR).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(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", repo_root .join(EXTERNAL_AGENT_DIR) .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()], }], ..Default::default() }), }] );}#[tokio::test]async fn import_plugins_requires_source_marketplace_details() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "github", "repo": "acme-corp/external-agent-plugins" } } }"#, ) .expect("write settings"); let outcome = service_for_paths(external_agent_home, codex_home) .import_plugins( /*cwd*/ None, Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "other-tools".to_string(), plugin_names: github_plugin_details().plugins[0].plugin_names.clone(), }], ..Default::default() }), ) .await .expect("import plugins"); assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new()); assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new()); assert_eq!(outcome.failed_marketplaces, vec!["other-tools".to_string()]); assert_eq!( outcome.failed_plugin_ids, vec!["formatter@other-tools".to_string()] ); assert_single_plugin_raw_error( &outcome.raw_errors, "plugin_import", "formatter@other-tools", );}#[tokio::test]async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::write( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "local", "path": "./external_plugins/acme-tools" } } }"#, ) .expect("write settings"); let outcome = service_for_paths(external_agent_home, codex_home) .import_plugins(/*cwd*/ None, Some(github_plugin_details())) .await .expect("import plugins"); assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new()); assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new()); assert_eq!(outcome.failed_marketplaces, vec!["acme-tools".to_string()]); assert_eq!( outcome.failed_plugin_ids, vec!["formatter@acme-tools".to_string()] ); assert_single_plugin_raw_error(&outcome.raw_errors, "plugin_import", "formatter@acme-tools");}#[tokio::test]async fn import_plugins_supports_external_agent_plugin_marketplace_layout() { let (_root, external_agent_home, codex_home) = fixture_paths(); let marketplace_root = external_agent_home.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( external_agent_home.join("settings.json"), serde_json::to_string_pretty(&serde_json::json!({ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "local", "path": marketplace_root } } })) .expect("serialize settings"), ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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(external_agent_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()], }], ..Default::default() }), ) .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(), raw_errors: 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"));}#[tokio::test]async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() { let (_root, external_agent_home, codex_home) = fixture_paths(); let marketplace_root = external_agent_home.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "directory", "path": "./my-marketplace" } } }"#, ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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 items = service_for_paths(external_agent_home.clone(), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "my-plugins".to_string(), plugin_names: vec!["cloudflare".to_string()], }], ..Default::default() }), }] );}#[tokio::test]async fn detect_home_infers_external_official_marketplace_when_missing_from_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( external_agent_home.join("settings.json"), format!( r#"{{ "enabledPlugins": {{ "sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true }} }}"# ), ) .expect("write settings"); let items = service_for_paths(external_agent_home.clone(), codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: true, cwds: None, }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", external_agent_home.join("settings.json").display() ), cwd: None, details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), plugin_names: vec!["sample".to_string()], }], ..Default::default() }), }] );}#[tokio::test]async fn import_plugins_supports_relative_external_agent_plugin_marketplace_path() { let (_root, external_agent_home, codex_home) = fixture_paths(); let marketplace_root = external_agent_home.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( external_agent_home.join("settings.json"), r#"{ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "directory", "path": "./my-marketplace" } } }"#, ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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(external_agent_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()], }], ..Default::default() }), ) .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(), raw_errors: 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"));}#[tokio::test]async fn import_plugins_infers_external_official_marketplace_when_missing_from_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); fs::create_dir_all(&external_agent_home).expect("create external agent home"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::write( external_agent_home.join("settings.json"), format!( r#"{{ "enabledPlugins": {{ "sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true }} }}"# ), ) .expect("write settings"); let outcome = service_for_paths(external_agent_home, codex_home) .import_plugins( /*cwd*/ None, Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), plugin_names: vec!["sample".to_string()], }], ..Default::default() }), ) .await .expect("import plugins"); assert_eq!( outcome.succeeded_marketplaces, vec![EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string()] ); assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new()); assert_eq!(outcome.failed_marketplaces, Vec::<String>::new()); assert_eq!( outcome.failed_plugin_ids, vec![format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}")] ); assert_single_plugin_raw_error( &outcome.raw_errors, "plugin_import", &format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"), );}#[tokio::test]async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace_path() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); let codex_home = root.path().join(".codex"); let repo_root = root.path().join("repo"); let marketplace_root = repo_root.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "directory", "path": "./my-marketplace" } } }"#, ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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 items = service_for_paths(external_agent_home, codex_home) .detect(ExternalAgentConfigDetectOptions { include_home: false, cwds: Some(vec![repo_root.clone()]), }) .await .expect("detect"); assert_eq!( items, vec![ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Plugins, description: format!( "Migrate enabled plugins from {}", repo_root .join(EXTERNAL_AGENT_DIR) .join("settings.json") .display() ), cwd: Some(repo_root), details: Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "my-plugins".to_string(), plugin_names: vec!["cloudflare".to_string()], }], ..Default::default() }), }] );}#[tokio::test]async fn import_plugins_supports_project_relative_external_agent_plugin_marketplace_path() { let root = TempDir::new().expect("create tempdir"); let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); let codex_home = root.path().join(".codex"); let repo_root = root.path().join("repo"); let marketplace_root = repo_root.join("my-marketplace"); let plugin_root = marketplace_root.join("plugins").join("cloudflare"); fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) .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( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), r#"{ "enabledPlugins": { "cloudflare@my-plugins": true }, "extraKnownMarketplaces": { "my-plugins": { "source": "directory", "path": "./my-marketplace" } } }"#, ) .expect("write settings"); fs::write( marketplace_root .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) .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(external_agent_home, codex_home.clone()) .import_plugins( Some(repo_root.as_path()), Some(MigrationDetails { plugins: vec![PluginsMigration { marketplace_name: "my-plugins".to_string(), plugin_names: vec!["cloudflare".to_string()], }], ..Default::default() }), ) .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(), raw_errors: 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_names() { let (_root, external_agent_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() .map(|parent| parent.join(".agents").join("skills")) .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); fs::create_dir_all(external_agent_home.join("skills").join("skill-a")) .expect("create source a"); fs::create_dir_all(external_agent_home.join("skills").join("skill-b")) .expect("create source b"); fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); let copied_names = service_for_paths(external_agent_home, codex_home) .import_skills(/*cwd*/ None) .expect("import skills"); assert_eq!(copied_names, vec!["skill-b".to_string()]);}