diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 1b7f99d39..f4b94339e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 73df29a3d..4282f1c05 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json new file mode 100644 index 000000000..20ddd6e48 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json new file mode 100644 index 000000000..a73e515c2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json new file mode 100644 index 000000000..85af24959 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json new file mode 100644 index 000000000..6823495d3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 834459287..4e8069046 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -28,6 +28,8 @@ import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; +import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; +import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; @@ -58,4 +60,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts new file mode 100644 index 000000000..2a79b8107 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectResponse.ts new file mode 100644 index 000000000..f220e3b61 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts new file mode 100644 index 000000000..7bc5d9d91 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportResponse.ts new file mode 100644 index 000000000..2ceddade0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportResponse.ts @@ -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; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts new file mode 100644 index 000000000..442143c81 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts new file mode 100644 index 000000000..c9bd160b1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 1a0b22343..5d033d363 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b3d3b4ae7..b7df2593b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ab0bd0559..22b7fe05c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -640,6 +640,64 @@ pub struct ConfigRequirementsReadResponse { pub requirements: Option, } +#[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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectResponse { + pub items: Vec, +} + +#[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>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportParams { + pub migration_items: Vec, +} + +#[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/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 762c3d91d..0be424a40 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 75d7aaf4d..ac3f09f82 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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: _, diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/external_agent_config_api.rs new file mode 100644 index 000000000..7cf0d65d7 --- /dev/null +++ b/codex-rs/app-server/src/external_agent_config_api.rs @@ -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 { + 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 { + 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, + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 5716dbb2d..206435592 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -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; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8d5412d4e..252705724 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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, codex_message_processor: CodexMessageProcessor, config_api: ConfigApi, + external_agent_config_api: ExternalAgentConfigApi, config: Arc, config_warnings: Arc>, } @@ -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, + } + } } diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs new file mode 100644 index 000000000..1d466e6f4 --- /dev/null +++ b/codex-rs/core/src/external_agent_config.rs @@ -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>, +} + +#[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, +} + +#[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> { + 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) -> 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, + ) -> 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::(&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::(&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> { + 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> { + 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 { + 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 { + 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, +) -> io::Result> { + 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 { + 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::>>() + .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 { + 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) -> 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::::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::::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" + ); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a9ca37e70..fdac1accb 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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;