mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Prepare external agent migration for source adapters (#32884)
## What changed - Add an optional `source` selector to `externalAgentConfig/detect`, while preserving the existing behavior for omitted, `null`, and unrecognized values. - Pass `claude-code` as the source for TUI detection and import requests. - Move source-specific migration behavior behind an adapter boundary and consolidate session migration into `codex-external-agent-migration`. ## Testing - Cover explicit, omitted, and unknown detection source values with app-server protocol tests. - Add unit coverage for the extracted migration and hook helpers. GitOrigin-RevId: 63bd3dcb485f3ad7bd996022db4e351506aeea77
This commit is contained in:
committed by
copyberry
parent
0877afbe85
commit
bc8222b8d9
Generated
+4
-13
@@ -1989,7 +1989,6 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-external-agent-migration",
|
||||
"codex-external-agent-sessions",
|
||||
"codex-features",
|
||||
"codex-feedback",
|
||||
"codex-file-search",
|
||||
@@ -3023,27 +3022,19 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "codex-external-agent-migration"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-hooks",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-external-agent-sessions"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"codex-app-server-protocol",
|
||||
"codex-hooks",
|
||||
"codex-protocol",
|
||||
"codex-utils-output-truncation",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -59,7 +59,6 @@ members = [
|
||||
"ext/skills",
|
||||
"ext/web-search",
|
||||
"external-agent-migration",
|
||||
"external-agent-sessions",
|
||||
"keyring-store",
|
||||
"file-search",
|
||||
"file-watcher",
|
||||
@@ -187,7 +186,6 @@ codex-goal-extension = { path = "ext/goal" }
|
||||
codex-guardian = { path = "ext/guardian" }
|
||||
codex-image-generation-extension = { path = "ext/image-generation" }
|
||||
codex-external-agent-migration = { path = "external-agent-migration" }
|
||||
codex-external-agent-sessions = { path = "external-agent-sessions" }
|
||||
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
||||
codex-features = { path = "features" }
|
||||
codex-feedback = { path = "feedback" }
|
||||
|
||||
+8
-1
@@ -810,6 +810,13 @@
|
||||
"includeHome": {
|
||||
"description": "If true, include detection under the user's home directory.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -823,7 +830,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"source": {
|
||||
"description": "Source product that produced the migration items. Missing means unspecified.",
|
||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
+8
-1
@@ -9237,6 +9237,13 @@
|
||||
"includeHome": {
|
||||
"description": "If true, include detection under the user's home directory.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExternalAgentConfigDetectParams",
|
||||
@@ -9400,7 +9407,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"source": {
|
||||
"description": "Source product that produced the migration items. Missing means unspecified.",
|
||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
+8
-1
@@ -5477,6 +5477,13 @@
|
||||
"includeHome": {
|
||||
"description": "If true, include detection under the user's home directory.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExternalAgentConfigDetectParams",
|
||||
@@ -5640,7 +5647,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"source": {
|
||||
"description": "Source product that produced the migration items. Missing means unspecified.",
|
||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
+7
@@ -14,6 +14,13 @@
|
||||
"includeHome": {
|
||||
"description": "If true, include detection under the user's home directory.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "ExternalAgentConfigDetectParams",
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"source": {
|
||||
"description": "Source product that produced the migration items. Missing means unspecified.",
|
||||
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
+6
-1
@@ -10,4 +10,9 @@ includeHome?: boolean,
|
||||
/**
|
||||
* Zero or more working directories to include for repo-scoped detection.
|
||||
*/
|
||||
cwds?: Array<string> | null, };
|
||||
cwds?: Array<string> | null,
|
||||
/**
|
||||
* Optional import-source selector. Missing or unrecognized values use the default source for
|
||||
* backwards compatibility.
|
||||
*/
|
||||
source?: string | null, };
|
||||
|
||||
+3
-1
@@ -5,6 +5,8 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr
|
||||
|
||||
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
|
||||
/**
|
||||
* Source product that produced the migration items. Missing means unspecified.
|
||||
* Import-source selector used to produce the migration items. Pass the same value to detection
|
||||
* and import; missing or unrecognized values use the default source for backwards
|
||||
* compatibility.
|
||||
*/
|
||||
source?: string | null, };
|
||||
|
||||
@@ -684,6 +684,10 @@ pub struct ExternalAgentConfigDetectParams {
|
||||
/// Zero or more working directories to include for repo-scoped detection.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwds: Option<Vec<PathBuf>>,
|
||||
/// Optional import-source selector. Missing or unrecognized values use the default source for
|
||||
/// backwards compatibility.
|
||||
#[ts(optional = nullable)]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
@@ -691,7 +695,9 @@ pub struct ExternalAgentConfigDetectParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ExternalAgentConfigImportParams {
|
||||
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
||||
/// Source product that produced the migration items. Missing means unspecified.
|
||||
/// Import-source selector used to produce the migration items. Pass the same value to detection
|
||||
/// and import; missing or unrecognized values use the default source for backwards
|
||||
/// compatibility.
|
||||
#[ts(optional = nullable)]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ codex-home = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
codex-external-agent-migration = { workspace = true }
|
||||
codex-external-agent-sessions = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-goal-extension = { workspace = true }
|
||||
codex-guardian = { workspace = true }
|
||||
|
||||
@@ -239,7 +239,7 @@ 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, including opaque `desktop` values stored in `config.toml`.
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `source` identifier reserved for adapter selection; omitted, `null`, or unrecognized values retain the default behavior. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
|
||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(crate) mod external_agent_config;
|
||||
@@ -0,0 +1,6 @@
|
||||
mod processor;
|
||||
mod service;
|
||||
mod session_importer;
|
||||
|
||||
pub(crate) use processor::ExternalAgentConfigRequestProcessor;
|
||||
pub(crate) use processor::ExternalAgentConfigRequestProcessorArgs;
|
||||
+21
-23
@@ -1,20 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::external_agent_config::ExternalAgentConfigDetectOptions;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigImportItemResult as CoreImportItemResult;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigImportOutcome as CoreImportOutcome;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigImportRawError as CoreImportRawError;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
|
||||
use crate::config::external_agent_config::ExternalAgentConfigService;
|
||||
use crate::config::external_agent_config::NamedMigration as CoreNamedMigration;
|
||||
use crate::config::external_agent_config::PendingPluginImport;
|
||||
use crate::config::external_agent_config::PluginImportOutcome;
|
||||
use crate::config::external_agent_config::record_import_error;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::request_processors::ConfigRequestProcessor;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::ExternalAgentConfigImportCompletedInput;
|
||||
use codex_analytics::ExternalAgentConfigImportFailureInput;
|
||||
@@ -41,7 +31,7 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SkillMigration;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
|
||||
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration as CoreSessionMigration;
|
||||
use codex_rollout::StateDbHandle;
|
||||
use codex_state::ExternalAgentConfigImportFailureRecord;
|
||||
use codex_state::ExternalAgentConfigImportSuccessRecord;
|
||||
@@ -49,8 +39,18 @@ use codex_thread_store::ThreadStore;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::ConfigRequestProcessor;
|
||||
use super::external_agent_session_import::ExternalAgentSessionImporter;
|
||||
use super::service::ExternalAgentConfigDetectOptions;
|
||||
use super::service::ExternalAgentConfigImportItemResult as CoreImportItemResult;
|
||||
use super::service::ExternalAgentConfigImportOutcome as CoreImportOutcome;
|
||||
use super::service::ExternalAgentConfigImportRawError as CoreImportRawError;
|
||||
use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem;
|
||||
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
|
||||
use super::service::ExternalAgentConfigService;
|
||||
use super::service::NamedMigration as CoreNamedMigration;
|
||||
use super::service::PendingPluginImport;
|
||||
use super::service::PluginImportOutcome;
|
||||
use super::service::record_import_error;
|
||||
use super::session_importer::ExternalAgentSessionImporter;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -487,15 +487,13 @@ impl ExternalAgentConfigRequestProcessor {
|
||||
description: migration_item.description,
|
||||
cwd: migration_item.cwd,
|
||||
details: migration_item.details.map(|details| {
|
||||
crate::config::external_agent_config::MigrationDetails {
|
||||
super::service::MigrationDetails {
|
||||
plugins: details
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
crate::config::external_agent_config::PluginsMigration {
|
||||
marketplace_name: plugin.marketplace_name,
|
||||
plugin_names: plugin.plugin_names,
|
||||
}
|
||||
.map(|plugin| super::service::PluginsMigration {
|
||||
marketplace_name: plugin.marketplace_name,
|
||||
plugin_names: plugin.plugin_names,
|
||||
})
|
||||
.collect(),
|
||||
skills: details
|
||||
@@ -832,7 +830,7 @@ fn protocol_import_type_result(item_result: &CoreImportItemResult) -> ProtocolIm
|
||||
}
|
||||
|
||||
fn protocol_import_success(
|
||||
success: &crate::config::external_agent_config::ExternalAgentConfigImportSuccess,
|
||||
success: &super::service::ExternalAgentConfigImportSuccess,
|
||||
) -> ProtocolImportSuccess {
|
||||
ProtocolImportSuccess {
|
||||
item_type: protocol_migration_item_type(success.item_type),
|
||||
@@ -898,5 +896,5 @@ fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationIte
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "external_agent_config_processor_tests.rs"]
|
||||
mod external_agent_config_processor_tests;
|
||||
#[path = "processor_tests.rs"]
|
||||
mod tests;
|
||||
+229
-617
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
use codex_external_agent_migration::RewriteProfile;
|
||||
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_migration::sessions::detect_recent_cla_sessions;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use super::MigrationDetails;
|
||||
use super::source_cla;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct InstructionSourceGroup {
|
||||
pub(super) scope: PathBuf,
|
||||
pub(super) sources: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct MarketplaceImportSource {
|
||||
pub(super) source: String,
|
||||
pub(super) ref_name: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct DetectedSourcePlugins {
|
||||
pub(super) description: String,
|
||||
pub(super) details: MigrationDetails,
|
||||
}
|
||||
|
||||
pub(super) struct PluginDetectionContext<'a> {
|
||||
pub(super) external_agent_home: &'a Path,
|
||||
pub(super) source_settings: &'a Path,
|
||||
pub(super) source_root: &'a Path,
|
||||
pub(super) settings: Option<&'a JsonValue>,
|
||||
pub(super) configured_plugin_ids: &'a HashSet<String>,
|
||||
pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) enum ExternalAgentSource {
|
||||
#[default]
|
||||
Cla,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SourceFeature {
|
||||
Config,
|
||||
Plugins,
|
||||
Sessions,
|
||||
}
|
||||
|
||||
impl ExternalAgentSource {
|
||||
pub(super) fn config_dir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cla => source_cla::CONFIG_DIR,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn supports(self, feature: SourceFeature) -> bool {
|
||||
match (self, feature) {
|
||||
(
|
||||
Self::Cla,
|
||||
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
|
||||
) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
|
||||
match (self, project_scope) {
|
||||
(Self::Cla, _) => "settings.json",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effective_settings(
|
||||
self,
|
||||
source_settings: &Path,
|
||||
) -> io::Result<Option<JsonValue>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::effective_settings(source_settings),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detect_plugins(
|
||||
self,
|
||||
context: PluginDetectionContext<'_>,
|
||||
) -> io::Result<Option<DetectedSourcePlugins>> {
|
||||
match self {
|
||||
Self::Cla => Ok(source_cla::detect_plugins(&context)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
|
||||
match self {
|
||||
Self::Cla => source_cla::can_detect_plugins(settings),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detect_recent_sessions(
|
||||
self,
|
||||
external_agent_home: &Path,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||
match self {
|
||||
Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn marketplace_import_sources(
|
||||
self,
|
||||
external_agent_home: &Path,
|
||||
source_root: &Path,
|
||||
source_settings: &Path,
|
||||
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
|
||||
match self {
|
||||
Self::Cla => Ok(source_cla::effective_settings(source_settings)?
|
||||
.as_ref()
|
||||
.map(|settings| {
|
||||
source_cla::marketplace_import_sources(
|
||||
settings,
|
||||
external_agent_home,
|
||||
source_root,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn append_config(
|
||||
self,
|
||||
root: &mut toml::map::Map<String, TomlValue>,
|
||||
settings: &serde_json::Map<String, JsonValue>,
|
||||
) {
|
||||
match self {
|
||||
Self::Cla => source_cla::append_config(root, settings),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_mcp_config(
|
||||
self,
|
||||
source_root: &Path,
|
||||
external_agent_home: &Path,
|
||||
settings: Option<&JsonValue>,
|
||||
) -> io::Result<TomlValue> {
|
||||
match self {
|
||||
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mcp_source_path(self, source_root: PathBuf) -> PathBuf {
|
||||
match self {
|
||||
Self::Cla => source_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn repo_instruction_source_groups(
|
||||
self,
|
||||
repo_root: &Path,
|
||||
) -> io::Result<Vec<InstructionSourceGroup>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn home_instruction_sources(
|
||||
self,
|
||||
external_agent_home: &Path,
|
||||
) -> io::Result<Vec<PathBuf>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::home_instruction_sources(external_agent_home),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
|
||||
match self {
|
||||
Self::Cla => source_cla::read_instruction_source(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_commands(
|
||||
self,
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::import_source_commands(source_commands, target_skills),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn count_missing_commands(
|
||||
self,
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<usize> {
|
||||
match self {
|
||||
Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn missing_command_names(
|
||||
self,
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_subagents(
|
||||
self,
|
||||
source_agents: &Path,
|
||||
target_agents: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hook_event_names(
|
||||
self,
|
||||
source_dir: &Path,
|
||||
target_hooks: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
match self {
|
||||
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
|
||||
match self {
|
||||
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rewrite_profile(self) -> RewriteProfile {
|
||||
match self {
|
||||
Self::Cla => source_cla::REWRITE_PROFILE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
|
||||
use codex_external_agent_migration::CommandDescriptionMode;
|
||||
use codex_external_agent_migration::CommandMigrationProfile;
|
||||
use codex_external_agent_migration::RewriteProfile;
|
||||
use codex_external_agent_migration::build_mcp_config_from_external;
|
||||
use codex_external_agent_migration::count_missing_commands_with_profile;
|
||||
use codex_external_agent_migration::hook_migration_event_names_cla;
|
||||
use codex_external_agent_migration::import_commands_with_profile;
|
||||
use codex_external_agent_migration::import_hooks_cla;
|
||||
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
|
||||
use codex_external_agent_migration::missing_command_names_with_profile;
|
||||
use codex_plugin::PluginId;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use super::MigrationDetails;
|
||||
use super::PluginsMigration;
|
||||
use super::is_non_empty_text_file;
|
||||
use super::source::DetectedSourcePlugins;
|
||||
use super::source::InstructionSourceGroup;
|
||||
use super::source::MarketplaceImportSource;
|
||||
use super::source::PluginDetectionContext;
|
||||
|
||||
pub(super) const CONFIG_DIR: &str = ".claude";
|
||||
pub(super) const CONFIG_MD: &str = "CLAUDE.md";
|
||||
pub(super) const KNOWN_MARKETPLACES_PATH: &str = "plugins/known_marketplaces.json";
|
||||
pub(super) const OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
|
||||
pub(super) const OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
|
||||
pub(super) const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
|
||||
CONFIG_MD,
|
||||
&[
|
||||
"claude code",
|
||||
"claude-code",
|
||||
"claude_code",
|
||||
"claudecode",
|
||||
"claude",
|
||||
],
|
||||
);
|
||||
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile =
|
||||
CommandMigrationProfile::new(REWRITE_PROFILE, CommandDescriptionMode::RequireFrontmatter);
|
||||
|
||||
pub(super) fn effective_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
|
||||
let mut effective = super::read_external_settings(project_settings)?;
|
||||
let Some(settings_dir) = project_settings.parent() else {
|
||||
return Ok(effective);
|
||||
};
|
||||
let local_settings = settings_dir.join("settings.local.json");
|
||||
let local_settings = match super::read_external_settings(&local_settings) {
|
||||
Ok(Some(local_settings)) => local_settings,
|
||||
Ok(None) => return Ok(effective),
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => return Ok(effective),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if let Some(effective) = effective.as_mut() {
|
||||
merge_json_settings(effective, &local_settings);
|
||||
} else {
|
||||
effective = Some(local_settings);
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
pub(super) fn append_config(
|
||||
root: &mut toml::map::Map<String, TomlValue>,
|
||||
settings: &serde_json::Map<String, JsonValue>,
|
||||
) {
|
||||
if settings
|
||||
.get("sandbox")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|sandbox| sandbox.get("enabled"))
|
||||
.and_then(JsonValue::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
root.insert(
|
||||
"sandbox_mode".to_string(),
|
||||
TomlValue::String("workspace-write".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detect_plugins(
|
||||
context: &PluginDetectionContext<'_>,
|
||||
) -> Option<DetectedSourcePlugins> {
|
||||
let settings = context.settings?;
|
||||
let import_sources =
|
||||
marketplace_import_sources(settings, context.external_agent_home, context.source_root);
|
||||
let details = extract_plugin_migration_details(
|
||||
settings,
|
||||
&import_sources,
|
||||
context.configured_plugin_ids,
|
||||
context.configured_marketplace_plugins,
|
||||
)?;
|
||||
Some(DetectedSourcePlugins {
|
||||
description: format!(
|
||||
"Migrate enabled plugins from {}",
|
||||
context.source_settings.display()
|
||||
),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn can_detect_plugins(settings: Option<&JsonValue>) -> bool {
|
||||
settings.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn marketplace_import_sources(
|
||||
settings: &JsonValue,
|
||||
external_agent_home: &Path,
|
||||
source_root: &Path,
|
||||
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||
let known_marketplaces_path = external_agent_home.join(KNOWN_MARKETPLACES_PATH);
|
||||
let known_marketplaces = match super::read_external_settings(&known_marketplaces_path) {
|
||||
Ok(known_marketplaces) => known_marketplaces,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
path = %known_marketplaces_path.display(),
|
||||
error = %err,
|
||||
"ignoring invalid external agent marketplace registry"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut import_sources = known_marketplaces
|
||||
.as_ref()
|
||||
.map(|known_marketplaces| {
|
||||
collect_marketplace_import_sources(known_marketplaces, external_agent_home)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(extra_known_marketplaces) = settings
|
||||
.as_object()
|
||||
.and_then(|settings| settings.get("extraKnownMarketplaces"))
|
||||
{
|
||||
let mut scoped_marketplaces = extra_known_marketplaces.clone();
|
||||
if let Some(scoped_marketplaces) = scoped_marketplaces.as_object_mut() {
|
||||
for (name, scoped_marketplace) in scoped_marketplaces {
|
||||
import_sources.remove(name);
|
||||
let Some(known_marketplace) = known_marketplaces
|
||||
.as_ref()
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|known_marketplaces| known_marketplaces.get(name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if scoped_marketplace.get("source") != known_marketplace.get("source") {
|
||||
continue;
|
||||
}
|
||||
let Some(install_location) = known_marketplace
|
||||
.get("installLocation")
|
||||
.and_then(JsonValue::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let install_location = Path::new(install_location);
|
||||
let install_location = if install_location.is_absolute() {
|
||||
install_location.to_path_buf()
|
||||
} else {
|
||||
external_agent_home.join(install_location)
|
||||
};
|
||||
let Some(scoped_marketplace) = scoped_marketplace.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
scoped_marketplace.insert(
|
||||
"installLocation".to_string(),
|
||||
JsonValue::String(install_location.display().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
import_sources.extend(collect_marketplace_import_sources(
|
||||
&scoped_marketplaces,
|
||||
source_root,
|
||||
));
|
||||
}
|
||||
|
||||
if has_enabled_plugin_for_marketplace(settings, OFFICIAL_MARKETPLACE_NAME)
|
||||
&& !import_sources.contains_key(OFFICIAL_MARKETPLACE_NAME)
|
||||
{
|
||||
import_sources.insert(
|
||||
OFFICIAL_MARKETPLACE_NAME.to_string(),
|
||||
MarketplaceImportSource {
|
||||
source: OFFICIAL_MARKETPLACE_SOURCE.to_string(),
|
||||
ref_name: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
import_sources
|
||||
}
|
||||
|
||||
pub(super) fn build_mcp_config(
|
||||
source_root: &Path,
|
||||
external_agent_home: &Path,
|
||||
settings: Option<&JsonValue>,
|
||||
) -> io::Result<TomlValue> {
|
||||
build_mcp_config_from_external(source_root, Some(external_agent_home), settings)
|
||||
}
|
||||
|
||||
pub(super) fn repo_instruction_source_groups(
|
||||
repo_root: &Path,
|
||||
) -> io::Result<Vec<InstructionSourceGroup>> {
|
||||
for candidate in [
|
||||
repo_root.join(CONFIG_MD),
|
||||
repo_root.join(CONFIG_DIR).join(CONFIG_MD),
|
||||
] {
|
||||
if is_non_empty_text_file(&candidate)? {
|
||||
return Ok(vec![InstructionSourceGroup {
|
||||
scope: repo_root.to_path_buf(),
|
||||
sources: vec![candidate],
|
||||
}]);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub(super) fn home_instruction_sources(external_agent_home: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let path = external_agent_home.join(CONFIG_MD);
|
||||
Ok(is_non_empty_text_file(&path)?
|
||||
.then_some(path)
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn read_instruction_source(path: &Path) -> io::Result<String> {
|
||||
fs::read_to_string(path)
|
||||
}
|
||||
|
||||
pub(super) fn import_source_commands(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
import_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||
}
|
||||
|
||||
pub(super) fn count_missing_source_commands(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<usize> {
|
||||
count_missing_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||
}
|
||||
|
||||
pub(super) fn missing_source_command_names(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
|
||||
}
|
||||
|
||||
pub(super) fn import_source_subagents(
|
||||
source_agents: &Path,
|
||||
target_agents: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
import_subagents_with_rewrite_profile(source_agents, target_agents, REWRITE_PROFILE)
|
||||
}
|
||||
|
||||
pub(super) fn source_hook_event_names(
|
||||
source_dir: &Path,
|
||||
target_hooks: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
hook_migration_event_names_cla(source_dir, target_hooks, REWRITE_PROFILE)
|
||||
}
|
||||
|
||||
pub(super) fn import_source_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
|
||||
import_hooks_cla(source_dir, target_hooks, REWRITE_PROFILE)
|
||||
}
|
||||
|
||||
fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) {
|
||||
match (existing, incoming) {
|
||||
(JsonValue::Object(existing), JsonValue::Object(incoming)) => {
|
||||
for (key, incoming_value) in incoming {
|
||||
match existing.get_mut(key) {
|
||||
Some(existing_value) => merge_json_settings(existing_value, incoming_value),
|
||||
None => {
|
||||
existing.insert(key.clone(), incoming_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(existing, incoming) => {
|
||||
*existing = incoming.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_plugin_migration_details(
|
||||
settings: &JsonValue,
|
||||
import_sources: &BTreeMap<String, MarketplaceImportSource>,
|
||||
configured_plugin_ids: &HashSet<String>,
|
||||
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
||||
) -> Option<MigrationDetails> {
|
||||
let loadable_marketplaces = import_sources
|
||||
.iter()
|
||||
.filter_map(|(marketplace_name, source)| {
|
||||
is_local_marketplace_source(&source.source, source.ref_name.clone())
|
||||
.ok()
|
||||
.map(|_| marketplace_name.clone())
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let mut plugins = BTreeMap::new();
|
||||
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) {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id.as_key(),
|
||||
marketplace_name = %plugin_id.marketplace_name,
|
||||
"enabled external agent plugin was not found in configured marketplace"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id.as_key(),
|
||||
marketplace_name = %plugin_id.marketplace_name,
|
||||
"marketplace source was not found for enabled external agent plugin"
|
||||
);
|
||||
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,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
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 has_enabled_plugin_for_marketplace(settings: &JsonValue, marketplace_name: &str) -> bool {
|
||||
collect_enabled_plugins(settings)
|
||||
.into_iter()
|
||||
.any(|plugin_id| {
|
||||
PluginId::parse(&plugin_id)
|
||||
.map(|plugin_id| plugin_id.marketplace_name == marketplace_name)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_marketplace_import_sources(
|
||||
marketplaces: &JsonValue,
|
||||
source_root: &Path,
|
||||
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||
marketplaces
|
||||
.as_object()
|
||||
.map(|extra_known_marketplaces| {
|
||||
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_kind = source_fields
|
||||
.get("source")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim);
|
||||
let declared_source = match source_kind {
|
||||
Some("github") => source_fields.get("repo"),
|
||||
Some("git") => source_fields.get("url"),
|
||||
Some("directory" | "local") => source_fields.get("path"),
|
||||
Some("file" | "url" | "npm" | "settings") => None,
|
||||
Some(_) => source_fields.get("source"),
|
||||
None => source_fields
|
||||
.get("repo")
|
||||
.or_else(|| source_fields.get("url"))
|
||||
.or_else(|| source_fields.get("path"))
|
||||
.or_else(|| value.get("source")),
|
||||
}
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let materialized_source = value
|
||||
.get("installLocation")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| {
|
||||
let path = Path::new(value);
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
source_root.join(path)
|
||||
};
|
||||
path.is_dir().then(|| path.display().to_string())
|
||||
});
|
||||
let (source, ref_name) = if let Some(source) = declared_source {
|
||||
let source = if matches!(source_kind, Some("directory" | "local")) {
|
||||
let path = Path::new(source);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
source_root.join(path)
|
||||
}
|
||||
.display()
|
||||
.to_string()
|
||||
} else {
|
||||
resolve_external_marketplace_source(source, source_root)
|
||||
};
|
||||
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);
|
||||
(source, ref_name)
|
||||
} else {
|
||||
(materialized_source?, None)
|
||||
};
|
||||
|
||||
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_external_marketplace_source(source: &str, source_root: &Path) -> String {
|
||||
if !looks_like_relative_local_path(source) {
|
||||
return source.to_string();
|
||||
}
|
||||
|
||||
source_root.join(source).display().to_string()
|
||||
}
|
||||
|
||||
fn looks_like_relative_local_path(source: &str) -> bool {
|
||||
source.starts_with("./") || source.starts_with("../") || source == "." || source == ".."
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use codex_external_agent_migration::RewriteProfile;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn display_source_paths(paths: &[PathBuf]) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
pub(super) fn copy_dir_recursive(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<()> {
|
||||
fs::create_dir_all(target)?;
|
||||
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
let file_type = entry.file_type()?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
copy_dir_recursive(&source_path, &target_path, rewrite_profile)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() {
|
||||
if is_skill_md(&source_path) {
|
||||
rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
|
||||
} else {
|
||||
fs::copy(source_path, target_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn rewrite_external_agent_terms(
|
||||
content: &str,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> String {
|
||||
let mut rewritten = replace_case_insensitive_with_boundaries(
|
||||
content,
|
||||
rewrite_profile.doc_file_name(),
|
||||
"AGENTS.md",
|
||||
);
|
||||
for from in rewrite_profile.term_variants() {
|
||||
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
|
||||
}
|
||||
for from in rewrite_profile.case_sensitive_term_variants() {
|
||||
rewritten = replace_with_boundaries(&rewritten, from, "Codex");
|
||||
}
|
||||
rewritten
|
||||
}
|
||||
|
||||
fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
|
||||
if needle.is_empty() {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
let bytes = input.as_bytes();
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut last_emitted = 0usize;
|
||||
let mut search_start = 0usize;
|
||||
|
||||
while let Some(relative_pos) = input[search_start..].find(needle) {
|
||||
let start = search_start + relative_pos;
|
||||
let end = start + needle.len();
|
||||
let boundary_before = start == 0 || !is_word_char(bytes[start - 1]);
|
||||
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
|
||||
if boundary_before && boundary_after {
|
||||
output.push_str(&input[last_emitted..start]);
|
||||
output.push_str(replacement);
|
||||
last_emitted = end;
|
||||
}
|
||||
search_start = end;
|
||||
}
|
||||
output.push_str(&input[last_emitted..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn is_skill_md(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
||||
}
|
||||
|
||||
fn rewrite_and_copy_text_file(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<()> {
|
||||
let source_contents = fs::read_to_string(source)?;
|
||||
let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
|
||||
fs::write(target, rewritten)
|
||||
}
|
||||
|
||||
fn replace_case_insensitive_with_boundaries(
|
||||
input: &str,
|
||||
needle: &str,
|
||||
replacement: &str,
|
||||
) -> String {
|
||||
let needle_lower = needle.to_ascii_lowercase();
|
||||
if needle_lower.is_empty() {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
let haystack_lower = input.to_ascii_lowercase();
|
||||
let bytes = input.as_bytes();
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut last_emitted = 0usize;
|
||||
let mut search_start = 0usize;
|
||||
|
||||
while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
|
||||
let start = search_start + relative_pos;
|
||||
let end = start + needle_lower.len();
|
||||
let boundary_before = start == 0 || !is_word_char(bytes[start - 1]);
|
||||
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
|
||||
if boundary_before && boundary_after {
|
||||
output.push_str(&input[last_emitted..start]);
|
||||
output.push_str(replacement);
|
||||
last_emitted = end;
|
||||
}
|
||||
search_start = end;
|
||||
}
|
||||
output.push_str(&input[last_emitted..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn is_word_char(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
+15
-10
@@ -1192,10 +1192,12 @@ async fn detect_home_skips_skills_when_all_skill_directories_exist() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
async fn import_repo_agents_md_from_nested_cwd_rewrites_terms_and_skips_non_empty_targets() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo-a");
|
||||
let nested_cwd = repo_root.join("nested");
|
||||
let repo_with_existing_target = root.path().join("repo-b");
|
||||
fs::create_dir_all(&nested_cwd).expect("create nested cwd");
|
||||
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(
|
||||
@@ -1224,7 +1226,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
cwd: Some(nested_cwd.clone()),
|
||||
details: None,
|
||||
},
|
||||
ExternalAgentConfigMigrationItem {
|
||||
@@ -1242,12 +1244,12 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
|
||||
ExternalAgentConfigImportItemResult {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
cwd: Some(nested_cwd.clone()),
|
||||
success_count: 1,
|
||||
error_count: 0,
|
||||
successes: vec![import_success(
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
Some(repo_root.clone()),
|
||||
Some(nested_cwd),
|
||||
repo_root
|
||||
.join(EXTERNAL_AGENT_CONFIG_MD)
|
||||
.display()
|
||||
@@ -1830,7 +1832,7 @@ async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source()
|
||||
|
||||
#[test]
|
||||
fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() {
|
||||
let (root, external_agent_home, codex_home) = fixture_paths();
|
||||
let (root, external_agent_home, _codex_home) = fixture_paths();
|
||||
let source_root = root.path().join("repo");
|
||||
let scoped_marketplace = source_root.join("repo-marketplace");
|
||||
let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug");
|
||||
@@ -1861,8 +1863,8 @@ fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision
|
||||
}
|
||||
});
|
||||
|
||||
let import_sources = service_for_paths(external_agent_home, codex_home)
|
||||
.marketplace_import_sources(&settings, &source_root);
|
||||
let import_sources =
|
||||
source_cla::marketplace_import_sources(&settings, &external_agent_home, &source_root);
|
||||
|
||||
assert_eq!(
|
||||
import_sources.get("debug"),
|
||||
@@ -1875,7 +1877,7 @@ fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision
|
||||
|
||||
#[test]
|
||||
fn marketplace_import_sources_prefers_supported_declaration_over_materialization() {
|
||||
let (_root, external_agent_home, codex_home) = fixture_paths();
|
||||
let (_root, external_agent_home, _codex_home) = fixture_paths();
|
||||
let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools");
|
||||
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
|
||||
fs::write(
|
||||
@@ -1894,8 +1896,11 @@ fn marketplace_import_sources_prefers_supported_declaration_over_materialization
|
||||
)
|
||||
.expect("write known marketplaces");
|
||||
|
||||
let import_sources = service_for_paths(external_agent_home.clone(), codex_home)
|
||||
.marketplace_import_sources(&serde_json::json!({}), &external_agent_home);
|
||||
let import_sources = source_cla::marketplace_import_sources(
|
||||
&serde_json::json!({}),
|
||||
&external_agent_home,
|
||||
&external_agent_home,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
import_sources.get("acme-tools"),
|
||||
+8
-8
@@ -5,12 +5,12 @@ use chrono::Utc;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_external_agent_sessions::CompletedExternalAgentSessionImport;
|
||||
use codex_external_agent_sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_sessions::ImportedExternalAgentSession;
|
||||
use codex_external_agent_sessions::PendingSessionImport;
|
||||
use codex_external_agent_sessions::prepare_validated_session_import;
|
||||
use codex_external_agent_sessions::record_completed_session_imports;
|
||||
use codex_external_agent_migration::sessions::CompletedExternalAgentSessionImport;
|
||||
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
|
||||
use codex_external_agent_migration::sessions::PendingSessionImport;
|
||||
use codex_external_agent_migration::sessions::prepare_validated_session_import;
|
||||
use codex_external_agent_migration::sessions::record_completed_session_imports;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -27,8 +27,8 @@ use codex_thread_store::UpdateThreadMetadataParams;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::config::external_agent_config::ExternalAgentConfigImportItemResult;
|
||||
use crate::config::external_agent_config::record_import_error;
|
||||
use super::service::ExternalAgentConfigImportItemResult;
|
||||
use super::service::record_import_error;
|
||||
use crate::config_manager::ConfigManager;
|
||||
|
||||
const SESSION_IMPORT_CONCURRENCY: usize = 5;
|
||||
@@ -87,7 +87,6 @@ mod attestation;
|
||||
mod auth_mode;
|
||||
mod bespoke_event_handling;
|
||||
mod command_exec;
|
||||
mod config;
|
||||
mod config_layer;
|
||||
mod config_manager;
|
||||
mod config_manager_service;
|
||||
@@ -98,6 +97,7 @@ mod dynamic_tools;
|
||||
mod effective_plugin_change;
|
||||
mod error_code;
|
||||
mod extensions;
|
||||
mod external_agent_migration;
|
||||
mod external_auth;
|
||||
mod filters;
|
||||
mod fs_watch;
|
||||
|
||||
@@ -13,6 +13,8 @@ use crate::extensions::ThreadExtensionDependencies;
|
||||
use crate::extensions::app_server_extension_event_sink;
|
||||
use crate::extensions::guardian_agent_spawner;
|
||||
use crate::extensions::thread_extensions;
|
||||
use crate::external_agent_migration::ExternalAgentConfigRequestProcessor;
|
||||
use crate::external_agent_migration::ExternalAgentConfigRequestProcessorArgs;
|
||||
use crate::fs_watch::FsWatchManager;
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
@@ -24,8 +26,6 @@ use crate::request_processors::CatalogRequestProcessor;
|
||||
use crate::request_processors::CommandExecRequestProcessor;
|
||||
use crate::request_processors::ConfigRequestProcessor;
|
||||
use crate::request_processors::EnvironmentRequestProcessor;
|
||||
use crate::request_processors::ExternalAgentConfigRequestProcessor;
|
||||
use crate::request_processors::ExternalAgentConfigRequestProcessorArgs;
|
||||
use crate::request_processors::FeedbackRequestProcessor;
|
||||
use crate::request_processors::FsRequestProcessor;
|
||||
use crate::request_processors::GitRequestProcessor;
|
||||
|
||||
@@ -501,8 +501,6 @@ mod catalog_processor;
|
||||
mod command_exec_processor;
|
||||
mod config_processor;
|
||||
mod environment_processor;
|
||||
mod external_agent_config_processor;
|
||||
mod external_agent_session_import;
|
||||
mod feedback_doctor_report;
|
||||
mod feedback_processor;
|
||||
mod fs_processor;
|
||||
@@ -525,8 +523,6 @@ pub(crate) use catalog_processor::CatalogRequestProcessor;
|
||||
pub(crate) use command_exec_processor::CommandExecRequestProcessor;
|
||||
pub(crate) use config_processor::ConfigRequestProcessor;
|
||||
pub(crate) use environment_processor::EnvironmentRequestProcessor;
|
||||
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
|
||||
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessorArgs;
|
||||
pub(crate) use feedback_processor::FeedbackRequestProcessor;
|
||||
pub(crate) use fs_processor::FsRequestProcessor;
|
||||
pub(crate) use git_processor::GitRequestProcessor;
|
||||
|
||||
@@ -43,7 +43,7 @@ use super::analytics::wait_for_analytics_event;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn external_agent_home(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(concat!(".", "cl", "aude"))
|
||||
codex_home.join(concat!(".", "cla", "ude"))
|
||||
}
|
||||
|
||||
fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String {
|
||||
@@ -51,6 +51,51 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String
|
||||
response.import_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_agent_config_detect_accepts_source_selection_and_defaults_unknown_values()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_home = external_agent_home(codex_home.path());
|
||||
std::fs::create_dir_all(&source_home)?;
|
||||
std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?;
|
||||
|
||||
let home_dir = codex_home.path().display().to_string();
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
|
||||
.build()
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for source in [None, Some("claude-code"), Some("unknown-source")] {
|
||||
let mut params = serde_json::json!({ "includeHome": true });
|
||||
if let Some(source) = source {
|
||||
params["source"] = serde_json::json!(source);
|
||||
}
|
||||
let request_id = mcp
|
||||
.send_raw_request("externalAgentConfig/detect", Some(params))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
responses.push(to_response::<ExternalAgentConfigDetectResponse>(response)?);
|
||||
}
|
||||
|
||||
assert_eq!(responses[0].items.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0].items[0].item_type,
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd
|
||||
);
|
||||
let expected = responses[0].clone();
|
||||
assert_eq!(responses, vec![expected; 3]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
|
||||
-> Result<()> {
|
||||
|
||||
@@ -13,11 +13,17 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
use super::RewriteProfile;
|
||||
use super::external_agent_config_dir;
|
||||
use super::invalid_data_error;
|
||||
use super::json_u64;
|
||||
use super::rewrite_external_agent_terms;
|
||||
use super::rewrite_hook_command_for_source;
|
||||
use super::write_hook_migration;
|
||||
use codex_hooks::HOOK_EVENT_NAMES;
|
||||
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn hooks_migration_description_cla(
|
||||
source_external_agent_dir: &Path,
|
||||
target_hooks: &Path,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<Option<String>> {
|
||||
if hook_migration_event_names_cla(source_external_agent_dir, target_hooks, rewrite_profile)?
|
||||
.is_empty()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(format!(
|
||||
"Migrate hooks from {} to {}",
|
||||
source_external_agent_dir.display(),
|
||||
target_hooks.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn hook_migration_event_names_cla(
|
||||
source_external_agent_dir: &Path,
|
||||
target_hooks: &Path,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let migration = hook_migration_cla(
|
||||
source_external_agent_dir,
|
||||
target_hooks.parent(),
|
||||
rewrite_profile,
|
||||
)?;
|
||||
Ok(migration.keys().cloned().collect())
|
||||
}
|
||||
|
||||
pub fn import_hooks_cla(
|
||||
source_external_agent_dir: &Path,
|
||||
target_hooks: &Path,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<bool> {
|
||||
let Some(parent) = target_hooks.parent() else {
|
||||
return Err(invalid_data_error("hooks target path has no parent"));
|
||||
};
|
||||
let migration = hook_migration_cla(source_external_agent_dir, Some(parent), rewrite_profile)?;
|
||||
if migration.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
write_hook_migration(source_external_agent_dir, target_hooks, migration)
|
||||
}
|
||||
|
||||
pub(super) fn hook_migration_cla(
|
||||
source_external_agent_dir: &Path,
|
||||
target_config_dir: Option<&Path>,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> io::Result<serde_json::Map<String, JsonValue>> {
|
||||
let mut settings_files = Vec::new();
|
||||
let mut disable_all_hooks = None;
|
||||
for settings_name in ["settings.json", "settings.local.json"] {
|
||||
let settings_file = source_external_agent_dir.join(settings_name);
|
||||
if !settings_file.is_file() {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&settings_file)?;
|
||||
let settings: JsonValue = serde_json::from_str(&raw)
|
||||
.map_err(|err| invalid_data_error(format!("invalid hooks settings: {err}")))?;
|
||||
if let Some(disabled) = settings.get("disableAllHooks").and_then(JsonValue::as_bool) {
|
||||
disable_all_hooks = Some(disabled);
|
||||
}
|
||||
settings_files.push(settings);
|
||||
}
|
||||
|
||||
if disable_all_hooks.unwrap_or(false) {
|
||||
return Ok(serde_json::Map::new());
|
||||
}
|
||||
|
||||
let mut migration = serde_json::Map::new();
|
||||
for settings in settings_files {
|
||||
append_convertible_hook_groups_cla(
|
||||
&settings,
|
||||
&mut migration,
|
||||
target_config_dir,
|
||||
rewrite_profile,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(migration)
|
||||
}
|
||||
|
||||
pub(super) fn append_convertible_hook_groups_cla(
|
||||
settings: &JsonValue,
|
||||
hooks_payload: &mut serde_json::Map<String, JsonValue>,
|
||||
target_config_dir: Option<&Path>,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) {
|
||||
let Some(hooks_config) = settings.get("hooks").and_then(JsonValue::as_object) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for event_name in HOOK_EVENT_NAMES {
|
||||
let Some(groups) = hooks_config.get(event_name).and_then(JsonValue::as_array) else {
|
||||
continue;
|
||||
};
|
||||
for group in groups {
|
||||
let Some(group_object) = group.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if group_object.contains_key("if")
|
||||
|| group_object
|
||||
.keys()
|
||||
.any(|key| !matches!(key.as_str(), "matcher" | "hooks"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut hook_commands = Vec::new();
|
||||
if let Some(hooks) = group_object.get("hooks").and_then(JsonValue::as_array) {
|
||||
for hook in hooks {
|
||||
let Some(hook_object) = hook.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let hook_type = hook_object
|
||||
.get("type")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("command");
|
||||
if hook_type != "command" {
|
||||
continue;
|
||||
}
|
||||
if hook_object.keys().any(|key| {
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"type"
|
||||
| "command"
|
||||
| "timeout"
|
||||
| "timeoutSec"
|
||||
| "statusMessage"
|
||||
| "async"
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if hook_object
|
||||
.get("async")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if ["asyncRewake", "shell", "once"]
|
||||
.into_iter()
|
||||
.any(|field| hook_object.contains_key(field))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(command) = hook_object
|
||||
.get("command")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|command| !command.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut command_payload = serde_json::Map::new();
|
||||
command_payload
|
||||
.insert("type".to_string(), JsonValue::String("command".to_string()));
|
||||
command_payload.insert(
|
||||
"command".to_string(),
|
||||
JsonValue::String(rewrite_hook_command_cla(command, target_config_dir)),
|
||||
);
|
||||
if let Some(timeout) = hook_object
|
||||
.get("timeout")
|
||||
.or_else(|| hook_object.get("timeoutSec"))
|
||||
.and_then(json_u64)
|
||||
{
|
||||
command_payload.insert(
|
||||
"timeout".to_string(),
|
||||
JsonValue::Number(serde_json::Number::from(timeout)),
|
||||
);
|
||||
}
|
||||
if let Some(status_message) =
|
||||
hook_object.get("statusMessage").and_then(JsonValue::as_str)
|
||||
{
|
||||
command_payload.insert(
|
||||
"statusMessage".to_string(),
|
||||
JsonValue::String(rewrite_external_agent_terms(
|
||||
status_message,
|
||||
rewrite_profile,
|
||||
)),
|
||||
);
|
||||
}
|
||||
hook_commands.push(JsonValue::Object(command_payload));
|
||||
}
|
||||
}
|
||||
if hook_commands.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut group_payload = serde_json::Map::new();
|
||||
if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name)
|
||||
&& let Some(matcher) = group_object.get("matcher").and_then(JsonValue::as_str)
|
||||
{
|
||||
group_payload.insert(
|
||||
"matcher".to_string(),
|
||||
JsonValue::String(matcher.to_string()),
|
||||
);
|
||||
}
|
||||
group_payload.insert("hooks".to_string(), JsonValue::Array(hook_commands));
|
||||
if let Some(groups) = hooks_payload
|
||||
.entry(event_name.to_string())
|
||||
.or_insert_with(|| JsonValue::Array(Vec::new()))
|
||||
.as_array_mut()
|
||||
{
|
||||
groups.push(JsonValue::Object(group_payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rewrite_hook_command_cla(command: &str, target_config_dir: Option<&Path>) -> String {
|
||||
let source_external_agent_dir = PathBuf::from(external_agent_config_dir());
|
||||
rewrite_hook_command_for_source(command, target_config_dir, &source_external_agent_dir)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
||||
use super::hooks_cla::append_convertible_hook_groups_cla;
|
||||
use super::hooks_cla::hook_migration_cla;
|
||||
use super::hooks_cla::rewrite_hook_command_cla;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
|
||||
const TEST_REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
|
||||
"CLAUDE.md",
|
||||
&[
|
||||
"claude code",
|
||||
"claude-code",
|
||||
"claude_code",
|
||||
"claudecode",
|
||||
"claude",
|
||||
],
|
||||
);
|
||||
|
||||
fn source_path(relative_path: &str) -> PathBuf {
|
||||
Path::new("/repo")
|
||||
.join(external_agent_config_dir())
|
||||
.join(relative_path)
|
||||
}
|
||||
|
||||
fn source_hook_command(script_name: &str) -> String {
|
||||
format!(
|
||||
"python3 {}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
|
||||
external_agent_config_dir()
|
||||
)
|
||||
}
|
||||
|
||||
fn source_hook_command_with_project_dir(script_name: &str) -> String {
|
||||
format!(
|
||||
"python3 \"${}\"/{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
|
||||
external_agent_project_dir_env_var(),
|
||||
external_agent_config_dir()
|
||||
)
|
||||
}
|
||||
|
||||
fn migrated_hook_command(script_name: &str) -> String {
|
||||
migrated_quoted_hook_command(script_name)
|
||||
}
|
||||
|
||||
fn migrated_quoted_hook_command(script_name: &str) -> String {
|
||||
let hook_path = Path::new("/repo/.codex")
|
||||
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
||||
.join(script_name);
|
||||
format!(
|
||||
"python3 {}",
|
||||
shell_single_quote(hook_path.to_string_lossy().as_ref())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_placeholder_accepts_defaults() {
|
||||
assert_eq!(
|
||||
parse_env_placeholder("${TOKEN:-fallback}"),
|
||||
Some("TOKEN".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_skips_placeholder_args() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().join(".mcp.json"),
|
||||
r#"{"mcpServers":{"db":{"command":"db-server","args":["${DATABASE_URL}"]}}}"#,
|
||||
)
|
||||
.expect("write mcp");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
root.path(),
|
||||
/*external_agent_home*/ None,
|
||||
/*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
TomlValue::Table(Default::default())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_prefers_command_transport_for_mixed_server_config() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().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");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
root.path(),
|
||||
/*external_agent_home*/ None,
|
||||
/*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.mixedTransport]
|
||||
command = "mcp-remote-proxy"
|
||||
args = [
|
||||
"https://example.com/mixed-transport",
|
||||
"--transport",
|
||||
"http",
|
||||
]
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_skips_unsupported_transports() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"legacy-sse": {"type": "sse", "url": "https://example.invalid/sse"},
|
||||
"vault": {
|
||||
"url": "https://example.invalid/vault",
|
||||
"headers": {"Authorization": "Bearer ${VAULT_TOKEN:-dev-token}"}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mcp");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
root.path(),
|
||||
/*external_agent_home*/ None,
|
||||
/*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.vault]
|
||||
url = "https://example.invalid/vault"
|
||||
bearer_token_env_var = "VAULT_TOKEN"
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_reads_matching_project_entries_from_repo_external_project_config() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let project = root.path().join("repo");
|
||||
fs::create_dir_all(&project).expect("create repo");
|
||||
let other = root.path().join("other");
|
||||
fs::create_dir_all(&other).expect("create other");
|
||||
fs::write(
|
||||
project.join(external_agent_project_config_file()),
|
||||
serde_json::json!({
|
||||
"mcpServers": {
|
||||
"top": {"command": "top-server"}
|
||||
},
|
||||
"projects": {
|
||||
project.display().to_string(): {
|
||||
"mcpServers": {
|
||||
"repo": {"command": "repo-server"}
|
||||
}
|
||||
},
|
||||
other.display().to_string(): {
|
||||
"mcpServers": {
|
||||
"other": {"command": "other-server"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write external agent project config");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
&project, /*external_agent_home*/ None, /*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.repo]
|
||||
command = "repo-server"
|
||||
|
||||
[mcp_servers.top]
|
||||
command = "top-server"
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_reads_matching_project_entries_from_home_external_project_config() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let project = root.path().join("repo");
|
||||
fs::create_dir_all(&project).expect("create repo");
|
||||
let external_agent_home = root.path().join(external_agent_config_dir());
|
||||
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
||||
fs::write(
|
||||
root.path().join(external_agent_project_config_file()),
|
||||
serde_json::json!({
|
||||
"projects": {
|
||||
project.display().to_string(): {
|
||||
"mcpServers": {
|
||||
"repo": {"command": "repo-server"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write external agent project config");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
&project,
|
||||
Some(&external_agent_home),
|
||||
/*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.repo]
|
||||
command = "repo-server"
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_preserves_repo_servers_over_home_project_entries() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let project = root.path().join("repo");
|
||||
fs::create_dir_all(&project).expect("create repo");
|
||||
let external_agent_home = root.path().join(external_agent_config_dir());
|
||||
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
||||
fs::write(
|
||||
project.join(EXTERNAL_AGENT_MCP_CONFIG_FILE),
|
||||
serde_json::json!({
|
||||
"mcpServers": {
|
||||
"shared": {"command": "repo-server"}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write repo mcp");
|
||||
fs::write(
|
||||
root.path().join(external_agent_project_config_file()),
|
||||
serde_json::json!({
|
||||
"projects": {
|
||||
project.display().to_string(): {
|
||||
"mcpServers": {
|
||||
"home-only": {"command": "home-only-server"},
|
||||
"shared": {"command": "home-server"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write external agent project config");
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
&project,
|
||||
Some(&external_agent_home),
|
||||
/*settings*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.home-only]
|
||||
command = "home-only-server"
|
||||
|
||||
[mcp_servers.shared]
|
||||
command = "repo-server"
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_migration_skips_disabled_servers() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"enabled": {"command": "enabled-server"},
|
||||
"explicit-disabled": {"command": "disabled-server", "disabled": true},
|
||||
"not-enabled": {"command": "not-enabled-server"}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mcp");
|
||||
let settings = serde_json::json!({
|
||||
"enabledMcpjsonServers": ["enabled"],
|
||||
"disabledMcpjsonServers": ["explicit-disabled"]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
build_mcp_config_from_external(
|
||||
root.path(),
|
||||
/*external_agent_home*/ None,
|
||||
Some(&settings),
|
||||
)
|
||||
.unwrap(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
[mcp_servers.enabled]
|
||||
command = "enabled-server"
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_skill_names_include_nested_paths() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/pr/review.md");
|
||||
|
||||
assert_eq!(command_skill_name(&root, &file), "source-command-pr-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_skill_names_must_fit_codex_skill_loader_limit() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/this/is/a/deeply/nested/command/with/a/very/long/name.md");
|
||||
let document = parse_document_content("---\ndescription: Review PR\n---\nReview\n");
|
||||
|
||||
assert!(
|
||||
command_skill_name_if_supported(
|
||||
&root,
|
||||
&file,
|
||||
&document,
|
||||
CommandDescriptionMode::RequireFrontmatter,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_with_overlong_descriptions_are_preserved() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/review.md");
|
||||
let description = "x".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
|
||||
let document =
|
||||
parse_document_content(&format!("---\ndescription: {description}\n---\nReview\n"));
|
||||
|
||||
assert_eq!(
|
||||
command_skill_name_if_supported(
|
||||
&root,
|
||||
&file,
|
||||
&document,
|
||||
CommandDescriptionMode::RequireFrontmatter,
|
||||
),
|
||||
Some("source-command-review".to_string())
|
||||
);
|
||||
|
||||
let rendered = render_command_skill(
|
||||
&document.body,
|
||||
"source-command-review",
|
||||
&description,
|
||||
"review",
|
||||
TEST_REWRITE_PROFILE,
|
||||
);
|
||||
let rendered_document = parse_document_content(&rendered);
|
||||
assert_eq!(
|
||||
rendered_document
|
||||
.frontmatter
|
||||
.get("description")
|
||||
.and_then(FrontmatterValue::as_scalar),
|
||||
Some(description.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_with_provider_runtime_expansion_are_skipped() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/deploy.md");
|
||||
let document = parse_document_content(
|
||||
"---\ndescription: Deploy\n---\nDeploy $ARGUMENTS from @release.yaml\n",
|
||||
);
|
||||
|
||||
assert!(
|
||||
command_skill_name_if_supported(
|
||||
&root,
|
||||
&file,
|
||||
&document,
|
||||
CommandDescriptionMode::RequireFrontmatter,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_without_description_are_skipped() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/README.md");
|
||||
let document = parse_document_content("# Notes\n\nThis documents commands.\n");
|
||||
|
||||
assert!(
|
||||
command_skill_name_if_supported(
|
||||
&root,
|
||||
&file,
|
||||
&document,
|
||||
CommandDescriptionMode::RequireFrontmatter,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_can_derive_descriptions_from_source_names() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let commands = root.path().join("commands");
|
||||
let target_skills = root.path().join("skills");
|
||||
fs::create_dir_all(&commands).expect("create commands");
|
||||
fs::write(
|
||||
commands.join("review-code.md"),
|
||||
"Review the current change.\n",
|
||||
)
|
||||
.expect("write command");
|
||||
let profile = CommandMigrationProfile::new(
|
||||
TEST_REWRITE_PROFILE,
|
||||
CommandDescriptionMode::UseSourceNameFallback,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
import_commands_with_profile(&commands, &target_skills, profile).unwrap(),
|
||||
vec!["source-command-review-code".to_string()]
|
||||
);
|
||||
let rendered = fs::read_to_string(
|
||||
target_skills
|
||||
.join("source-command-review-code")
|
||||
.join("SKILL.md"),
|
||||
)
|
||||
.expect("read migrated command");
|
||||
assert!(rendered.contains("description: \"Migrated source command `review-code`\""));
|
||||
assert!(rendered.contains("Review the current change."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_slug_collisions_are_skipped() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let commands = root.path().join("commands");
|
||||
fs::create_dir_all(&commands).expect("create commands");
|
||||
fs::write(
|
||||
commands.join("foo-bar.md"),
|
||||
"---\ndescription: First\n---\nRun the first command.\n",
|
||||
)
|
||||
.expect("write first command");
|
||||
fs::write(
|
||||
commands.join("foo_bar.md"),
|
||||
"---\ndescription: Second\n---\nRun the second command.\n",
|
||||
)
|
||||
.expect("write second command");
|
||||
|
||||
assert_eq!(
|
||||
unique_supported_command_sources(&commands, CommandDescriptionMode::RequireFrontmatter,)
|
||||
.unwrap(),
|
||||
Vec::<(PathBuf, String)>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() {
|
||||
let document = parse_document_content(
|
||||
"---\nname: cloud-incident\ndescription: Debug incidents\nskills:\n - runbook-reader\ntools:\n - Read\n - Bash\ndisallowedTools:\n - Write\n---\nInvestigate carefully.\n",
|
||||
);
|
||||
|
||||
assert!(agent_metadata(&document).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_requires_minimum_codex_agent_fields() {
|
||||
let missing_description =
|
||||
parse_document_content("---\nname: incomplete\n---\nInvestigate carefully.\n");
|
||||
let missing_body =
|
||||
parse_document_content("---\nname: incomplete\ndescription: Missing body\n---\n");
|
||||
|
||||
assert!(agent_metadata(&missing_description).is_none());
|
||||
assert!(agent_metadata(&missing_body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_preserves_default_model_when_source_model_is_present() {
|
||||
let document = parse_document_content(
|
||||
"---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n",
|
||||
);
|
||||
let metadata = agent_metadata(&document).expect("metadata");
|
||||
let rendered: TomlValue = toml::from_str(
|
||||
&render_agent_toml(&document.body, &metadata, TEST_REWRITE_PROFILE).expect("render agent"),
|
||||
)
|
||||
.expect("parse rendered agent");
|
||||
let expected: TomlValue = toml::from_str(
|
||||
r#"
|
||||
name = "reviewer"
|
||||
description = "Review code"
|
||||
model_reasoning_effort = "xhigh"
|
||||
developer_instructions = """
|
||||
Review carefully."""
|
||||
"#,
|
||||
)
|
||||
.expect("parse expected agent");
|
||||
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_target_preserves_dotted_file_stem() {
|
||||
let target_agents = Path::new("/repo/.codex/agents");
|
||||
let source_file = source_path("agents/security.audit.md");
|
||||
|
||||
assert_eq!(
|
||||
subagent_target_file(&source_file, target_agents),
|
||||
Some(PathBuf::from("/repo/.codex/agents/security.audit.toml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_accepts_crlf_delimiters() {
|
||||
let document = parse_document_content(
|
||||
"---\r\nname: reviewer\r\ndescription: Review code\r\n---\r\nReview carefully.\r\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
document
|
||||
.frontmatter
|
||||
.get("name")
|
||||
.and_then(FrontmatterValue::as_scalar),
|
||||
document
|
||||
.frontmatter
|
||||
.get("description")
|
||||
.and_then(FrontmatterValue::as_scalar),
|
||||
document.body.as_str(),
|
||||
),
|
||||
(
|
||||
Some("reviewer"),
|
||||
Some("Review code"),
|
||||
"Review carefully.\r\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_migration_ignores_unsupported_handlers() {
|
||||
let settings = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash",
|
||||
"if": "tool_input.command contains 'rm'",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": source_hook_command("policy_gate.py")
|
||||
}]
|
||||
}, {
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(rm *)",
|
||||
"command": source_hook_command("policy_gate.py")
|
||||
},
|
||||
{
|
||||
"type": "http",
|
||||
"url": "https://example.invalid/hook"
|
||||
}
|
||||
]
|
||||
}],
|
||||
"PermissionRequest": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": source_hook_command("approve.py")
|
||||
}]
|
||||
}],
|
||||
"SubagentStart": [{
|
||||
"matcher": "worker",
|
||||
"hooks": [{"type": "prompt", "prompt": "check"}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
let mut migration = serde_json::Map::new();
|
||||
append_convertible_hook_groups_cla(
|
||||
&settings,
|
||||
&mut migration,
|
||||
Some(Path::new("/repo/.codex")),
|
||||
TEST_REWRITE_PROFILE,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
migration,
|
||||
serde_json::json!({
|
||||
"PermissionRequest": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": migrated_hook_command("approve.py")
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("object")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_migration_honors_disable_all_hooks() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().join("settings.json"),
|
||||
r#"{
|
||||
"disableAllHooks": true,
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "startup",
|
||||
"hooks": [{"type": "command", "command": "echo setup"}]
|
||||
}]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
assert_eq!(
|
||||
hook_migration_cla(
|
||||
root.path(),
|
||||
/*target_config_dir*/ None,
|
||||
TEST_REWRITE_PROFILE,
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::Map::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_migration_honors_settings_local_disable_override() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
root.path().join("settings.json"),
|
||||
r#"{
|
||||
"disableAllHooks": true,
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "project",
|
||||
"hooks": [{"type": "command", "command": "echo project"}]
|
||||
}]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
root.path().join("settings.local.json"),
|
||||
r#"{
|
||||
"disableAllHooks": false,
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "local",
|
||||
"hooks": [{"type": "command", "command": "echo local"}]
|
||||
}]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
|
||||
assert_eq!(
|
||||
hook_migration_cla(
|
||||
root.path(),
|
||||
/*target_config_dir*/ None,
|
||||
TEST_REWRITE_PROFILE,
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!({
|
||||
"SessionStart": [{
|
||||
"matcher": "project",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo project"
|
||||
}]
|
||||
}, {
|
||||
"matcher": "local",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo local"
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("object")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_command_paths_rewrite_to_target_hook_dir() {
|
||||
let project_dir_env_var = external_agent_project_dir_env_var();
|
||||
let plugin_root_env_var = format!(
|
||||
"{}_PLUGIN_ROOT",
|
||||
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
|
||||
);
|
||||
let source_hooks_path = format!(
|
||||
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}",
|
||||
external_agent_config_dir()
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&source_hook_command_with_project_dir("check.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_hook_command("check.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
shell_single_quote(
|
||||
Path::new("/repo/.codex")
|
||||
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
||||
.join("check-style.sh")
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&source_hook_command("check.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_hook_command("check.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 ./{source_hooks_path}/check.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_hook_command("check.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/check.py'"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_quoted_hook_command("check.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 \"${{{project_dir_env_var}}}/{source_hooks_path}/check.py\""),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_quoted_hook_command("check.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("bash -lc \"python3 {source_hooks_path}/check.py\""),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("bash -lc \"python3 {source_hooks_path}/check.py\"")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!(
|
||||
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
|
||||
),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\"")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("python3 {source_hooks_path}/{{lint,fmt}}.sh")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 {source_hooks_path}/my\\ script.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("python3 {source_hooks_path}/my\\ script.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME)
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!(
|
||||
"python3 \"%{}%\\{}\\hooks\\check.py\"",
|
||||
project_dir_env_var,
|
||||
external_agent_config_dir()
|
||||
),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!(
|
||||
"python3 \"%{}%\\{}\\hooks\\check.py\"",
|
||||
project_dir_env_var,
|
||||
external_agent_config_dir()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
migrated_quoted_hook_command("my script.py")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(
|
||||
&format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"),
|
||||
Some(Path::new("/repo/.codex")),
|
||||
),
|
||||
format!(
|
||||
"{} 2>/dev/null || true",
|
||||
shell_single_quote(
|
||||
Path::new("/repo/.codex")
|
||||
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
|
||||
.join("check.py")
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
)
|
||||
)
|
||||
);
|
||||
let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh");
|
||||
assert_eq!(
|
||||
rewrite_hook_command_cla(&plugin_script_command, Some(Path::new("/repo/.codex")),),
|
||||
plugin_script_command
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_script_copy_keeps_existing_target_scripts() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let source_external_agent_dir = root.path().join(external_agent_config_dir());
|
||||
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
|
||||
let target_config_dir = root.path().join(".codex");
|
||||
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
|
||||
fs::create_dir_all(&source_hooks).expect("create source hooks");
|
||||
fs::create_dir_all(&target_hooks).expect("create target hooks");
|
||||
fs::write(source_hooks.join("check.py"), "new script").expect("write source hook");
|
||||
fs::write(target_hooks.join("check.py"), "existing script").expect("write target hook");
|
||||
|
||||
copy_hook_scripts(&source_external_agent_dir, &target_config_dir).expect("copy hooks");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(target_hooks.join("check.py")).expect("read target hook"),
|
||||
"existing script"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_migration_drops_negative_timeouts() {
|
||||
let settings = serde_json::json!({
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "startup",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo setup",
|
||||
"timeout": -1
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
let mut migration = serde_json::Map::new();
|
||||
append_convertible_hook_groups_cla(
|
||||
&settings,
|
||||
&mut migration,
|
||||
/*target_config_dir*/ None,
|
||||
TEST_REWRITE_PROFILE,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
migration,
|
||||
serde_json::json!({
|
||||
"SessionStart": [{
|
||||
"matcher": "startup",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo setup"
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("object")
|
||||
);
|
||||
}
|
||||
+33
-19
@@ -1,8 +1,8 @@
|
||||
use crate::ExternalAgentSessionMigration;
|
||||
use crate::ledger::load_import_ledger;
|
||||
use crate::ledger::save_import_ledger;
|
||||
use crate::now_unix_seconds;
|
||||
use crate::summarize_session;
|
||||
use super::ExternalAgentSessionMigration;
|
||||
use super::ledger::load_import_ledger;
|
||||
use super::ledger::save_import_ledger;
|
||||
use super::now_unix_seconds;
|
||||
use super::summarize_session;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::fs;
|
||||
@@ -13,7 +13,7 @@ use std::time::Duration;
|
||||
const SESSION_IMPORT_MAX_COUNT: usize = 50;
|
||||
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
pub fn detect_recent_sessions(
|
||||
pub fn detect_recent_cla_sessions(
|
||||
external_agent_home: &Path,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||
@@ -110,8 +110,8 @@ pub fn detect_recent_sessions(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ledger::record_imported_session;
|
||||
use super::*;
|
||||
use crate::ledger::record_imported_session;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs::FileTimes;
|
||||
@@ -135,7 +135,8 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
@@ -163,7 +164,8 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
@@ -190,7 +192,8 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
@@ -218,7 +221,8 @@ mod tests {
|
||||
],
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
@@ -247,7 +251,8 @@ mod tests {
|
||||
)],
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
@@ -276,7 +281,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(
|
||||
detect_recent_sessions(&external_agent_home, root.path())
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path())
|
||||
.expect("detect")
|
||||
.is_empty()
|
||||
);
|
||||
@@ -313,7 +318,8 @@ mod tests {
|
||||
let mut all_sessions = expected.clone();
|
||||
all_sessions.push(oldest_session.clone());
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, expected);
|
||||
for session in sessions {
|
||||
@@ -321,7 +327,8 @@ mod tests {
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, vec![oldest_session.clone()]);
|
||||
for session in sessions {
|
||||
@@ -347,7 +354,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, expected);
|
||||
for session in sessions {
|
||||
@@ -355,7 +363,8 @@ mod tests {
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, vec![oldest_session]);
|
||||
}
|
||||
@@ -376,7 +385,7 @@ mod tests {
|
||||
.expect("record import");
|
||||
|
||||
assert!(
|
||||
detect_recent_sessions(&external_agent_home, root.path())
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path())
|
||||
.expect("detect")
|
||||
.is_empty()
|
||||
);
|
||||
@@ -404,8 +413,13 @@ mod tests {
|
||||
]),
|
||||
)
|
||||
.expect("update session");
|
||||
set_modified_at(
|
||||
&session_path,
|
||||
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
|
||||
);
|
||||
|
||||
let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
+8
-8
@@ -1,11 +1,11 @@
|
||||
use crate::ConversationMessage;
|
||||
use crate::ImportedExternalAgentSession;
|
||||
use crate::MessageRole;
|
||||
use crate::records::read_session_import;
|
||||
use crate::summarize_for_label;
|
||||
use crate::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||
use crate::title::SessionTitleCandidates;
|
||||
use crate::title::fallback_title_from_user_message;
|
||||
use super::ConversationMessage;
|
||||
use super::ImportedExternalAgentSession;
|
||||
use super::MessageRole;
|
||||
use super::records::read_session_import;
|
||||
use super::summarize_for_label;
|
||||
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||
use super::title::SessionTitleCandidates;
|
||||
use super::title::fallback_title_from_user_message;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use crate::now_unix_seconds;
|
||||
use super::now_unix_seconds;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
//! Parsing and export helpers for external-agent session histories.
|
||||
|
||||
mod detect;
|
||||
mod detect_cla;
|
||||
mod export;
|
||||
mod ledger;
|
||||
mod records;
|
||||
@@ -11,7 +11,7 @@ use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use detect::detect_recent_sessions;
|
||||
pub use detect_cla::detect_recent_cla_sessions;
|
||||
use export::load_session_for_import_with_content_sha256;
|
||||
pub use ledger::CompletedExternalAgentSessionImport;
|
||||
pub use ledger::has_current_session_been_imported;
|
||||
+9
-9
@@ -1,10 +1,10 @@
|
||||
use crate::ConversationMessage;
|
||||
use crate::ExternalAgentSessionMigration;
|
||||
use crate::MessageRole;
|
||||
use crate::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||
use crate::title::SessionTitleCandidates;
|
||||
use crate::title::fallback_title_from_user_message;
|
||||
use crate::truncate;
|
||||
use super::ConversationMessage;
|
||||
use super::ExternalAgentSessionMigration;
|
||||
use super::MessageRole;
|
||||
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||
use super::title::SessionTitleCandidates;
|
||||
use super::title::fallback_title_from_user_message;
|
||||
use super::truncate;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
@@ -422,13 +422,13 @@ mod tests {
|
||||
fn converts_tool_result_blocks_to_bounded_external_agent_tags() {
|
||||
let block = serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"content": "codex-rs/external-agent-sessions/src/records.rs"
|
||||
"content": "codex-rs/external-agent-migration/src/sessions/records.rs"
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_result_note(&block),
|
||||
"[external_agent_tool_result]\n\
|
||||
codex-rs/external-agent-sessions/src/records.rs\n\
|
||||
codex-rs/external-agent-migration/src/sessions/records.rs\n\
|
||||
[/external_agent_tool_result]"
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
use crate::SESSION_TITLE_MAX_LEN;
|
||||
use crate::truncate;
|
||||
use super::SESSION_TITLE_MAX_LEN;
|
||||
use super::truncate;
|
||||
|
||||
pub(super) const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session";
|
||||
const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 10] = [
|
||||
@@ -1,6 +0,0 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "external-agent-sessions",
|
||||
crate_name = "codex_external_agent_sessions",
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "codex-external-agent-sessions"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_external_agent_sessions"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -429,7 +429,7 @@ impl AppServerSession {
|
||||
request_id,
|
||||
params: ExternalAgentConfigImportParams {
|
||||
migration_items,
|
||||
source: None,
|
||||
source: Some("claude-code".to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -216,6 +216,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
|
||||
.external_agent_config_detect(ExternalAgentConfigDetectParams {
|
||||
include_home: true,
|
||||
cwds: Some(vec![cwd.clone()]),
|
||||
source: Some("claude-code".to_string()),
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user