support plugins in external agent config migration (#17855)

This commit is contained in:
alexsong-oai
2026-04-14 19:39:10 -07:00
committed by GitHub
Unverified
parent 2bfa627613
commit ca650561d6
15 changed files with 822 additions and 33 deletions
@@ -605,6 +605,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -620,6 +630,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -1359,6 +1370,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -1478,6 +1503,24 @@
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ReadOnlyAccess": {
"oneOf": [
{
@@ -7509,6 +7509,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/v2/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType"
}
@@ -7524,6 +7534,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -9551,6 +9562,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/v2/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -10473,6 +10498,24 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -4126,6 +4126,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -4141,6 +4151,7 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
@@ -6323,6 +6334,20 @@
}
]
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -7245,6 +7270,24 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -13,6 +13,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -28,9 +38,42 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
}
},
"properties": {
@@ -13,6 +13,16 @@
"description": {
"type": "string"
},
"details": {
"anyOf": [
{
"$ref": "#/definitions/MigrationDetails"
},
{
"type": "null"
}
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
@@ -28,9 +38,42 @@
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"MigrationDetails": {
"properties": {
"plugins": {
"items": {
"$ref": "#/definitions/PluginsMigration"
},
"type": "array"
}
},
"required": [
"plugins"
],
"type": "object"
},
"PluginsMigration": {
"properties": {
"marketplaceName": {
"type": "string"
},
"pluginNames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"marketplaceName",
"pluginNames"
],
"type": "object"
}
},
"properties": {
@@ -2,9 +2,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
import type { MigrationDetails } from "./MigrationDetails";
export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string,
/**
* Null or empty means home-scoped migration; non-empty means repo-scoped migration.
*/
cwd: string | null, };
cwd: string | null, details: MigrationDetails | null, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG";
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginsMigration } from "./PluginsMigration";
export type MigrationDetails = { plugins: Array<PluginsMigration>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginsMigration = { marketplaceName: string, pluginNames: Array<string>, };
@@ -198,6 +198,7 @@ export type { McpToolCallStatus } from "./McpToolCallStatus";
export type { MemoryCitation } from "./MemoryCitation";
export type { MemoryCitationEntry } from "./MemoryCitationEntry";
export type { MergeStrategy } from "./MergeStrategy";
export type { MigrationDetails } from "./MigrationDetails";
export type { Model } from "./Model";
export type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
export type { ModelListParams } from "./ModelListParams";
@@ -236,6 +237,7 @@ export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
export type { PluginsMigration } from "./PluginsMigration";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
@@ -944,11 +944,33 @@ pub enum ExternalAgentConfigMigrationItemType {
#[serde(rename = "SKILLS")]
#[ts(rename = "SKILLS")]
Skills,
#[serde(rename = "PLUGINS")]
#[ts(rename = "PLUGINS")]
Plugins,
#[serde(rename = "MCP_SERVER_CONFIG")]
#[ts(rename = "MCP_SERVER_CONFIG")]
McpServerConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginsMigration {
#[serde(rename = "marketplaceName")]
#[ts(rename = "marketplaceName")]
pub marketplace_name: String,
#[serde(rename = "pluginNames")]
#[ts(rename = "pluginNames")]
pub plugin_names: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -957,6 +979,7 @@ pub struct ExternalAgentConfigMigrationItem {
pub description: String,
/// Null or empty means home-scoped migration; non-empty means repo-scoped migration.
pub cwd: Option<PathBuf>,
pub details: Option<MigrationDetails>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -6592,6 +6615,39 @@ mod tests {
);
}
#[test]
fn external_agent_config_plugins_details_round_trip() {
let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({
"itemType": "PLUGINS",
"description": "Install supported plugins from Claude settings",
"cwd": absolute_path_string("repo"),
"details": {
"plugins": [
{
"marketplaceName": "team-marketplace",
"pluginNames": ["asana"]
}
]
}
}))
.expect("plugins migration item should deserialize");
assert_eq!(
item,
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: "Install supported plugins from Claude settings".to_string(),
cwd: Some(PathBuf::from(absolute_path_string("repo"))),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "team-marketplace".to_string(),
plugin_names: vec!["asana".to_string()],
}],
}),
}
);
}
#[test]
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
+2 -2
View File
@@ -196,8 +196,8 @@ Example with notification opt-out:
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect.
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`.
@@ -6,6 +6,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
@@ -51,12 +53,25 @@ impl ExternalAgentConfigApi {
CoreMigrationItemType::AgentsMd => {
ExternalAgentConfigMigrationItemType::AgentsMd
}
CoreMigrationItemType::Plugins => {
ExternalAgentConfigMigrationItemType::Plugins
}
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
}),
})
.collect(),
})
@@ -82,15 +97,33 @@ impl ExternalAgentConfigApi {
ExternalAgentConfigMigrationItemType::AgentsMd => {
CoreMigrationItemType::AgentsMd
}
ExternalAgentConfigMigrationItemType::Plugins => {
CoreMigrationItemType::Plugins
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| {
codex_core::external_agent_config::MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| {
codex_core::external_agent_config::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
}
})
.collect(),
}
}),
})
.collect(),
)
.await
.map_err(map_io_error)?;
Ok(ExternalAgentConfigImportResponse {})
+326 -19
View File
@@ -1,4 +1,11 @@
use crate::plugins::MarketplaceAddRequest;
use crate::plugins::PluginId;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::add_marketplace;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
@@ -9,6 +16,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";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
@@ -21,36 +30,57 @@ pub enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
Plugins,
McpServerConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsMigration {
pub marketplace_name: String,
pub plugin_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct PluginImportOutcome {
succeeded_marketplaces: Vec<String>,
succeeded_plugin_ids: Vec<String>,
failed_marketplaces: Vec<String>,
failed_plugin_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
pub details: Option<MigrationDetails>,
}
#[derive(Clone)]
pub struct ExternalAgentConfigService {
codex_home: PathBuf,
claude_home: PathBuf,
external_agent_home: PathBuf,
}
impl ExternalAgentConfigService {
pub fn new(codex_home: PathBuf) -> Self {
let claude_home = default_claude_home();
let external_agent_home = default_external_agent_home();
Self {
codex_home,
claude_home,
external_agent_home,
}
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, claude_home: PathBuf) -> Self {
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
Self {
codex_home,
claude_home,
external_agent_home,
}
}
@@ -73,7 +103,10 @@ impl ExternalAgentConfigService {
Ok(items)
}
pub fn import(&self, migration_items: Vec<ExternalAgentConfigMigrationItem>) -> io::Result<()> {
pub async fn import(
&self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
for migration_item in migration_items {
match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
@@ -100,6 +133,35 @@ impl ExternalAgentConfigService {
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Plugins => {
let service = self.clone();
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"
);
}
}
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
}
}
@@ -114,17 +176,15 @@ impl ExternalAgentConfigService {
) -> io::Result<()> {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.claude_home.join("settings.json"),
|| self.external_agent_home.join("settings.json"),
|repo_root| repo_root.join(".claude").join("settings.json"),
);
let settings = read_external_settings(&source_settings)?;
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
);
if source_settings.is_file() {
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
if let Some(settings) = settings.as_ref() {
let migrated = build_config_from_external(&settings)?;
if !is_empty_toml_table(&migrated) {
let mut should_include = true;
@@ -149,6 +209,7 @@ impl ExternalAgentConfigService {
target_config.display()
),
cwd: cwd.clone(),
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
@@ -159,8 +220,15 @@ impl ExternalAgentConfigService {
}
}
self.detect_plugin_migration(
source_settings.as_path(),
cwd.clone(),
settings.as_ref(),
items,
);
let source_skills = repo_root.map_or_else(
|| self.claude_home.join("skills"),
|| self.external_agent_home.join("skills"),
|repo_root| repo_root.join(".claude").join("skills"),
);
let target_skills = repo_root.map_or_else(
@@ -177,6 +245,7 @@ impl ExternalAgentConfigService {
target_skills.display()
),
cwd: cwd.clone(),
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
@@ -188,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.claude_home.join("CLAUDE.md");
let path = self.external_agent_home.join("CLAUDE.md");
is_non_empty_text_file(&path)?.then_some(path)
};
let target_agents_md = repo_root.map_or_else(
@@ -206,6 +275,7 @@ impl ExternalAgentConfigService {
target_agents_md.display()
),
cwd,
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
@@ -224,6 +294,109 @@ impl ExternalAgentConfigService {
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn detect_plugin_migration(
&self,
source_settings: &Path,
cwd: Option<PathBuf>,
settings: Option<&JsonValue>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) {
let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else {
return;
};
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!("Import enabled plugins from {}", source_settings.display()),
cwd,
details: Some(plugin_details),
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
}
async fn import_plugins(
&self,
cwd: Option<&Path>,
details: Option<MigrationDetails>,
) -> io::Result<PluginImportOutcome> {
let Some(MigrationDetails { plugins }) = details else {
return Err(invalid_data_error(
"plugins migration item is missing details".to_string(),
));
};
let mut outcome = PluginImportOutcome::default();
let plugins_manager = PluginsManager::new(self.codex_home.clone());
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone();
let plugin_names = plugin_group.plugin_names;
let plugin_ids = plugin_names
.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 Some(import_source) = import_source else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
let request = MarketplaceAddRequest {
source: import_source.source,
ref_name: import_source.ref_name,
sparse_paths: Vec::new(),
};
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
match AbsolutePathBuf::try_from(
add_marketplace_outcome
.installed_root
.join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH),
) {
Ok(path) => {
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
path
}
Err(_) => {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
}
}
}
Err(_) => {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
}
};
for plugin_name in plugin_names {
match plugins_manager
.install_plugin(PluginInstallRequest {
plugin_name: plugin_name.clone(),
marketplace_path: marketplace_path.clone(),
})
.await
{
Ok(_) => outcome
.succeeded_plugin_ids
.push(format!("{plugin_name}@{marketplace_name}")),
Err(_) => outcome
.failed_plugin_ids
.push(format!("{plugin_name}@{marketplace_name}")),
}
}
}
Ok(outcome)
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
(
@@ -234,7 +407,7 @@ impl ExternalAgentConfigService {
return Ok(());
} else {
(
self.claude_home.join("settings.json"),
self.external_agent_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
};
@@ -286,7 +459,7 @@ impl ExternalAgentConfigService {
return Ok(0);
} else {
(
self.claude_home.join("skills"),
self.external_agent_home.join("skills"),
self.home_target_skills_dir(),
)
};
@@ -326,7 +499,7 @@ impl ExternalAgentConfigService {
return Ok(());
} else {
(
self.claude_home.join("CLAUDE.md"),
self.external_agent_home.join("CLAUDE.md"),
self.codex_home.join("AGENTS.md"),
)
};
@@ -345,7 +518,7 @@ impl ExternalAgentConfigService {
}
}
fn default_claude_home() -> PathBuf {
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");
}
@@ -353,6 +526,139 @@ fn default_claude_home() -> PathBuf {
PathBuf::from(".claude")
}
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
if !path.is_file() {
return Ok(None);
}
let raw_settings = fs::read_to_string(path)?;
let settings =
serde_json::from_str(&raw_settings).map_err(|err| invalid_data_error(err.to_string()))?;
Ok(Some(settings))
}
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
let mut plugins = BTreeMap::new();
for plugin_id in collect_enabled_plugins(settings) {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
let plugin_group = plugins
.entry(plugin_id.marketplace_name.clone())
.or_insert_with(|| PluginsMigration {
marketplace_name: plugin_id.marketplace_name.clone(),
plugin_names: Vec::new(),
});
plugin_group.plugin_names.push(plugin_id.plugin_name);
}
let plugins = plugins
.into_values()
.filter_map(|mut plugin_group| {
if plugin_group.plugin_names.is_empty() {
return None;
}
plugin_group.plugin_names.sort();
Some(plugin_group)
})
.collect::<Vec<_>>();
if plugins.is_empty() {
return None;
}
Some(MigrationDetails { plugins })
}
fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
let Some(enabled_plugins) = settings
.as_object()
.and_then(|settings| settings.get("enabledPlugins"))
.and_then(JsonValue::as_object)
else {
return Vec::new();
};
enabled_plugins
.iter()
.filter_map(|(plugin_key, enabled)| {
if !enabled.as_bool().unwrap_or(false) {
return None;
}
PluginId::parse(plugin_key)
.ok()
.map(|plugin_id| plugin_id.as_key())
})
.collect()
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
) -> BTreeMap<String, MarketplaceImportSource> {
let Some(extra_known_marketplaces) = settings
.as_object()
.and_then(|settings| settings.get("extraKnownMarketplaces"))
.and_then(JsonValue::as_object)
else {
return BTreeMap::new();
};
extra_known_marketplaces
.iter()
.filter_map(|(name, value)| {
let source_fields = if let Some(source) = value.get("source")
&& source.is_object()
{
source.as_object()?
} else {
value.as_object()?
};
let source = source_fields
.get("repo")
.or_else(|| source_fields.get("url"))
.or_else(|| source_fields.get("path"))
.or_else(|| value.get("source"))?
.as_str()?
.trim()
.to_string();
if source.is_empty() {
return None;
}
let ref_name = source_fields
.get("ref")
.or_else(|| value.get("ref"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MarketplaceImportSource {
source: String,
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);
@@ -480,11 +786,11 @@ fn is_skill_md(path: &Path) -> bool {
fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_claude_terms(&source_contents);
let rewritten = rewrite_external_agent_terms(&source_contents);
fs::write(target, rewritten)
}
fn rewrite_claude_terms(content: &str) -> String {
fn rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
for from in [
"claude code",
@@ -662,6 +968,7 @@ fn migration_metric_tags(
ExternalAgentConfigMigrationItemType::Config => "config",
ExternalAgentConfigMigrationItemType::Skills => "skills",
ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md",
ExternalAgentConfigMigrationItemType::Plugins => "plugins",
ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config",
};
let mut tags = vec![("migration_type", migration_type.to_string())];
+174 -10
View File
@@ -1,5 +1,6 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io;
use tempfile::TempDir;
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
@@ -13,6 +14,15 @@ fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgent
ExternalAgentConfigService::new_for_test(codex_home, claude_home)
}
fn github_plugin_details() -> MigrationDetails {
MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
}
}
#[test]
fn detect_home_lists_config_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
@@ -44,6 +54,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
codex_home.join("config.toml").display()
),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
@@ -53,6 +64,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
agents_skills.display()
),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
@@ -62,6 +74,7 @@ fn detect_home_lists_config_skills_and_agents_md() {
codex_home.join("AGENTS.md").display()
),
cwd: None,
details: None,
},
];
@@ -93,6 +106,7 @@ fn detect_repo_lists_agents_md_for_each_cwd() {
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
@@ -102,14 +116,15 @@ fn detect_repo_lists_agents_md_for_each_cwd() {
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
details: None,
},
];
assert_eq!(items, expected);
}
#[test]
fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
#[tokio::test]
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
@@ -134,18 +149,22 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
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!(
@@ -164,8 +183,8 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
);
}
#[test]
fn import_home_skips_empty_config_migration() {
#[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");
fs::write(
@@ -179,7 +198,9 @@ fn import_home_skips_empty_config_migration() {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await
.expect("import");
assert!(!codex_home.join("config.toml").exists());
@@ -239,8 +260,8 @@ fn detect_home_skips_skills_when_all_skill_directories_exist() {
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
#[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");
@@ -264,13 +285,16 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
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!(
@@ -284,8 +308,8 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
);
}
#[test]
fn import_repo_agents_md_overwrites_empty_targets() {
#[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");
@@ -297,7 +321,9 @@ fn import_repo_agents_md_overwrites_empty_targets() {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
assert_eq!(
@@ -336,12 +362,13 @@ fn detect_repo_prefers_non_empty_dot_claude_agents_source() {
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
details: None,
}]
);
}
#[test]
fn import_repo_uses_non_empty_dot_claude_agents_source() {
#[tokio::test]
async fn import_repo_uses_non_empty_dot_claude_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");
@@ -358,7 +385,9 @@ fn import_repo_uses_non_empty_dot_claude_agents_source() {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
assert_eq!(
@@ -378,6 +407,141 @@ 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");
fs::write(
claude_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"deployer@acme-tools": true,
"analyzer@security-plugins": false
}
}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
claude_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()],
}],
}),
}]
);
}
#[tokio::test]
async fn import_plugins_requires_details() {
let (_root, claude_home, codex_home) = fixture_paths();
let err = service_for_paths(claude_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 import_plugins_requires_source_marketplace_details() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::write(
claude_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "github",
"repo": "acme-corp/claude-plugins"
}
}
}"#,
)
.expect("write settings");
let outcome = service_for_paths(claude_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(),
}],
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: Vec::new(),
succeeded_plugin_ids: Vec::new(),
failed_marketplaces: vec!["other-tools".to_string()],
failed_plugin_ids: vec!["formatter@other-tools".to_string()],
}
);
}
#[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");
fs::write(
claude_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(claude_home, codex_home)
.import_plugins(/*cwd*/ None, Some(github_plugin_details()))
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: Vec::new(),
succeeded_plugin_ids: Vec::new(),
failed_marketplaces: vec!["acme-tools".to_string()],
failed_plugin_ids: vec!["formatter@acme-tools".to_string()],
}
);
}
#[test]
fn import_skills_returns_only_new_skill_directory_count() {
let (_root, claude_home, codex_home) = fixture_paths();