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:
parent
f46b767b7e
commit
6d6570d89d
23 changed files with 1575 additions and 1 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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, };
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/")]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: _,
|
||||
|
|
|
|||
106
codex-rs/app-server/src/external_agent_config_api.rs
Normal file
106
codex-rs/app-server/src/external_agent_config_api.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
920
codex-rs/core/src/external_agent_config.rs
Normal file
920
codex-rs/core/src/external_agent_config.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue