Improve external agent plugin migration for configured marketplaces

This commit is contained in:
alexsong-oai
2026-04-15 20:11:00 -07:00
parent 28b76d13fe
commit c8754046f0
5 changed files with 708 additions and 108 deletions

View File

@@ -1,8 +1,16 @@
use crate::plugins::MarketplaceAddRequest;
use crate::plugins::MarketplacePluginInstallPolicy;
use crate::plugins::PluginId;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::add_marketplace;
use crate::plugins::find_marketplace_manifest_path;
use crate::plugins::load_marketplace;
use crate::plugins::marketplace_install_root;
use crate::plugins::parse_marketplace_source;
use crate::plugins::resolve_configured_marketplace_root;
use crate::plugins::validate_plugin_segment;
use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
@@ -15,8 +23,8 @@ use toml::Value as TomlValue;
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
// Installed marketplace roots always expose their manifest at this relative path.
const INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
const EXTERNAL_AGENT_DIR: &str = ".claude";
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
@@ -137,22 +145,11 @@ impl ExternalAgentConfigService {
let cwd = migration_item.cwd;
let details = migration_item.details;
tokio::spawn(async move {
match service.import_plugins(cwd.as_deref(), details).await {
Ok(outcome) => {
tracing::info!(
succeeded_marketplaces = outcome.succeeded_marketplaces.len(),
succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(),
failed_marketplaces = outcome.failed_marketplaces.len(),
failed_plugin_ids = outcome.failed_plugin_ids.len(),
"external agent config plugin import completed"
);
}
Err(err) => {
tracing::warn!(
error = %err,
"external agent config plugin import failed"
);
}
if let Err(err) = service.import_plugins(cwd.as_deref(), details).await {
tracing::warn!(
error = %err,
"external agent config plugin import failed"
);
}
});
emit_migration_metric(
@@ -173,10 +170,12 @@ impl ExternalAgentConfigService {
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let configured_plugin_ids = configured_plugin_ids(&self.codex_home)?;
let configured_marketplace_plugins = configured_marketplace_plugins(&self.codex_home)?;
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.external_agent_home.join("settings.json"),
|repo_root| repo_root.join(".claude").join("settings.json"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let settings = read_external_settings(&source_settings)?;
let target_config = repo_root.map_or_else(
@@ -223,12 +222,14 @@ impl ExternalAgentConfigService {
source_settings.as_path(),
cwd.clone(),
settings.as_ref(),
&configured_plugin_ids,
&configured_marketplace_plugins,
items,
);
let source_skills = repo_root.map_or_else(
|| self.external_agent_home.join("skills"),
|repo_root| repo_root.join(".claude").join("skills"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
);
let target_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
@@ -239,7 +240,7 @@ impl ExternalAgentConfigService {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}",
"Migrate skills from {} to {}",
source_skills.display(),
target_skills.display()
),
@@ -256,7 +257,7 @@ impl ExternalAgentConfigService {
let source_agents_md = if let Some(repo_root) = repo_root {
find_repo_agents_md_source(repo_root)?
} else {
let path = self.external_agent_home.join("CLAUDE.md");
let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);
is_non_empty_text_file(&path)?.then_some(path)
};
let target_agents_md = repo_root.map_or_else(
@@ -298,9 +299,17 @@ impl ExternalAgentConfigService {
source_settings: &Path,
cwd: Option<PathBuf>,
settings: Option<&JsonValue>,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) {
let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else {
let Some(plugin_details) = settings.and_then(|settings| {
extract_plugin_migration_details(
settings,
configured_plugin_ids,
configured_marketplace_plugins,
)
}) else {
return;
};
@@ -336,8 +345,13 @@ impl ExternalAgentConfigService {
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
let import_source =
read_marketplace_import_source(cwd, &self.external_agent_home, &marketplace_name)?;
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let import_source = read_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings).remove(&marketplace_name)
});
let Some(import_source) = import_source else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
@@ -351,12 +365,17 @@ impl ExternalAgentConfigService {
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
let Some(marketplace_path) = find_marketplace_manifest_path(
add_marketplace_outcome.installed_root.as_path(),
) else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
add_marketplace_outcome
.installed_root
.join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH)
marketplace_path
}
Err(_) => {
outcome.failed_marketplaces.push(marketplace_name);
@@ -388,7 +407,7 @@ impl ExternalAgentConfigService {
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("settings.json"),
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -440,7 +459,7 @@ impl ExternalAgentConfigService {
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<usize> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("skills"),
repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -487,7 +506,7 @@ impl ExternalAgentConfigService {
return Ok(());
} else {
(
self.external_agent_home.join("CLAUDE.md"),
self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
self.codex_home.join("AGENTS.md"),
)
};
@@ -508,10 +527,10 @@ impl ExternalAgentConfigService {
fn default_external_agent_home() -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".claude");
return PathBuf::from(home).join(EXTERNAL_AGENT_DIR);
}
PathBuf::from(".claude")
PathBuf::from(EXTERNAL_AGENT_DIR)
}
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
@@ -525,12 +544,36 @@ fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
Ok(Some(settings))
}
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
fn extract_plugin_migration_details(
settings: &JsonValue,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<MigrationDetails> {
let loadable_marketplaces = collect_marketplace_import_sources(settings)
.into_iter()
.filter_map(|(marketplace_name, source)| {
parse_marketplace_source(&source.source, source.ref_name)
.ok()
.map(|_| marketplace_name)
})
.collect::<HashSet<_>>();
let mut plugins = BTreeMap::new();
for plugin_id in collect_enabled_plugins(settings) {
for plugin_id in collect_enabled_plugins(settings)
.into_iter()
.filter(|plugin_id| !configured_plugin_ids.contains(plugin_id))
{
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
if let Some(installable_plugins) =
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
{
if !installable_plugins.contains(&plugin_id.plugin_name) {
continue;
}
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
continue;
}
let plugin_group = plugins
.entry(plugin_id.marketplace_name.clone())
.or_insert_with(|| PluginsMigration {
@@ -579,6 +622,97 @@ fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
.collect()
}
fn configured_plugin_ids(codex_home: &Path) -> io::Result<HashSet<String>> {
let config_path = codex_home.join("config.toml");
if !config_path.is_file() {
return Ok(HashSet::new());
}
let raw_config = fs::read_to_string(&config_path)?;
if raw_config.trim().is_empty() {
return Ok(HashSet::new());
}
let config = toml::from_str::<TomlValue>(&raw_config)
.map_err(|err| invalid_data_error(format!("invalid config.toml: {err}")))?;
let Some(plugins) = config.get("plugins").and_then(TomlValue::as_table) else {
return Ok(HashSet::new());
};
Ok(plugins.keys().cloned().collect())
}
fn configured_marketplace_plugins(
codex_home: &Path,
) -> io::Result<BTreeMap<String, HashSet<String>>> {
let default_install_root = marketplace_install_root(codex_home);
let mut marketplace_roots = BTreeMap::new();
let config_path = codex_home.join("config.toml");
if !config_path.is_file() {
return Ok(BTreeMap::new());
}
let raw_config = fs::read_to_string(&config_path)?;
if raw_config.trim().is_empty() {
return Ok(BTreeMap::new());
}
let config = toml::from_str::<TomlValue>(&raw_config)
.map_err(|err| invalid_data_error(format!("invalid config.toml: {err}")))?;
let Some(marketplaces) = config.get("marketplaces").and_then(TomlValue::as_table) else {
return Ok(BTreeMap::new());
};
for (marketplace_name, marketplace) in marketplaces {
if validate_plugin_segment(marketplace_name, "marketplace name").is_err()
|| !marketplace.is_table()
{
continue;
}
let Some(root) = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
&default_install_root,
) else {
continue;
};
let root = if root.is_relative() {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(root)
} else {
root
};
marketplace_roots.insert(marketplace_name.clone(), root);
}
let mut marketplace_plugins = BTreeMap::new();
for (marketplace_name, root) in marketplace_roots {
let Some(manifest_path) = find_marketplace_manifest_path(&root) else {
continue;
};
let Ok(marketplace) = load_marketplace(&manifest_path) else {
continue;
};
let plugins =
marketplace
.plugins
.into_iter()
.filter(|plugin| {
plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable
&& plugin.policy.products.as_deref().is_none_or(|products| {
Product::Codex.matches_product_restriction(products)
})
})
.map(|plugin| plugin.name)
.collect::<HashSet<_>>();
marketplace_plugins.insert(marketplace_name, plugins);
}
Ok(marketplace_plugins)
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
) -> BTreeMap<String, MarketplaceImportSource> {
@@ -631,22 +765,6 @@ struct MarketplaceImportSource {
ref_name: Option<String>,
}
fn read_marketplace_import_source(
cwd: Option<&Path>,
external_agent_home: &Path,
marketplace_name: &str,
) -> io::Result<Option<MarketplaceImportSource>> {
let source_settings = cwd.map_or_else(
|| external_agent_home.join("settings.json"),
|cwd| cwd.join(".claude").join("settings.json"),
);
let Some(settings) = read_external_settings(&source_settings)? else {
return Ok(None);
};
Ok(collect_marketplace_import_sources(&settings).remove(marketplace_name))
}
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(None);
@@ -729,8 +847,10 @@ fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {
fn find_repo_agents_md_source(repo_root: &Path) -> io::Result<Option<PathBuf>> {
for candidate in [
repo_root.join("CLAUDE.md"),
repo_root.join(".claude").join("CLAUDE.md"),
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD),
] {
if is_non_empty_text_file(&candidate)? {
return Ok(Some(candidate));
@@ -779,7 +899,11 @@ fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
}
fn rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
&EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(),
"AGENTS.md",
);
for from in [
"claude code",
"claude-code",

View File

@@ -5,13 +5,16 @@ use tempfile::TempDir;
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
(root, claude_home, codex_home)
(root, external_agent_home, codex_home)
}
fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgentConfigService {
ExternalAgentConfigService::new_for_test(codex_home, claude_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 {
@@ -25,20 +28,21 @@ fn github_plugin_details() -> MigrationDetails {
#[test]
fn detect_home_lists_config_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
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(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::write(claude_home.join("CLAUDE.md"), "claude rules").expect("write claude md");
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
fs::write(external_agent_home.join("CLAUDE.md"), "claude rules")
.expect("write external agent md");
fs::write(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{"model":"claude","env":{"FOO":"bar"}}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home.clone(), codex_home.clone())
let items = service_for_paths(external_agent_home.clone(), codex_home.clone())
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
@@ -50,7 +54,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}",
claude_home.join("settings.json").display(),
external_agent_home.join("settings.json").display(),
codex_home.join("config.toml").display()
),
cwd: None,
@@ -59,8 +63,8 @@ fn detect_home_lists_config_skills_and_agents_md() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}",
claude_home.join("skills").display(),
"Migrate skills from {} to {}",
external_agent_home.join("skills").display(),
agents_skills.display()
),
cwd: None,
@@ -70,7 +74,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
claude_home.join("CLAUDE.md").display(),
external_agent_home.join("CLAUDE.md").display(),
codex_home.join("AGENTS.md").display()
),
cwd: None,
@@ -125,25 +129,32 @@ fn detect_repo_lists_agents_md_for_each_cwd() {
#[tokio::test]
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
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(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
fs::write(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{"model":"claude","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(
claude_home.join("skills").join("skill-a").join("SKILL.md"),
external_agent_home
.join("skills")
.join("skill-a")
.join("SKILL.md"),
"Use Claude Code and CLAUDE utilities.",
)
.expect("write skill");
fs::write(claude_home.join("CLAUDE.md"), "Claude code guidance").expect("write agents");
fs::write(
external_agent_home.join("CLAUDE.md"),
"Claude code guidance",
)
.expect("write agents");
service_for_paths(claude_home, codex_home.clone())
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
@@ -185,15 +196,15 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
#[tokio::test]
async fn import_home_skips_empty_config_migration() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{"model":"claude","sandbox":{"enabled":false}}"#,
)
.expect("write settings");
service_for_paths(claude_home, codex_home.clone())
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
@@ -208,11 +219,11 @@ async fn import_home_skips_empty_config_migration() {
#[test]
fn detect_home_skips_config_when_target_already_has_supported_fields() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
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(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#,
)
.expect("write settings");
@@ -230,7 +241,7 @@ fn detect_home_skips_config_when_target_already_has_supported_fields() {
)
.expect("write config");
let items = service_for_paths(claude_home, codex_home)
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
@@ -242,15 +253,15 @@ fn detect_home_skips_config_when_target_already_has_supported_fields() {
#[test]
fn detect_home_skips_skills_when_all_skill_directories_exist() {
let (_root, claude_home, codex_home) = fixture_paths();
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(claude_home.join("skills").join("skill-a")).expect("create source");
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(claude_home, codex_home)
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
@@ -333,7 +344,7 @@ async fn import_repo_agents_md_overwrites_empty_targets() {
}
#[test]
fn detect_repo_prefers_non_empty_dot_claude_agents_source() {
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");
@@ -368,7 +379,7 @@ fn detect_repo_prefers_non_empty_dot_claude_agents_source() {
}
#[tokio::test]
async fn import_repo_uses_non_empty_dot_claude_agents_source() {
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");
@@ -409,21 +420,26 @@ fn migration_metric_tags_for_skills_include_skills_count() {
#[test]
fn detect_home_lists_enabled_plugins_from_settings() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
claude_home.join("settings.json"),
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/claude-plugins"
}
}
}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home.clone(), codex_home)
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
@@ -436,7 +452,7 @@ fn detect_home_lists_enabled_plugins_from_settings() {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
claude_home.join("settings.json").display()
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
@@ -449,11 +465,153 @@ fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[test]
fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"deployer@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string()],
}],
}),
}]
);
}
#[test]
fn detect_repo_skips_plugins_that_are_disabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = false
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root]),
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root]),
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn import_plugins_requires_details() {
let (_root, claude_home, codex_home) = fixture_paths();
let (_root, external_agent_home, codex_home) = fixture_paths();
let err = service_for_paths(claude_home, codex_home)
let err = service_for_paths(external_agent_home, codex_home)
.import_plugins(/*cwd*/ None, /*details*/ None)
.await
.expect_err("expected missing details error");
@@ -462,12 +620,248 @@ async fn import_plugins_requires_details() {
assert_eq!(err.to_string(), "plugins migration item is missing details");
}
#[test]
fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo 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(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write project codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
}),
}]
);
}
#[test]
fn detect_home_skips_plugins_without_marketplace_source() {
let (_root, 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,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
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,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_repo_filters_plugins_against_installed_marketplace() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
let marketplace_root = codex_home.join(".tmp").join("marketplaces").join("debug");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(".claude")).expect("create repo 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(".claude").join("settings.json"),
r#"{
"enabledPlugins": {
"sample@debug": true,
"available@debug": true,
"missing@debug": true
},
"extraKnownMarketplaces": {
"debug": {
"source": "owner/debug-marketplace"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[marketplaces.debug]
source_type = "git"
source = "owner/debug-marketplace"
"#,
)
.expect("write codex config");
fs::write(
marketplace_root
.join(".agents")
.join("plugins")
.join("marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"installation": "NOT_AVAILABLE"
}
},
{
"name": "available",
"source": {
"source": "local",
"path": "./plugins/available"
}
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
marketplace_root
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write sample plugin manifest");
fs::write(
marketplace_root
.join("plugins")
.join("available")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"available"}"#,
)
.expect("write available plugin manifest");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "debug".to_string(),
plugin_names: vec!["available".to_string()],
}],
}),
}]
);
}
#[tokio::test]
async fn import_plugins_requires_source_marketplace_details() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
@@ -482,7 +876,7 @@ async fn import_plugins_requires_source_marketplace_details() {
)
.expect("write settings");
let outcome = service_for_paths(claude_home, codex_home)
let outcome = service_for_paths(external_agent_home, codex_home)
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
@@ -508,10 +902,10 @@ async fn import_plugins_requires_source_marketplace_details() {
#[tokio::test]
async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
claude_home.join("settings.json"),
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
@@ -526,7 +920,7 @@ async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace(
)
.expect("write settings");
let outcome = service_for_paths(claude_home, codex_home)
let outcome = service_for_paths(external_agent_home, codex_home)
.import_plugins(/*cwd*/ None, Some(github_plugin_details()))
.await
.expect("import plugins");
@@ -542,18 +936,96 @@ async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace(
);
}
#[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(".claude-plugin"))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
format!(
r#"{{
"enabledPlugins": {{
"cloudflare@my-plugins": true
}},
"extraKnownMarketplaces": {{
"my-plugins": {{
"source": "local",
"path": "{}"
}}
}}
}}"#,
marketplace_root.display()
),
)
.expect("write settings");
fs::write(
marketplace_root
.join(".claude-plugin")
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(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()],
}],
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
}
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[test]
fn import_skills_returns_only_new_skill_directory_count() {
let (_root, claude_home, codex_home) = fixture_paths();
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(claude_home.join("skills").join("skill-a")).expect("create source a");
fs::create_dir_all(claude_home.join("skills").join("skill-b")).expect("create source b");
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_count = service_for_paths(claude_home, codex_home)
let copied_count = service_for_paths(external_agent_home, codex_home)
.import_skills(/*cwd*/ None)
.expect("import skills");

View File

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

View File

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

View File

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