Support external agent config detect and import (#12660)

Migration Behavior

* Config
  *  Migrates settings.json into config.toml
* Only adds fields when config.toml is missing, or when those fields are
missing from the existing file
  *  Supported mappings:
    env -> shell_environment_policy
     sandbox.enabled = true -> sandbox_mode = "workspace-write"

* Skills
  *  Copies home and repo .claude/skills into .agents/skills
  *  Existing skill directories are not overwritten
  *  SKILL.md content is rewritten from Claude-related terms to Codex

* AgentsMd
  *  Repo only
  *  Migrates CLAUDE.md into AGENTS.md
* Detect/import only proceed when AGENTS.md is missing or present but
empty
  *  Content is rewritten from Claude-related terms to Codex
This commit is contained in:
alexsong-oai 2026-02-25 02:11:51 -08:00 committed by GitHub
parent f46b767b7e
commit 6d6570d89d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1575 additions and 1 deletions

View file

@ -376,6 +376,70 @@
},
"type": "object"
},
"ExternalAgentConfigDetectParams": {
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"type": "object"
},
"ExternalAgentConfigImportParams": {
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"FeedbackUploadParams": {
"properties": {
"classification": {
@ -3403,6 +3467,54 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/detect"
],
"title": "ExternalAgentConfig/detectRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigDetectParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/detectRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import"
],
"title": "ExternalAgentConfig/importRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigImportParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {

View file

@ -1169,6 +1169,54 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/detect"
],
"title": "ExternalAgentConfig/detectRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExternalAgentConfigDetectParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/detectRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import"
],
"title": "ExternalAgentConfig/importRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -9125,6 +9173,95 @@
}
]
},
"ExternalAgentConfigDetectParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"title": "ExternalAgentConfigDetectParams",
"type": "object"
},
"ExternalAgentConfigDetectResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"items": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"items"
],
"title": "ExternalAgentConfigDetectResponse",
"type": "object"
},
"ExternalAgentConfigImportParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"title": "ExternalAgentConfigImportParams",
"type": "object"
},
"ExternalAgentConfigImportResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExternalAgentConfigImportResponse",
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"FeedbackUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {

View file

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"title": "ExternalAgentConfigDetectParams",
"type": "object"
}

View file

@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
}
},
"properties": {
"items": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"items"
],
"title": "ExternalAgentConfigDetectResponse",
"type": "object"
}

View file

@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
}
},
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"title": "ExternalAgentConfigImportParams",
"type": "object"
}

View file

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExternalAgentConfigImportResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigDetectParams = {
/**
* If true, include detection under the user's home (~/.claude, ~/.codex, etc.).
*/
includeHome?: boolean,
/**
* Zero or more working directories to include for repo-scoped detection.
*/
cwds?: Array<string> | null, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type ExternalAgentConfigDetectResponse = { items: Array<ExternalAgentConfigMigrationItem>, };

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigImportResponse = Record<string, never>;

View file

@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string,
/**
* Null or empty means home-scoped migration; non-empty means repo-scoped migration.
*/
cwd: string | null, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG";

View file

@ -68,6 +68,12 @@ export type { ExperimentalFeature } from "./ExperimentalFeature";
export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams";
export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse";
export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams";
export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse";
export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImportParams";
export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse";
export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type { FeedbackUploadParams } from "./FeedbackUploadParams";
export type { FeedbackUploadResponse } from "./FeedbackUploadResponse";
export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision";

View file

@ -350,6 +350,14 @@ client_request_definitions! {
params: v2::ConfigReadParams,
response: v2::ConfigReadResponse,
},
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
params: v2::ExternalAgentConfigDetectParams,
response: v2::ExternalAgentConfigDetectResponse,
},
ExternalAgentConfigImport => "externalAgentConfig/import" {
params: v2::ExternalAgentConfigImportParams,
response: v2::ExternalAgentConfigImportResponse,
},
ConfigValueWrite => "config/value/write" {
params: v2::ConfigValueWriteParams,
response: v2::ConfigWriteResponse,

View file

@ -640,6 +640,64 @@ pub struct ConfigRequirementsReadResponse {
pub requirements: Option<ConfigRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum ExternalAgentConfigMigrationItemType {
#[serde(rename = "AGENTS_MD")]
#[ts(rename = "AGENTS_MD")]
AgentsMd,
#[serde(rename = "CONFIG")]
#[ts(rename = "CONFIG")]
Config,
#[serde(rename = "SKILLS")]
#[ts(rename = "SKILLS")]
Skills,
#[serde(rename = "MCP_SERVER_CONFIG")]
#[ts(rename = "MCP_SERVER_CONFIG")]
McpServerConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
/// Null or empty means home-scoped migration; non-empty means repo-scoped migration.
pub cwd: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigDetectResponse {
pub items: Vec<ExternalAgentConfigMigrationItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigDetectParams {
/// If true, include detection under the user's home (~/.claude, ~/.codex, etc.).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_home: bool,
/// Zero or more working directories to include for repo-scoped detection.
#[ts(optional = nullable)]
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportParams {
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View file

@ -153,6 +153,8 @@ Example with notification opt-out:
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `enforceResidency`, and `network` constraints.

View file

@ -822,6 +822,10 @@ impl CodexMessageProcessor {
ClientRequest::ConfigRequirementsRead { .. } => {
warn!("ConfigRequirementsRead request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ExternalAgentConfigDetect { .. }
| ClientRequest::ExternalAgentConfigImport { .. } => {
warn!("ExternalAgentConfig request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::GetAccountRateLimits {
request_id,
params: _,

View file

@ -0,0 +1,106 @@
use crate::error_code::INTERNAL_ERROR_CODE;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use codex_core::external_agent_config::ExternalAgentConfigService;
use std::io;
use std::path::PathBuf;
#[derive(Clone)]
pub(crate) struct ExternalAgentConfigApi {
migration_service: ExternalAgentConfigService,
}
impl ExternalAgentConfigApi {
pub(crate) fn new(codex_home: PathBuf) -> Self {
Self {
migration_service: ExternalAgentConfigService::new(codex_home),
}
}
pub(crate) async fn detect(
&self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
let items = self
.migration_service
.detect(ExternalAgentConfigDetectOptions {
include_home: params.include_home,
cwds: params.cwds,
})
.map_err(map_io_error)?;
Ok(ExternalAgentConfigDetectResponse {
items: items
.into_iter()
.map(|migration_item| ExternalAgentConfigMigrationItem {
item_type: match migration_item.item_type {
CoreMigrationItemType::Config => {
ExternalAgentConfigMigrationItemType::Config
}
CoreMigrationItemType::Skills => {
ExternalAgentConfigMigrationItemType::Skills
}
CoreMigrationItemType::AgentsMd => {
ExternalAgentConfigMigrationItemType::AgentsMd
}
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
})
.collect(),
})
}
pub(crate) async fn import(
&self,
params: ExternalAgentConfigImportParams,
) -> Result<ExternalAgentConfigImportResponse, JSONRPCErrorError> {
self.migration_service
.import(
params
.migration_items
.into_iter()
.map(|migration_item| CoreMigrationItem {
item_type: match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
CoreMigrationItemType::Config
}
ExternalAgentConfigMigrationItemType::Skills => {
CoreMigrationItemType::Skills
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
CoreMigrationItemType::AgentsMd
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
})
.collect(),
)
.map_err(map_io_error)?;
Ok(ExternalAgentConfigImportResponse {})
}
}
fn map_io_error(err: io::Error) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
}
}

View file

@ -57,6 +57,7 @@ mod codex_message_processor;
mod config_api;
mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
mod filters;
mod fuzzy_file_search;
mod message_processor;

View file

@ -8,6 +8,7 @@ use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@ -22,6 +23,8 @@ use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@ -126,6 +129,7 @@ pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
external_agent_config_api: ExternalAgentConfigApi,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
@ -197,11 +201,13 @@ impl MessageProcessor {
loader_overrides,
cloud_requirements,
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
Self {
outgoing,
codex_message_processor,
config_api,
external_agent_config_api,
config,
config_warnings: Arc::new(config_warnings),
}
@ -363,6 +369,26 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::ExternalAgentConfigDetect { request_id, params } => {
self.handle_external_agent_config_detect(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigImport { request_id, params } => {
self.handle_external_agent_config_import(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(
ConnectionRequestId {
@ -492,4 +518,26 @@ impl MessageProcessor {
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_external_agent_config_detect(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigDetectParams,
) {
match self.external_agent_config_api.detect(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_external_agent_config_import(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigImportParams,
) {
match self.external_agent_config_api.import(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
}

View file

@ -0,0 +1,920 @@
use serde_json::Value as JsonValue;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
McpServerConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
}
#[derive(Clone)]
pub struct ExternalAgentConfigService {
codex_home: PathBuf,
claude_home: PathBuf,
}
impl ExternalAgentConfigService {
pub fn new(codex_home: PathBuf) -> Self {
let claude_home = default_claude_home();
Self {
codex_home,
claude_home,
}
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, claude_home: PathBuf) -> Self {
Self {
codex_home,
claude_home,
}
}
pub fn detect(
&self,
params: ExternalAgentConfigDetectOptions,
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
let mut items = Vec::new();
if params.include_home {
self.detect_migrations(None, &mut items)?;
}
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
let Some(repo_root) = find_repo_root(Some(cwd))? else {
continue;
};
self.detect_migrations(Some(&repo_root), &mut items)?;
}
Ok(items)
}
pub fn import(&self, migration_items: Vec<ExternalAgentConfigMigrationItem>) -> io::Result<()> {
for migration_item in migration_items {
match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
self.import_config(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::Skills => {
self.import_skills(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
self.import_agents_md(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
}
}
Ok(())
}
fn detect_migrations(
&self,
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.claude_home.join("settings.json"),
|repo_root| repo_root.join(".claude").join("settings.json"),
);
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
);
if source_settings.is_file() {
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if !is_empty_toml_table(&migrated) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated)?;
}
if should_include {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}.",
source_settings.display(),
target_config.display()
),
cwd: cwd.clone(),
});
}
}
}
let source_skills = repo_root.map_or_else(
|| self.claude_home.join("skills"),
|repo_root| repo_root.join(".claude").join("skills"),
);
let target_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
|repo_root| repo_root.join(".agents").join("skills"),
);
let source_skill_names = collect_subdirectory_names(&source_skills)?;
let target_skill_names = collect_subdirectory_names(&target_skills)?;
if source_skill_names
.iter()
.any(|skill_name| !target_skill_names.contains(skill_name))
{
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}.",
source_skills.display(),
target_skills.display()
),
cwd: cwd.clone(),
});
}
let source_agents_md = repo_root.map_or_else(
|| self.claude_home.join("CLAUDE.md"),
|repo_root| repo_root.join("CLAUDE.md"),
);
let target_agents_md = repo_root.map_or_else(
|| self.codex_home.join("AGENTS.md"),
|repo_root| repo_root.join("AGENTS.md"),
);
if source_agents_md.is_file() && is_missing_or_empty_text_file(&target_agents_md)? {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
source_agents_md.display(),
target_agents_md.display()
),
cwd,
});
}
Ok(())
}
fn home_target_skills_dir(&self) -> PathBuf {
self.codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("settings.json"),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
};
if !source_settings.is_file() {
return Ok(());
}
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(());
}
let Some(target_parent) = target_config.parent() else {
return Err(invalid_data_error("config target path has no parent"));
};
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
write_toml_file(&target_config, &migrated)?;
return Ok(());
}
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
let changed = merge_missing_toml_values(&mut existing, &migrated)?;
if !changed {
return Ok(());
}
write_toml_file(&target_config, &existing)?;
Ok(())
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("skills"),
self.home_target_skills_dir(),
)
};
if !source_skills.is_dir() {
return Ok(());
}
fs::create_dir_all(&target_skills)?;
for entry in fs::read_dir(&source_skills)? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let target = target_skills.join(entry.file_name());
if target.exists() {
continue;
}
copy_dir_recursive(&entry.path(), &target)?;
}
Ok(())
}
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {
(repo_root.join("CLAUDE.md"), repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("CLAUDE.md"),
self.codex_home.join("AGENTS.md"),
)
};
if !source_agents_md.is_file() || !is_missing_or_empty_text_file(&target_agents_md)? {
return Ok(());
}
let Some(target_parent) = target_agents_md.parent() else {
return Err(invalid_data_error("AGENTS.md target path has no parent"));
};
fs::create_dir_all(target_parent)?;
rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)
}
}
fn default_claude_home() -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".claude");
}
PathBuf::from(".claude")
}
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(None);
};
let mut current = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()?.join(cwd)
};
if !current.exists() {
return Ok(None);
}
if current.is_file() {
let Some(parent) = current.parent() else {
return Ok(None);
};
current = parent.to_path_buf();
}
let fallback = current.clone();
loop {
let git_path = current.join(".git");
if git_path.is_dir() || git_path.is_file() {
return Ok(Some(current));
}
if !current.pop() {
break;
}
}
Ok(Some(fallback))
}
fn collect_subdirectory_names(path: &Path) -> io::Result<HashSet<OsString>> {
let mut names = HashSet::new();
if !path.is_dir() {
return Ok(names);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
names.insert(entry.file_name());
}
}
Ok(names)
}
fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
if !path.exists() {
return Ok(true);
}
if !path.is_file() {
return Ok(false);
}
Ok(fs::read_to_string(path)?.trim().is_empty())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> 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)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
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) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_claude_terms(&source_contents);
fs::write(target, rewritten)
}
fn rewrite_claude_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
for from in [
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
] {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
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_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = start + 1;
}
if last_emitted == 0 {
return input.to_string();
}
output.push_str(&input[last_emitted..]);
output
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {
let Some(settings_obj) = settings.as_object() else {
return Err(invalid_data_error(
"external agent settings root must be an object",
));
};
let mut root = toml::map::Map::new();
if let Some(env) = settings_obj.get("env").and_then(JsonValue::as_object)
&& !env.is_empty()
{
let mut shell_policy = toml::map::Map::new();
shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string()));
shell_policy.insert(
"set".to_string(),
TomlValue::Table(json_object_to_toml_table(env)?),
);
root.insert(
"shell_environment_policy".to_string(),
TomlValue::Table(shell_policy),
);
}
if let Some(sandbox_enabled) = settings_obj
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
&& sandbox_enabled
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
Ok(TomlValue::Table(root))
}
fn json_object_to_toml_table(
object: &serde_json::Map<String, JsonValue>,
) -> io::Result<toml::map::Map<String, TomlValue>> {
let mut table = toml::map::Map::new();
for (key, value) in object {
table.insert(key.clone(), json_to_toml_value(value)?);
}
Ok(table)
}
fn json_to_toml_value(value: &JsonValue) -> io::Result<TomlValue> {
match value {
JsonValue::Null => Ok(TomlValue::String("null".to_string())),
JsonValue::Bool(v) => Ok(TomlValue::Boolean(*v)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
return Ok(TomlValue::Integer(i));
}
if let Some(f) = n.as_f64() {
return Ok(TomlValue::Float(f));
}
Err(invalid_data_error("unsupported JSON number"))
}
JsonValue::String(v) => Ok(TomlValue::String(v.clone())),
JsonValue::Array(values) => values
.iter()
.map(json_to_toml_value)
.collect::<io::Result<Vec<_>>>()
.map(TomlValue::Array),
JsonValue::Object(map) => json_object_to_toml_table(map).map(TomlValue::Table),
}
}
fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> io::Result<bool> {
match (existing, incoming) {
(TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => {
let mut changed = false;
for (key, incoming_value) in incoming_table {
match existing_table.get_mut(key) {
Some(existing_value) => {
if matches!(
(&*existing_value, incoming_value),
(TomlValue::Table(_), TomlValue::Table(_))
) && merge_missing_toml_values(existing_value, incoming_value)?
{
changed = true;
}
}
None => {
existing_table.insert(key.clone(), incoming_value.clone());
changed = true;
}
}
}
Ok(changed)
}
_ => Err(invalid_data_error(
"expected TOML table while merging migrated config values",
)),
}
}
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
let serialized = toml::to_string_pretty(value)
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
fs::write(path, format!("{serialized}\n"))
}
fn is_empty_toml_table(value: &TomlValue) -> bool {
match value {
TomlValue::Table(table) => table.is_empty(),
TomlValue::String(_)
| TomlValue::Integer(_)
| TomlValue::Float(_)
| TomlValue::Boolean(_)
| TomlValue::Datetime(_)
| TomlValue::Array(_) => false,
}
}
fn invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
(root, claude_home, codex_home)
}
fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgentConfigService {
ExternalAgentConfigService::new_for_test(codex_home, claude_home)
}
#[test]
fn detect_home_lists_config_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::write(claude_home.join("CLAUDE.md"), "claude rules").expect("write claude md");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","env":{"FOO":"bar"}}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home.clone(), codex_home.clone())
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}.",
claude_home.join("settings.json").display(),
codex_home.join("config.toml").display()
),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}.",
claude_home.join("skills").display(),
agents_skills.display()
),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
claude_home.join("CLAUDE.md").display(),
codex_home.join("AGENTS.md").display()
),
cwd: None,
},
];
assert_eq!(items, expected);
}
#[test]
fn detect_repo_lists_agents_md_for_each_cwd() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let nested = repo_root.join("nested").join("child");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(&nested).expect("create nested");
fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source");
let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![nested, repo_root.clone()]),
})
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root.clone()),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
},
];
assert_eq!(items, expected);
}
#[test]
fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar"},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#,
)
.expect("write settings");
fs::write(
claude_home.join("skills").join("skill-a").join("SKILL.md"),
"Use Claude Code and CLAUDE utilities.",
)
.expect("write skill");
fs::write(claude_home.join("CLAUDE.md"), "Claude code guidance").expect("write agents");
service_for_paths(claude_home, codex_home.clone())
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: String::new(),
cwd: None,
},
])
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"),
"Codex guidance"
);
let parsed_config: TomlValue = toml::from_str(
&fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected_config: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "bar"
"#,
)
.expect("parse expected");
assert_eq!(parsed_config, expected_config);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
"Use Codex and Codex utilities."
);
}
#[test]
fn import_home_skips_empty_config_migration() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","sandbox":{"enabled":false}}"#,
)
.expect("write settings");
service_for_paths(claude_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
}])
.expect("import");
assert!(!codex_home.join("config.toml").exists());
}
#[test]
fn detect_home_skips_config_when_target_already_has_supported_fields() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
claude_home.join("settings.json"),
r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#,
)
.expect("write settings");
fs::write(
codex_home.join("config.toml"),
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "bar"
"#,
)
.expect("write config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_home_skips_skills_when_all_skill_directories_exist() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source");
fs::create_dir_all(agents_skills.join("skill-a")).expect("create target");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo-a");
let repo_with_existing_target = root.path().join("repo-b");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git");
fs::write(
repo_root.join("CLAUDE.md"),
"Claude code\nclaude\nCLAUDE-CODE\nSee CLAUDE.md\n",
)
.expect("write source");
fs::write(repo_with_existing_target.join("CLAUDE.md"), "new source").expect("write source");
fs::write(
repo_with_existing_target.join("AGENTS.md"),
"keep existing target",
)
.expect("write target");
service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_with_existing_target.clone()),
},
])
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex\nCodex\nCodex\nSee AGENTS.md\n"
);
assert_eq!(
fs::read_to_string(repo_with_existing_target.join("AGENTS.md"))
.expect("read existing target"),
"keep existing target"
);
}
#[test]
fn import_repo_agents_md_overwrites_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source");
fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target");
service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
}])
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex guidance"
);
}
}

View file

@ -34,6 +34,7 @@ pub mod error;
pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod external_agent_config;
pub mod features;
mod file_watcher;
mod flags;