support plugin/list. (#13540)

Introduce a plugin/list which reads from local marketplace.json.
Also update the signature for plugin/install.
This commit is contained in:
xl-openai 2026-03-05 21:58:50 -05:00 committed by GitHub
parent 56420da857
commit 520ed724d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1645 additions and 145 deletions

View file

@ -953,25 +953,34 @@
},
"PluginInstallParams": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginName"
],
"type": "object"
},
"PluginListParams": {
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
@ -3264,6 +3273,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/list"
],
"title": "Plugin/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {

View file

@ -716,6 +716,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/list"
],
"title": "Plugin/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -11174,21 +11198,15 @@
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
"marketplacePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginName"
],
"title": "PluginInstallParams",
@ -11199,6 +11217,104 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
"type": "object"
},
"PluginListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaces": {
"items": {
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
},
"type": "array"
}
},
"required": [
"marketplaces"
],
"title": "PluginListResponse",
"type": "object"
},
"PluginMarketplaceEntry": {
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/definitions/v2/PluginSummary"
},
"type": "array"
}
},
"required": [
"name",
"path",
"plugins"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"local"
],
"title": "LocalPluginSourceType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalPluginSource",
"type": "object"
}
]
},
"PluginSummary": {
"properties": {
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/v2/PluginSource"
}
},
"required": [
"enabled",
"name",
"source"
],
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",

View file

@ -1211,6 +1211,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/list"
],
"title": "Plugin/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -8371,21 +8395,15 @@
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginName"
],
"title": "PluginInstallParams",
@ -8396,6 +8414,104 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
"type": "object"
},
"PluginListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaces": {
"items": {
"$ref": "#/definitions/PluginMarketplaceEntry"
},
"type": "array"
}
},
"required": [
"marketplaces"
],
"title": "PluginListResponse",
"type": "object"
},
"PluginMarketplaceEntry": {
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/definitions/PluginSummary"
},
"type": "array"
}
},
"required": [
"name",
"path",
"plugins"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"local"
],
"title": "LocalPluginSourceType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalPluginSource",
"type": "object"
}
]
},
"PluginSummary": {
"properties": {
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/PluginSource"
}
},
"required": [
"enabled",
"name",
"source"
],
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",

View file

@ -1,21 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginName"
],
"title": "PluginInstallParams",

View file

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
"type": "object"
}

View file

@ -0,0 +1,83 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PluginMarketplaceEntry": {
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"plugins": {
"items": {
"$ref": "#/definitions/PluginSummary"
},
"type": "array"
}
},
"required": [
"name",
"path",
"plugins"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"local"
],
"title": "LocalPluginSourceType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalPluginSource",
"type": "object"
}
]
},
"PluginSummary": {
"properties": {
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/PluginSource"
}
},
"required": [
"enabled",
"name",
"source"
],
"type": "object"
}
},
"properties": {
"marketplaces": {
"items": {
"$ref": "#/definitions/PluginMarketplaceEntry"
},
"type": "array"
}
},
"required": [
"marketplaces"
],
"title": "PluginListResponse",
"type": "object"
}

View file

@ -23,6 +23,7 @@ import type { LoginAccountParams } from "./v2/LoginAccountParams";
import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams";
import type { ModelListParams } from "./v2/ModelListParams";
import type { PluginInstallParams } from "./v2/PluginInstallParams";
import type { PluginListParams } from "./v2/PluginListParams";
import type { ReviewStartParams } from "./v2/ReviewStartParams";
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
import type { SkillsListParams } from "./v2/SkillsListParams";
@ -49,4 +50,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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "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": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "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": "plugin/list", id: RequestId, params: PluginListParams, } | { "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": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };

View file

@ -1,5 +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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, };
export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, };

View file

@ -0,0 +1,11 @@
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginListParams = {
/**
* Optional working directories used to discover repo marketplaces. When omitted,
* only home-scoped marketplaces are considered.
*/
cwds?: Array<AbsolutePathBuf> | null, };

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, };

View file

@ -129,6 +129,11 @@ export type { PatchChangeKind } from "./PatchChangeKind";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginListParams } from "./PluginListParams";
export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { ProductSurface } from "./ProductSurface";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";

View file

@ -248,6 +248,10 @@ client_request_definitions! {
params: v2::SkillsListParams,
response: v2::SkillsListResponse,
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
response: v2::PluginListResponse,
},
SkillsRemoteList => "skills/remote/list" {
params: v2::SkillsRemoteReadParams,
response: v2::SkillsRemoteReadResponse,

View file

@ -2371,6 +2371,23 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginListParams {
/// Optional working directories used to discover repo marketplaces. When omitted,
/// only home-scoped marketplaces are considered.
#[ts(optional = nullable)]
pub cwds: Option<Vec<AbsolutePathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginListResponse {
pub marketplaces: Vec<PluginMarketplaceEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -2534,6 +2551,34 @@ pub struct SkillsListEntry {
pub errors: Vec<SkillErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginMarketplaceEntry {
pub name: String,
pub path: PathBuf,
pub plugins: Vec<PluginSummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSummary {
pub name: String,
pub source: PluginSource,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum PluginSource {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Local { path: PathBuf },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -2553,10 +2598,8 @@ pub struct SkillsConfigWriteResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallParams {
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub plugin_name: String,
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View file

@ -148,12 +148,13 @@ Example with notification opt-out:
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplaceName` (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath` (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 13 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.

View file

@ -79,6 +79,11 @@ use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::ProductSurface as ApiProductSurface;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
@ -198,6 +203,8 @@ use codex_core::mcp::collect_mcp_snapshot;
use codex_core::mcp::group_tools_by_server;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::parse_cursor;
use codex_core::plugins::MarketplaceError;
use codex_core::plugins::MarketplacePluginSourceSummary;
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
use codex_core::plugins::PluginInstallRequest;
use codex_core::read_head_for_summary;
@ -646,6 +653,10 @@ impl CodexMessageProcessor {
self.skills_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginList { request_id, params } => {
self.plugin_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsRemoteList { request_id, params } => {
self.skills_remote_list(to_connection_request_id(request_id), params)
.await;
@ -4318,6 +4329,30 @@ impl CodexMessageProcessor {
self.outgoing.send_error(request_id, error).await;
}
async fn send_marketplace_error(
&self,
request_id: ConnectionRequestId,
err: MarketplaceError,
action: &str,
) {
match err {
MarketplaceError::MarketplaceNotFound { .. } => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
}
MarketplaceError::Io { .. } => {
self.send_internal_error(request_id, format!("failed to {action}: {err}"))
.await;
}
MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::InvalidPlugin(_) => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
}
}
}
async fn wait_for_thread_shutdown(thread: &Arc<CodexThread>) -> ThreadShutdownResult {
match thread.submit(Op::Shutdown).await {
Ok(_) => {
@ -4924,6 +4959,66 @@ impl CodexMessageProcessor {
.await;
}
async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) {
let plugins_manager = self.thread_manager.plugins_manager();
let roots = params.cwds.unwrap_or_default();
let config = match self.load_latest_config().await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let data = match tokio::task::spawn_blocking(move || {
let marketplaces = plugins_manager.list_marketplaces_for_config(&config, &roots)?;
Ok::<Vec<PluginMarketplaceEntry>, MarketplaceError>(
marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
path: marketplace.path,
plugins: marketplace
.plugins
.into_iter()
.map(|plugin| PluginSummary {
enabled: plugin.enabled,
name: plugin.name,
source: match plugin.source {
MarketplacePluginSourceSummary::Local { path } => {
PluginSource::Local { path }
}
},
})
.collect(),
})
.collect(),
)
})
.await
{
Ok(Ok(data)) => data,
Ok(Err(err)) => {
self.send_marketplace_error(request_id, err, "list marketplace plugins")
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to list marketplace plugins: {err}"),
)
.await;
return;
}
};
self.outgoing
.send_response(request_id, PluginListResponse { marketplaces: data })
.await;
}
async fn skills_remote_list(
&self,
request_id: ConnectionRequestId,
@ -5034,16 +5129,14 @@ impl CodexMessageProcessor {
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
let PluginInstallParams {
marketplace_name,
marketplace_path,
plugin_name,
cwd,
} = params;
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
plugin_name,
marketplace_name,
cwd: cwd.unwrap_or_else(|| self.config.cwd.clone()),
marketplace_path,
};
match plugins_manager.install_plugin(request).await {
@ -5062,6 +5155,10 @@ impl CodexMessageProcessor {
}
match err {
CorePluginInstallError::Marketplace(err) => {
self.send_marketplace_error(request_id, err, "install plugin")
.await;
}
CorePluginInstallError::Config(err) => {
self.send_internal_error(
request_id,
@ -5076,7 +5173,13 @@ impl CodexMessageProcessor {
)
.await;
}
CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {}
CorePluginInstallError::Store(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
}
}
}

View file

@ -35,6 +35,8 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ServerRequest;
@ -439,6 +441,33 @@ impl McpProcess {
self.send_request("skills/list", params).await
}
/// Send a `plugin/install` JSON-RPC request.
pub async fn send_plugin_install_request(
&mut self,
params: PluginInstallParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("plugin/install", params).await
}
/// Send a `plugin/list` JSON-RPC request.
pub async fn send_plugin_list_request(
&mut self,
params: PluginListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("plugin/list", params).await
}
/// Send a JSON-RPC request with raw params for protocol-level validation tests.
pub async fn send_raw_request(
&mut self,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<i64> {
self.send_request(method, params).await
}
/// Send a `collaborationMode/list` JSON-RPC request.
pub async fn send_list_collaboration_modes_request(
&mut self,

View file

@ -15,6 +15,8 @@ mod mcp_server_elicitation;
mod model_list;
mod output_schema;
mod plan_item;
mod plugin_install;
mod plugin_list;
mod rate_limits;
mod realtime_conversation;
mod request_user_input;

View file

@ -0,0 +1,66 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/install",
Some(serde_json::json!({
"marketplacePath": "relative-marketplace.json",
"pluginName": "missing-plugin",
})),
)
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("Invalid request"));
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: AbsolutePathBuf::try_from(
codex_home.path().join("missing-marketplace.json"),
)?,
plugin_name: "missing-plugin".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("marketplace file"));
assert!(err.error.message.contains("does not exist"));
Ok(())
}

View file

@ -0,0 +1,299 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn plugin_list_returns_invalid_request_for_invalid_marketplace_file() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
"{not json",
)?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid marketplace file"));
Ok(())
}
#[tokio::test]
async fn plugin_list_rejects_relative_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/list",
Some(serde_json::json!({
"cwds": ["relative-root"],
})),
)
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("Invalid request"));
Ok(())
}
#[tokio::test]
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "home-plugin",
"source": {
"source": "local",
"path": "./home-plugin"
}
}
]
}"#,
)?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: PluginListResponse = to_response(response)?;
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
},
{
"name": "disabled-plugin",
"source": {
"source": "local",
"path": "./disabled-plugin"
}
}
]
}"#,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
[plugins."enabled-plugin@codex-curated"]
enabled = true
[plugins."disabled-plugin@codex-curated"]
enabled = false
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json")
})
.expect("expected repo marketplace entry");
assert_eq!(marketplace.name, "codex-curated");
assert_eq!(marketplace.plugins.len(), 2);
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
assert_eq!(marketplace.plugins[0].enabled, true);
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
assert_eq!(marketplace.plugins[1].enabled, false);
Ok(())
}
#[tokio::test]
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "shared-plugin",
"source": {
"source": "local",
"path": "./shared-plugin"
}
}
]
}"#,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
[plugins."shared-plugin@codex-curated"]
enabled = true
"#,
)?;
let workspace_enabled = TempDir::new()?;
std::fs::create_dir_all(workspace_enabled.path().join(".git"))?;
std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?;
std::fs::write(
workspace_enabled
.path()
.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "shared-plugin",
"source": {
"source": "local",
"path": "./shared-plugin"
}
}
]
}"#,
)?;
std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?;
std::fs::write(
workspace_enabled.path().join(".codex/config.toml"),
r#"[plugins."shared-plugin@codex-curated"]
enabled = false
"#,
)?;
set_project_trust_level(
codex_home.path(),
workspace_enabled.path(),
TrustLevel::Trusted,
)?;
let workspace_default = TempDir::new()?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![
AbsolutePathBuf::try_from(workspace_enabled.path())?,
AbsolutePathBuf::try_from(workspace_default.path())?,
]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let shared_plugin = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "shared-plugin")
.expect("expected shared-plugin entry");
assert_eq!(shared_plugin.enabled, true);
Ok(())
}

View file

@ -1,5 +1,7 @@
use super::load_plugin_manifest;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplacePluginSourceSummary;
use super::marketplace::list_marketplaces;
use super::marketplace::resolve_marketplace_plugin;
use super::plugin_manifest_name;
use super::store::DEFAULT_PLUGIN_VERSION;
@ -26,6 +28,7 @@ use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
@ -42,8 +45,21 @@ pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
pub plugin_name: String,
pub marketplace_name: String,
pub cwd: PathBuf,
pub marketplace_path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceSummary {
pub name: String,
pub path: PathBuf,
pub plugins: Vec<ConfiguredMarketplacePluginSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
@ -234,11 +250,7 @@ impl PluginsManager {
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
let resolved = resolve_marketplace_plugin(
&request.cwd,
&request.plugin_name,
&request.marketplace_name,
)?;
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || {
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
@ -262,6 +274,56 @@ impl PluginsManager {
Ok(result)
}
pub fn list_marketplaces_for_config(
&self,
config: &Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<ConfiguredMarketplaceSummary>, MarketplaceError> {
let configured_plugins = self
.plugins_for_config(config)
.plugins()
.iter()
.map(|plugin| (plugin.config_name.clone(), plugin.enabled))
.collect::<HashMap<String, bool>>();
let marketplaces = list_marketplaces(additional_roots)?;
let mut seen_plugin_keys = HashSet::new();
Ok(marketplaces
.into_iter()
.filter_map(|marketplace| {
let marketplace_name = marketplace.name.clone();
let plugins = marketplace
.plugins
.into_iter()
.filter_map(|plugin| {
let plugin_key = format!("{}@{marketplace_name}", plugin.name);
if !seen_plugin_keys.insert(plugin_key.clone()) {
return None;
}
Some(ConfiguredMarketplacePluginSummary {
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
// plugin entries from duplicate marketplace files intentionally
// resolve to the first discovered source.
enabled: configured_plugins
.get(&plugin_key)
.copied()
.unwrap_or(false),
name: plugin.name,
source: plugin.source,
})
})
.collect::<Vec<_>>();
(!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary {
name: marketplace.name,
path: marketplace.path,
plugins,
})
})
.collect())
}
}
#[derive(Debug, thiserror::Error)]
@ -288,9 +350,9 @@ impl PluginInstallError {
matches!(
self,
Self::Marketplace(
MarketplaceError::InvalidMarketplaceFile { .. }
MarketplaceError::MarketplaceNotFound { .. }
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::DuplicatePlugin { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
@ -1086,8 +1148,10 @@ mod tests {
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_name: "debug".to_string(),
cwd: repo_root.clone(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap();
@ -1106,4 +1170,207 @@ mod tests {
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn list_marketplaces_for_config_includes_enabled_state() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
},
{
"name": "disabled-plugin",
"source": {
"source": "local",
"path": "./disabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."enabled-plugin@debug"]
enabled = true
[plugins."disabled-plugin@debug"]
enabled = false
"#,
);
let config = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.build()
.await
.expect("config should load");
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path == tmp.path().join("repo/.agents/plugins/marketplace.json")
})
.expect("expected repo marketplace entry");
assert_eq!(
marketplace,
ConfiguredMarketplaceSummary {
name: "debug".to_string(),
path: tmp.path().join("repo/.agents/plugins/marketplace.json"),
plugins: vec![
ConfiguredMarketplacePluginSummary {
name: "enabled-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: tmp.path().join("repo/.agents/plugins/enabled-plugin"),
},
enabled: true,
},
ConfiguredMarketplacePluginSummary {
name: "disabled-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: tmp.path().join("repo/.agents/plugins/disabled-plugin"),
},
enabled: false,
},
],
}
);
}
#[tokio::test]
async fn list_marketplaces_for_config_uses_first_duplicate_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();
let repo_a_root = tmp.path().join("repo-a");
let repo_b_root = tmp.path().join("repo-b");
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
fs::write(
repo_a_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "dup-plugin",
"source": {
"source": "local",
"path": "./from-a"
}
}
]
}"#,
)
.unwrap();
fs::write(
repo_b_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "dup-plugin",
"source": {
"source": "local",
"path": "./from-b"
}
},
{
"name": "b-only-plugin",
"source": {
"source": "local",
"path": "./from-b-only"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."dup-plugin@debug"]
enabled = true
[plugins."b-only-plugin@debug"]
enabled = false
"#,
);
let config = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.build()
.await
.expect("config should load");
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
],
)
.unwrap();
let repo_a_marketplace = marketplaces
.iter()
.find(|marketplace| {
marketplace.path == tmp.path().join("repo-a/.agents/plugins/marketplace.json")
})
.expect("repo-a marketplace should be listed");
assert_eq!(
repo_a_marketplace.plugins,
vec![ConfiguredMarketplacePluginSummary {
name: "dup-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: tmp.path().join("repo-a/.agents/plugins/from-a"),
},
enabled: true,
}]
);
let repo_b_marketplace = marketplaces
.iter()
.find(|marketplace| {
marketplace.path == tmp.path().join("repo-b/.agents/plugins/marketplace.json")
})
.expect("repo-b marketplace should be listed");
assert_eq!(
repo_b_marketplace.plugins,
vec![ConfiguredMarketplacePluginSummary {
name: "b-only-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: tmp.path().join("repo-b/.agents/plugins/from-b-only"),
},
enabled: false,
}]
);
let duplicate_plugin_count = marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.filter(|plugin| plugin.name == "dup-plugin")
.count();
assert_eq!(duplicate_plugin_count, 1);
}
}

View file

@ -18,6 +18,24 @@ pub struct ResolvedMarketplacePlugin {
pub source_path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceSummary {
pub name: String,
pub path: PathBuf,
pub plugins: Vec<MarketplacePluginSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSourceSummary {
Local { path: PathBuf },
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceError {
#[error("{context}: {source}")]
@ -27,6 +45,9 @@ pub enum MarketplaceError {
source: io::Error,
},
#[error("marketplace file `{path}` does not exist")]
MarketplaceNotFound { path: PathBuf },
#[error("invalid marketplace file `{path}`: {message}")]
InvalidMarketplaceFile { path: PathBuf, message: String },
@ -36,14 +57,6 @@ pub enum MarketplaceError {
marketplace_name: String,
},
#[error(
"multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`"
)]
DuplicatePlugin {
plugin_name: String,
marketplace_name: String,
},
#[error("{0}")]
InvalidPlugin(String),
}
@ -54,77 +67,97 @@ impl MarketplaceError {
}
}
// For now, marketplace discovery always reads from disk so installs see the latest
// marketplace.json contents without any in-memory cache invalidation.
// Always read the specified marketplace file from disk so installs see the
// latest marketplace.json contents without any in-memory cache invalidation.
pub fn resolve_marketplace_plugin(
cwd: &Path,
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
marketplace_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
resolve_marketplace_plugin_from_paths(
&discover_marketplace_paths(cwd),
plugin_name,
marketplace_name,
)
}
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace_name = marketplace.name;
let plugin = marketplace
.plugins
.into_iter()
.find(|plugin| plugin.name == plugin_name);
fn resolve_marketplace_plugin_from_paths(
marketplace_paths: &[PathBuf],
plugin_name: &str,
marketplace_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
for marketplace_path in marketplace_paths {
let marketplace = load_marketplace(marketplace_path)?;
let discovered_marketplace_name = marketplace.name;
let mut matches = marketplace
.plugins
.into_iter()
.filter(|plugin| plugin.name == plugin_name)
.collect::<Vec<_>>();
let Some(plugin) = plugin else {
return Err(MarketplaceError::PluginNotFound {
plugin_name: plugin_name.to_string(),
marketplace_name,
});
};
if discovered_marketplace_name != marketplace_name || matches.is_empty() {
continue;
}
if matches.len() > 1 {
return Err(MarketplaceError::DuplicatePlugin {
plugin_name: plugin_name.to_string(),
marketplace_name: marketplace_name.to_string(),
});
}
if let Some(plugin) = matches.pop() {
let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err(
|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
},
)?;
return Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
});
}
}
Err(MarketplaceError::PluginNotFound {
plugin_name: plugin_name.to_string(),
marketplace_name: marketplace_name.to_string(),
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
})
}
fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
fn list_marketplaces_with_home(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
let mut marketplaces = Vec::new();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source = match plugin.source {
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
path: resolve_plugin_source_path(
marketplace_path.as_path(),
MarketplacePluginSource::Local { path },
)?
.into_path_buf(),
},
};
plugins.push(MarketplacePluginSummary {
name: plugin.name,
source,
});
}
marketplaces.push(MarketplaceSummary {
name: marketplace.name,
path: marketplace_path,
plugins,
});
}
Ok(marketplaces)
}
fn discover_marketplace_paths_from_roots(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(repo_root) = get_git_repo_root(cwd) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if let Some(home) = home_dir {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
paths.push(path);
}
}
if let Some(home) = home_dir() {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
paths.push(path);
for root in additional_roots {
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() && !paths.contains(&path) {
paths.push(path);
}
}
}
@ -132,8 +165,15 @@ fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
}
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path)
.map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?;
let contents = fs::read_to_string(path).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
MarketplaceError::MarketplaceNotFound {
path: path.to_path_buf(),
}
} else {
MarketplaceError::io("failed to read marketplace file", err)
}
})?;
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: path.to_path_buf(),
message: err.to_string(),
@ -233,9 +273,11 @@ mod tests {
)
.unwrap();
let resolved =
resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated")
.unwrap();
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
)
.unwrap();
assert_eq!(
resolved,
@ -260,7 +302,11 @@ mod tests {
)
.unwrap();
let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err();
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"missing",
)
.unwrap_err();
assert_eq!(
err.to_string(),
@ -269,7 +315,112 @@ mod tests {
}
#[test]
fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() {
fn list_marketplaces_returns_home_and_repo_marketplaces() {
let tmp = tempdir().unwrap();
let home_root = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
home_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "shared-plugin",
"source": {
"source": "local",
"path": "./home-shared"
}
},
{
"name": "home-only",
"source": {
"source": "local",
"path": "./home-only"
}
}
]
}"#,
)
.unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "shared-plugin",
"source": {
"source": "local",
"path": "./repo-shared"
}
},
{
"name": "repo-only",
"source": {
"source": "local",
"path": "./repo-only"
}
}
]
}"#,
)
.unwrap();
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
assert_eq!(
marketplaces,
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_root.join(".agents/plugins/marketplace.json"),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-shared"),
},
},
MarketplacePluginSummary {
name: "home-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-only"),
},
},
],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-shared"),
},
},
MarketplacePluginSummary {
name: "repo-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-only"),
},
},
],
},
]
);
}
#[test]
fn list_marketplaces_keeps_distinct_entries_for_same_name() {
let tmp = tempdir().unwrap();
let home_root = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
@ -313,23 +464,97 @@ mod tests {
)
.unwrap();
let resolved = resolve_marketplace_plugin_from_paths(
&[repo_marketplace, home_marketplace],
"local-plugin",
"codex-curated",
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
assert_eq!(
resolved,
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/repo-plugin"),
)
.unwrap(),
}
marketplaces,
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_marketplace,
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-plugin"),
},
}],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_marketplace.clone(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-plugin"),
},
}],
},
]
);
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
"local-plugin",
)
.unwrap();
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
);
}
#[test]
fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let nested_root = repo_root.join("nested/project");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(&nested_root).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./plugin"
}
}
]
}"#,
)
.unwrap();
let marketplaces = list_marketplaces_with_home(
&[
AbsolutePathBuf::try_from(repo_root.clone()).unwrap(),
AbsolutePathBuf::try_from(nested_root).unwrap(),
],
None,
)
.unwrap();
assert_eq!(
marketplaces,
vec![MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/plugin"),
},
}],
}]
);
}
@ -356,8 +581,11 @@ mod tests {
)
.unwrap();
let err =
resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err();
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
)
.unwrap_err();
assert_eq!(
err.to_string(),
@ -367,4 +595,46 @@ mod tests {
)
);
}
#[test]
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./first"
}
},
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./second"
}
}
]
}"#,
)
.unwrap();
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
)
.unwrap();
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
);
}
}

View file

@ -7,6 +7,8 @@ mod store;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginInstallError;
@ -16,6 +18,8 @@ pub use manager::PluginsManager;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_name;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;