Add hooks/list app-server RPC (#19778)

## Why

We need a way to list the available hooks to expose via the TUI and App
so users can view and manage their hooks

## What

- Adds `hooks/list` for one or more `cwd` values that returns discovered
hook metadata

## Stack

1. openai/codex#19705
2. This PR - openai/codex#19778
3. openai/codex#19840
4. openai/codex#19882

## Review Notes

The generated schema files account for most of the raw diff, these files
have the core change:

- `hooks/src/engine/discovery.rs` builds the inventory entries during
hook discovery while leaving runtime handlers focused on execution.
- `app-server/src/codex_message_processor.rs` wires `hooks/list` into
the app-server flow for each requested `cwd`.
- `app-server-protocol/src/protocol/v2.rs` defines the new v2
request/response payloads exposed on the wire.

### Core Changes

`core/src/plugins/manager.rs` adds `plugins_for_layer_stack(...)` so
`skills/list` and `hooks/list`can resolve plugin state for each
requested `cwd`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-29 23:39:57 +00:00
committed by GitHub
co-authored by Codex
parent 6eab7519b4
commit 8774229a89
28 changed files with 1405 additions and 193 deletions
+1
View File
@@ -1874,6 +1874,7 @@ dependencies = [
"codex-feedback", "codex-feedback",
"codex-file-search", "codex-file-search",
"codex-git-utils", "codex-git-utils",
"codex-hooks",
"codex-login", "codex-login",
"codex-mcp", "codex-mcp",
"codex-memories-write", "codex-memories-write",
@@ -1415,6 +1415,18 @@
], ],
"type": "object" "type": "object"
}, },
"HooksListParams": {
"properties": {
"cwds": {
"description": "When empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"ImageDetail": { "ImageDetail": {
"enum": [ "enum": [
"auto", "auto",
@@ -4860,6 +4872,30 @@
"title": "Skills/listRequest", "title": "Skills/listRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -642,6 +642,30 @@
"title": "Skills/listRequest", "title": "Skills/listRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -9568,6 +9592,21 @@
"title": "HookCompletedNotification", "title": "HookCompletedNotification",
"type": "object" "type": "object"
}, },
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": { "HookEventName": {
"enum": [ "enum": [
"preToolUse", "preToolUse",
@@ -9594,6 +9633,64 @@
], ],
"type": "string" "type": "string"
}, },
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"eventName": {
"$ref": "#/definitions/v2/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/v2/HookHandlerType"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/v2/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"displayOrder",
"eventName",
"handlerType",
"source",
"sourcePath",
"timeoutSec"
],
"type": "object"
},
"HookMigration": { "HookMigration": {
"properties": { "properties": {
"name": { "name": {
@@ -9779,6 +9876,68 @@
"title": "HookStartedNotification", "title": "HookStartedNotification",
"type": "object" "type": "object"
}, },
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/v2/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/v2/HookMetadata"
},
"type": "array"
},
"warnings": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks",
"warnings"
],
"type": "object"
},
"HooksListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": "array"
}
},
"title": "HooksListParams",
"type": "object"
},
"HooksListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
},
"ImageDetail": { "ImageDetail": {
"enum": [ "enum": [
"auto", "auto",
@@ -1348,6 +1348,30 @@
"title": "Skills/listRequest", "title": "Skills/listRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"hooks/list"
],
"title": "Hooks/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/HooksListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Hooks/listRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -6178,6 +6202,21 @@
"title": "HookCompletedNotification", "title": "HookCompletedNotification",
"type": "object" "type": "object"
}, },
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": { "HookEventName": {
"enum": [ "enum": [
"preToolUse", "preToolUse",
@@ -6204,6 +6243,64 @@
], ],
"type": "string" "type": "string"
}, },
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"displayOrder",
"eventName",
"handlerType",
"source",
"sourcePath",
"timeoutSec"
],
"type": "object"
},
"HookMigration": { "HookMigration": {
"properties": { "properties": {
"name": { "name": {
@@ -6389,6 +6486,68 @@
"title": "HookStartedNotification", "title": "HookStartedNotification",
"type": "object" "type": "object"
}, },
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/HookMetadata"
},
"type": "array"
},
"warnings": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks",
"warnings"
],
"type": "object"
},
"HooksListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": "array"
}
},
"title": "HooksListParams",
"type": "object"
},
"HooksListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
},
"ImageDetail": { "ImageDetail": {
"enum": [ "enum": [
"auto", "auto",
@@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "When empty, defaults to the current session working directory.",
"items": {
"type": "string"
},
"type": "array"
}
},
"title": "HooksListParams",
"type": "object"
}
@@ -0,0 +1,160 @@
{
"$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"
},
"HookErrorInfo": {
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"message",
"path"
],
"type": "object"
},
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
},
"HookHandlerType": {
"enum": [
"command",
"prompt",
"agent"
],
"type": "string"
},
"HookMetadata": {
"properties": {
"command": {
"type": [
"string",
"null"
]
},
"displayOrder": {
"format": "int64",
"type": "integer"
},
"eventName": {
"$ref": "#/definitions/HookEventName"
},
"handlerType": {
"$ref": "#/definitions/HookHandlerType"
},
"matcher": {
"type": [
"string",
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"source": {
"$ref": "#/definitions/HookSource"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"displayOrder",
"eventName",
"handlerType",
"source",
"sourcePath",
"timeoutSec"
],
"type": "object"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HooksListEntry": {
"properties": {
"cwd": {
"type": "string"
},
"errors": {
"items": {
"$ref": "#/definitions/HookErrorInfo"
},
"type": "array"
},
"hooks": {
"items": {
"$ref": "#/definitions/HookMetadata"
},
"type": "array"
},
"warnings": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"cwd",
"errors",
"hooks",
"warnings"
],
"type": "object"
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/HooksListEntry"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "HooksListResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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 HookErrorInfo = { path: string, message: string, };
@@ -0,0 +1,9 @@
// 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";
import type { HookEventName } from "./HookEventName";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookSource } from "./HookSource";
export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, };
@@ -0,0 +1,7 @@
// 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 { HookErrorInfo } from "./HookErrorInfo";
import type { HookMetadata } from "./HookMetadata";
export type HooksListEntry = { cwd: string, hooks: Array<HookMetadata>, warnings: Array<string>, errors: Array<HookErrorInfo>, };
@@ -0,0 +1,9 @@
// 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 HooksListParams = {
/**
* When empty, defaults to the current session working directory.
*/
cwds?: Array<string>, };
@@ -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 { HooksListEntry } from "./HooksListEntry";
export type HooksListResponse = { data: Array<HooksListEntry>, };
@@ -152,9 +152,11 @@ export type { GuardianRiskLevel } from "./GuardianRiskLevel";
export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization";
export type { GuardianWarningNotification } from "./GuardianWarningNotification"; export type { GuardianWarningNotification } from "./GuardianWarningNotification";
export type { HookCompletedNotification } from "./HookCompletedNotification"; export type { HookCompletedNotification } from "./HookCompletedNotification";
export type { HookErrorInfo } from "./HookErrorInfo";
export type { HookEventName } from "./HookEventName"; export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode"; export type { HookExecutionMode } from "./HookExecutionMode";
export type { HookHandlerType } from "./HookHandlerType"; export type { HookHandlerType } from "./HookHandlerType";
export type { HookMetadata } from "./HookMetadata";
export type { HookMigration } from "./HookMigration"; export type { HookMigration } from "./HookMigration";
export type { HookOutputEntry } from "./HookOutputEntry"; export type { HookOutputEntry } from "./HookOutputEntry";
export type { HookOutputEntryKind } from "./HookOutputEntryKind"; export type { HookOutputEntryKind } from "./HookOutputEntryKind";
@@ -164,6 +166,9 @@ export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope"; export type { HookScope } from "./HookScope";
export type { HookSource } from "./HookSource"; export type { HookSource } from "./HookSource";
export type { HookStartedNotification } from "./HookStartedNotification"; export type { HookStartedNotification } from "./HookStartedNotification";
export type { HooksListEntry } from "./HooksListEntry";
export type { HooksListParams } from "./HooksListParams";
export type { HooksListResponse } from "./HooksListResponse";
export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification";
@@ -581,6 +581,11 @@ client_request_definitions! {
serialization: global("config"), serialization: global("config"),
response: v2::SkillsListResponse, response: v2::SkillsListResponse,
}, },
HooksList => "hooks/list" {
params: v2::HooksListParams,
serialization: global("config"),
response: v2::HooksListResponse,
},
MarketplaceAdd => "marketplace/add" { MarketplaceAdd => "marketplace/add" {
params: v2::MarketplaceAddParams, params: v2::MarketplaceAddParams,
serialization: global("config"), serialization: global("config"),
@@ -4357,6 +4357,22 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>, pub data: Vec<SkillsListEntry>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListParams {
/// When empty, defaults to the current session working directory.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwds: Vec<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListResponse {
pub data: Vec<HooksListEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
@@ -4560,6 +4576,40 @@ pub struct SkillsListEntry {
pub errors: Vec<SkillErrorInfo>, pub errors: Vec<SkillErrorInfo>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListEntry {
pub cwd: PathBuf,
pub hooks: Vec<HookMetadata>,
pub warnings: Vec<String>,
pub errors: Vec<HookErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookMetadata {
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub matcher: Option<String>,
pub command: Option<String>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source: HookSource,
pub plugin_id: Option<String>,
pub display_order: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookErrorInfo {
pub path: PathBuf,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
+1
View File
@@ -42,6 +42,7 @@ codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true } codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true } codex-features = { workspace = true }
codex-git-utils = { workspace = true } codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-otel = { workspace = true } codex-otel = { workspace = true }
codex-shell-command = { workspace = true } codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true } codex-utils-cli = { workspace = true }
+38
View File
@@ -196,6 +196,7 @@ Example with notification opt-out:
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `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. - `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`). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `hooks/list` — list discovered hooks for one or more `cwd` values.
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
@@ -1452,6 +1453,43 @@ To enable or disable a skill by name:
} }
``` ```
Use `hooks/list` to fetch the discovered hooks for one or more `cwds`.
```json
{
"method": "hooks/list",
"id": 28,
"params": {
"cwds": ["/Users/me/project"]
}
}
```
```json
{
"id": 28,
"result": {
"data": [{
"cwd": "/Users/me/project",
"hooks": [{
"eventName": "pre_tool_use",
"handlerType": "command",
"matcher": "Bash",
"command": "python3 /Users/me/hook.py",
"timeoutSec": 5,
"statusMessage": "running hook",
"sourcePath": "/Users/me/.codex/config.toml",
"source": "user",
"pluginId": null,
"displayOrder": 0
}],
"warnings": [],
"errors": []
}]
}
}
```
## Apps ## Apps
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config.
@@ -76,6 +76,9 @@ use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -233,6 +236,7 @@ use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings; use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError; use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode; use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key; use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig; use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread; use codex_core::CodexThread;
@@ -719,6 +723,23 @@ impl CodexMessageProcessor {
.await .await
} }
/// Resolve a caller-provided cwd into the absolute cwd and matching config layers
/// so list-style RPCs share the same per-cwd error handling.
async fn resolve_cwd_config(
&self,
cwd: &Path,
) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> {
let cwd_abs =
AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?;
let config_layer_stack = self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
.map_err(|err| err.to_string())?;
Ok((cwd_abs, config_layer_stack))
}
pub(crate) fn handle_config_mutation(&self) { pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches(); self.clear_plugin_related_caches();
} }
@@ -1086,6 +1107,10 @@ impl CodexMessageProcessor {
self.skills_list(to_connection_request_id(request_id), params) self.skills_list(to_connection_request_id(request_id), params)
.await; .await;
} }
ClientRequest::HooksList { request_id, params } => {
self.hooks_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::MarketplaceAdd { request_id, params } => { ClientRequest::MarketplaceAdd { request_id, params } => {
self.marketplace_add(to_connection_request_id(request_id), params) self.marketplace_add(to_connection_request_id(request_id), params)
.await; .await;
@@ -6248,43 +6273,24 @@ impl CodexMessageProcessor {
.map(|environment| environment.get_filesystem()); .map(|environment| environment.get_filesystem());
let mut data = Vec::new(); let mut data = Vec::new();
for cwd in cwds { for cwd in cwds {
let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await {
Ok(resolved) => resolved,
Err(message) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message,
}],
});
continue;
}
};
let extra_roots = extra_roots_by_cwd let extra_roots = extra_roots_by_cwd
.get(&cwd) .get(&cwd)
.map_or(&[][..], std::vec::Vec::as_slice); .map_or(&[][..], std::vec::Vec::as_slice);
let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) {
Ok(path) => path,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let config_layer_stack = match self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let effective_skill_roots = if workspace_codex_plugins_enabled { let effective_skill_roots = if workspace_codex_plugins_enabled {
plugins_manager plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &config) .effective_skill_roots_for_layer_stack(&config_layer_stack, &config)
@@ -6316,6 +6322,86 @@ impl CodexMessageProcessor {
} }
Ok(SkillsListResponse { data }) Ok(SkillsListResponse { data })
} }
async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) {
let result = self.hooks_list_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
/// Handle `hooks/list` by resolving hooks for each requested cwd.
async fn hooks_list_response(
&self,
params: HooksListParams,
) -> Result<HooksListResponse, JSONRPCErrorError> {
let HooksListParams { cwds } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let auth = self.auth_manager.auth().await;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
let config = match self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(cwd.clone()),
)
.await
{
Ok(config) => config,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: Vec::new(),
warnings: Vec::new(),
errors: vec![codex_app_server_protocol::HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&config,
/*plugin_hooks_feature_enabled*/ true,
)
.await
} else {
codex_core::plugins::PluginLoadOutcome::default()
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
..Default::default()
});
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: hooks_to_info(&hooks.hooks),
warnings: hooks.warnings,
errors: Vec::new(),
});
}
Ok(HooksListResponse { data })
}
async fn marketplace_remove( async fn marketplace_remove(
&self, &self,
request_id: ConnectionRequestId, request_id: ConnectionRequestId,
@@ -8623,6 +8709,24 @@ fn skills_to_info(
.collect() .collect()
} }
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
hooks
.iter()
.map(|hook| HookMetadata {
event_name: hook.event_name.into(),
handler_type: hook.handler_type.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
})
.collect()
}
fn plugin_skills_to_info( fn plugin_skills_to_info(
skills: &[codex_core::skills::SkillMetadata], skills: &[codex_core::skills::SkillMetadata],
disabled_skill_paths: &std::collections::HashSet<AbsolutePathBuf>, disabled_skill_paths: &std::collections::HashSet<AbsolutePathBuf>,
@@ -37,6 +37,7 @@ use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCError;
@@ -580,6 +581,15 @@ impl McpProcess {
self.send_request("skills/list", params).await self.send_request("skills/list", params).await
} }
/// Send a `hooks/list` JSON-RPC request.
pub async fn send_hooks_list_request(
&mut self,
params: HooksListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("hooks/list", params).await
}
/// Send a `marketplace/add` JSON-RPC request. /// Send a `marketplace/add` JSON-RPC request.
pub async fn send_marketplace_add_request( pub async fn send_marketplace_add_request(
&mut self, &mut self,
@@ -0,0 +1,286 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::JSONRPCResponse;
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(30);
fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> {
std::fs::write(
codex_home.join("config.toml"),
r#"[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/listed-hook.py"
timeout = 5
statusMessage = "running listed hook"
"#,
)?;
Ok(())
}
fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> {
let plugin_root = codex_home.join("plugins/cache/test/demo/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::create_dir_all(plugin_root.join("hooks"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"demo"}"#,
)?;
std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?;
std::fs::write(
codex_home.join("config.toml"),
r#"[features]
plugins = true
plugin_hooks = true
codex_hooks = true
[plugins."demo@test"]
enabled = true
"#,
)?;
Ok(())
}
#[tokio::test]
async fn hooks_list_shows_discovered_hook() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_user_hook_config(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![cwd.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
assert_eq!(
data,
vec![HooksListEntry {
cwd: cwd.path().to_path_buf(),
hooks: vec![HookMetadata {
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("python3 /tmp/listed-hook.py".to_string()),
timeout_sec: 5,
status_message: Some("running listed hook".to_string()),
source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home.path().join("config.toml")
)?,)?,
source: HookSource::User,
plugin_id: None,
display_order: 0,
}],
warnings: Vec::new(),
errors: Vec::new(),
}]
);
Ok(())
}
#[tokio::test]
async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_plugin_hook_config(
codex_home.path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo plugin hook",
"timeout": 7,
"statusMessage": "running plugin hook"
}
]
}
]
}
}"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![cwd.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
assert_eq!(
data,
vec![HooksListEntry {
cwd: cwd.path().to_path_buf(),
hooks: vec![HookMetadata {
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("echo plugin hook".to_string()),
timeout_sec: 7,
status_message: Some("running plugin hook".to_string()),
source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home
.path()
.join("plugins/cache/test/demo/local/hooks/hooks.json"),
)?,)?,
source: HookSource::Plugin,
plugin_id: Some("demo@test".to_string()),
display_order: 0,
}],
warnings: Vec::new(),
errors: Vec::new(),
}]
);
Ok(())
}
#[tokio::test]
async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_plugin_hook_config(codex_home.path(), "{ not-json")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![cwd.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].hooks, Vec::new());
assert_eq!(data[0].warnings.len(), 1);
assert!(
data[0].warnings[0].contains("failed to parse plugin hooks config"),
"unexpected warnings: {:?}",
data[0].warnings
);
Ok(())
}
#[tokio::test]
async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
codex_hooks = false
"#,
)?;
std::fs::create_dir_all(workspace.path().join(".git"))?;
std::fs::create_dir_all(workspace.path().join(".codex"))?;
std::fs::write(
workspace.path().join(".codex/config.toml"),
r#"[features]
codex_hooks = true
[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo project hook"
timeout = 5
"#,
)?;
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![
codex_home.path().to_path_buf(),
workspace.path().to_path_buf(),
],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
assert_eq!(
data,
vec![
HooksListEntry {
cwd: codex_home.path().to_path_buf(),
hooks: Vec::new(),
warnings: Vec::new(),
errors: Vec::new(),
},
HooksListEntry {
cwd: workspace.path().to_path_buf(),
hooks: vec![HookMetadata {
event_name: HookEventName::PreToolUse,
handler_type: HookHandlerType::Command,
matcher: Some("Bash".to_string()),
command: Some("echo project hook".to_string()),
timeout_sec: 5,
status_message: None,
source_path: AbsolutePathBuf::try_from(
workspace.path().join(".codex/config.toml"),
)?,
source: HookSource::Project,
plugin_id: None,
display_order: 0,
}],
warnings: Vec::new(),
errors: Vec::new(),
},
]
);
Ok(())
}
@@ -16,6 +16,7 @@ mod experimental_api;
mod experimental_feature_list; mod experimental_feature_list;
mod external_agent_config; mod external_agent_config;
mod fs; mod fs;
mod hooks_list;
mod initialize; mod initialize;
mod marketplace_add; mod marketplace_add;
mod marketplace_remove; mod marketplace_remove;
+13 -8
View File
@@ -112,6 +112,7 @@ pub async fn load_plugins_from_layer_stack(
extra_plugins: HashMap<String, PluginConfig>, extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore, store: &PluginStore,
restriction_product: Option<Product>, restriction_product: Option<Product>,
plugin_hooks_enabled: bool,
) -> PluginLoadOutcome<McpServerConfig> { ) -> PluginLoadOutcome<McpServerConfig> {
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
let mut configured_plugins = configured_plugins_from_stack(config_layer_stack); let mut configured_plugins = configured_plugins_from_stack(config_layer_stack);
@@ -128,6 +129,7 @@ pub async fn load_plugins_from_layer_stack(
store, store,
restriction_product, restriction_product,
&skill_config_rules, &skill_config_rules,
plugin_hooks_enabled,
) )
.await; .await;
for name in loaded_plugin.mcp_servers.keys() { for name in loaded_plugin.mcp_servers.keys() {
@@ -497,6 +499,7 @@ async fn load_plugin(
store: &PluginStore, store: &PluginStore,
restriction_product: Option<Product>, restriction_product: Option<Product>,
skill_config_rules: &SkillConfigRules, skill_config_rules: &SkillConfigRules,
plugin_hooks_enabled: bool,
) -> LoadedPlugin<McpServerConfig> { ) -> LoadedPlugin<McpServerConfig> {
let plugin_id = PluginId::parse(&config_name); let plugin_id = PluginId::parse(&config_name);
let active_plugin_root = plugin_id let active_plugin_root = plugin_id
@@ -593,14 +596,16 @@ async fn load_plugin(
} }
loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
let (hook_sources, hook_load_warnings) = load_plugin_hooks( if plugin_hooks_enabled {
&plugin_root, let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&loaded_plugin_id, &plugin_root,
&store.plugin_data_root(&loaded_plugin_id), &loaded_plugin_id,
manifest_paths, &store.plugin_data_root(&loaded_plugin_id),
); manifest_paths,
loaded_plugin.hook_sources = hook_sources; );
loaded_plugin.hook_load_warnings = hook_load_warnings; loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
}
loaded_plugin loaded_plugin
} }
+41 -9
View File
@@ -373,6 +373,7 @@ pub struct PluginsManager {
#[derive(Clone)] #[derive(Clone)]
struct CachedPluginLoadOutcome { struct CachedPluginLoadOutcome {
config_version: String, config_version: String,
plugin_hooks_enabled: bool,
outcome: PluginLoadOutcome, outcome: PluginLoadOutcome,
} }
@@ -443,9 +444,12 @@ impl PluginsManager {
return PluginLoadOutcome::default(); return PluginLoadOutcome::default();
} }
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let config_version = version_for_toml(&config.config_layer_stack.effective_config()); let config_version = version_for_toml(&config.config_layer_stack.effective_config());
if !force_reload
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) { && let Some(outcome) =
self.cached_enabled_outcome(&config_version, plugin_hooks_enabled)
{
return outcome; return outcome;
} }
@@ -454,6 +458,7 @@ impl PluginsManager {
self.remote_installed_plugin_configs(config), self.remote_installed_plugin_configs(config),
&self.store, &self.store,
self.restriction_product, self.restriction_product,
plugin_hooks_enabled,
) )
.await; .await;
log_plugin_load_errors(&outcome); log_plugin_load_errors(&outcome);
@@ -463,6 +468,7 @@ impl PluginsManager {
}; };
*cache = Some(CachedPluginLoadOutcome { *cache = Some(CachedPluginLoadOutcome {
config_version, config_version,
plugin_hooks_enabled,
outcome: outcome.clone(), outcome: outcome.clone(),
}); });
outcome outcome
@@ -485,35 +491,61 @@ impl PluginsManager {
*cached_enabled_outcome = None; *cached_enabled_outcome = None;
} }
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache. /// Load plugins for a config layer stack without touching the plugins cache.
pub async fn effective_skill_roots_for_layer_stack( pub async fn plugins_for_layer_stack(
&self, &self,
config_layer_stack: &ConfigLayerStack, config_layer_stack: &ConfigLayerStack,
config: &Config, config: &Config,
) -> Vec<AbsolutePathBuf> { plugin_hooks_feature_enabled: bool,
) -> PluginLoadOutcome {
if !config.features.enabled(Feature::Plugins) { if !config.features.enabled(Feature::Plugins) {
return Vec::new(); return PluginLoadOutcome::default();
} }
load_plugins_from_layer_stack( load_plugins_from_layer_stack(
config_layer_stack, config_layer_stack,
self.remote_installed_plugin_configs(config), self.remote_installed_plugin_configs(config),
&self.store, &self.store,
self.restriction_product, self.restriction_product,
plugin_hooks_feature_enabled,
)
.await
}
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
pub async fn effective_skill_roots_for_layer_stack(
&self,
config_layer_stack: &ConfigLayerStack,
config: &Config,
) -> Vec<AbsolutePathBuf> {
self.plugins_for_layer_stack(
config_layer_stack,
config,
config.features.enabled(Feature::PluginHooks),
) )
.await .await
.effective_skill_roots() .effective_skill_roots()
} }
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> { fn cached_enabled_outcome(
&self,
config_version: &str,
plugin_hooks_enabled: bool,
) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() { match self.cached_enabled_outcome.read() {
Ok(cache) => cache Ok(cache) => cache
.as_ref() .as_ref()
.filter(|cached| cached.config_version == config_version) .filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.map(|cached| cached.outcome.clone()), .map(|cached| cached.outcome.clone()),
Err(err) => err Err(err) => err
.into_inner() .into_inner()
.as_ref() .as_ref()
.filter(|cached| cached.config_version == config_version) .filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.map(|cached| cached.outcome.clone()), .map(|cached| cached.outcome.clone()),
} }
} }
@@ -97,6 +97,18 @@ fn run_git(repo: &Path, args: &[&str]) {
} }
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
plugin_config_toml_with_plugin_hooks(
enabled,
plugins_feature_enabled,
/*plugin_hooks_feature_enabled*/ false,
)
}
fn plugin_config_toml_with_plugin_hooks(
enabled: bool,
plugins_feature_enabled: bool,
plugin_hooks_feature_enabled: bool,
) -> String {
let mut root = toml::map::Map::new(); let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new(); let mut features = toml::map::Map::new();
@@ -104,6 +116,10 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
"plugins".to_string(), "plugins".to_string(),
Value::Boolean(plugins_feature_enabled), Value::Boolean(plugins_feature_enabled),
); );
features.insert(
"plugin_hooks".to_string(),
Value::Boolean(plugin_hooks_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features)); root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new(); let mut plugin = toml::map::Map::new();
@@ -1067,6 +1083,61 @@ async fn load_plugins_returns_empty_when_feature_disabled() {
assert_eq!(outcome, PluginLoadOutcome::default()); assert_eq!(outcome, PluginLoadOutcome::default());
} }
#[tokio::test]
async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"PreToolUse": [
{
"hooks": [{ "type": "command", "command": "echo plugin hook" }]
}
]
}
}"#,
);
let manager = PluginsManager::new(codex_home.path().to_path_buf());
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ false,
),
);
let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let without_plugin_hooks = manager
.plugins_for_config(&config_without_plugin_hooks)
.await;
assert!(
without_plugin_hooks
.effective_plugin_hook_sources()
.is_empty()
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ true,
),
);
let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await;
assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1);
}
#[tokio::test] #[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() { async fn load_plugins_rejects_invalid_plugin_keys() {
let codex_home = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap();
@@ -3435,6 +3506,7 @@ async fn load_plugins_ignores_project_config_files() {
std::collections::HashMap::new(), std::collections::HashMap::new(),
&PluginStore::new(codex_home.path().to_path_buf()), &PluginStore::new(codex_home.path().to_path_buf()),
Some(Product::Codex), Some(Product::Codex),
/*plugin_hooks_enabled*/ false,
) )
.await; .await;
+136 -140
View File
@@ -18,12 +18,15 @@ use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use super::ConfiguredHandler; use super::ConfiguredHandler;
use super::HookListEntry;
use crate::events::common::matcher_pattern_for_event; use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern; use crate::events::common::validate_matcher_pattern;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookSource;
pub(crate) struct DiscoveryResult { pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>, pub handlers: Vec<ConfiguredHandler>,
pub hook_entries: Vec<HookListEntry>,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -33,6 +36,7 @@ struct HookHandlerSource<'a> {
is_managed: bool, is_managed: bool,
source: HookSource, source: HookSource,
env: HashMap<String, String>, env: HashMap<String, String>,
plugin_id: Option<String>,
} }
pub(crate) fn discover_handlers( pub(crate) fn discover_handlers(
@@ -40,93 +44,77 @@ pub(crate) fn discover_handlers(
plugin_hook_sources: Vec<PluginHookSource>, plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>, plugin_hook_load_warnings: Vec<String>,
) -> DiscoveryResult { ) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
let mut handlers = Vec::new();
let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64;
append_plugin_hook_sources(
&mut handlers,
&mut warnings,
&mut display_order,
plugin_hook_sources,
);
return DiscoveryResult { handlers, warnings };
};
let mut handlers = Vec::new(); let mut handlers = Vec::new();
let mut hook_entries = Vec::new();
let mut warnings = plugin_hook_load_warnings; let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64; let mut display_order = 0_i64;
append_managed_requirement_handlers( if let Some(config_layer_stack) = config_layer_stack {
&mut handlers, append_managed_requirement_handlers(
&mut warnings, &mut handlers,
&mut display_order, &mut hook_entries,
config_layer_stack, &mut warnings,
); &mut display_order,
config_layer_stack,
);
for layer in config_layer_stack.get_layers( for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst, ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false, /*include_disabled*/ false,
) { ) {
let hook_source = hook_source_for_config_layer_source(&layer.name); let hook_source = hook_source_for_config_layer_source(&layer.name);
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
(&json_hooks, &toml_hooks) (&json_hooks, &toml_hooks)
&& !json_events.is_empty() && !json_events.is_empty()
&& !toml_events.is_empty() && !toml_events.is_empty()
{ {
warnings.push(format!( warnings.push(format!(
"loading hooks from both {} and {}; prefer a single representation for this layer", "loading hooks from both {} and {}; prefer a single representation for this layer",
json_source_path.display(), json_source_path.display(),
toml_source_path.display() toml_source_path.display()
)); ));
} }
if let Some((source_path, hook_events)) = json_hooks { for (source_path, hook_events) in [json_hooks, toml_hooks].into_iter().flatten() {
append_hook_events( append_hook_events(
&mut handlers, &mut handlers,
&mut warnings, &mut hook_entries,
&mut display_order, &mut warnings,
HookHandlerSource { &mut display_order,
path: &source_path, HookHandlerSource {
is_managed: false, path: &source_path,
source: hook_source, is_managed: false,
env: HashMap::new(), source: hook_source,
}, env: HashMap::new(),
hook_events, plugin_id: None,
); },
} hook_events,
);
if let Some((source_path, hook_events)) = toml_hooks { }
append_hook_events(
&mut handlers,
&mut warnings,
&mut display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: hook_source,
env: HashMap::new(),
},
hook_events,
);
} }
} }
append_plugin_hook_sources( append_plugin_hook_sources(
&mut handlers, &mut handlers,
&mut hook_entries,
&mut warnings, &mut warnings,
&mut display_order, &mut display_order,
plugin_hook_sources, plugin_hook_sources,
); );
DiscoveryResult { handlers, warnings } DiscoveryResult {
handlers,
hook_entries,
warnings,
}
} }
fn append_managed_requirement_handlers( fn append_managed_requirement_handlers(
handlers: &mut Vec<ConfiguredHandler>, handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
warnings: &mut Vec<String>, warnings: &mut Vec<String>,
display_order: &mut i64, display_order: &mut i64,
config_layer_stack: &ConfigLayerStack, config_layer_stack: &ConfigLayerStack,
@@ -141,6 +129,7 @@ fn append_managed_requirement_handlers(
}; };
append_hook_events( append_hook_events(
handlers, handlers,
hook_entries,
warnings, warnings,
display_order, display_order,
HookHandlerSource { HookHandlerSource {
@@ -148,6 +137,7 @@ fn append_managed_requirement_handlers(
is_managed: true, is_managed: true,
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
env: HashMap::new(), env: HashMap::new(),
plugin_id: None,
}, },
managed_hooks.get().hooks.clone(), managed_hooks.get().hooks.clone(),
); );
@@ -155,6 +145,7 @@ fn append_managed_requirement_handlers(
fn append_plugin_hook_sources( fn append_plugin_hook_sources(
handlers: &mut Vec<ConfiguredHandler>, handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
warnings: &mut Vec<String>, warnings: &mut Vec<String>,
display_order: &mut i64, display_order: &mut i64,
plugin_hook_sources: Vec<PluginHookSource>, plugin_hook_sources: Vec<PluginHookSource>,
@@ -163,6 +154,7 @@ fn append_plugin_hook_sources(
for source in plugin_hook_sources { for source in plugin_hook_sources {
let PluginHookSource { let PluginHookSource {
plugin_root, plugin_root,
plugin_id,
plugin_data_root, plugin_data_root,
source_path, source_path,
hooks, hooks,
@@ -177,8 +169,10 @@ fn append_plugin_hook_sources(
env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone()); env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone());
// For OOTB compat with existing plugins that use this env var. // For OOTB compat with existing plugins that use this env var.
env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value); env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value);
let plugin_id = plugin_id.as_key();
append_hook_events( append_hook_events(
handlers, handlers,
hook_entries,
warnings, warnings,
display_order, display_order,
HookHandlerSource { HookHandlerSource {
@@ -186,6 +180,7 @@ fn append_plugin_hook_sources(
is_managed: false, is_managed: false,
source: HookSource::Plugin, source: HookSource::Plugin,
env, env,
plugin_id: Some(plugin_id),
}, },
hooks, hooks,
); );
@@ -330,6 +325,7 @@ fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
fn append_hook_events( fn append_hook_events(
handlers: &mut Vec<ConfiguredHandler>, handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
warnings: &mut Vec<String>, warnings: &mut Vec<String>,
display_order: &mut i64, display_order: &mut i64,
source: HookHandlerSource<'_>, source: HookHandlerSource<'_>,
@@ -338,6 +334,7 @@ fn append_hook_events(
for (event_name, groups) in hook_events.into_matcher_groups() { for (event_name, groups) in hook_events.into_matcher_groups() {
append_matcher_groups( append_matcher_groups(
handlers, handlers,
hook_entries,
warnings, warnings,
display_order, display_order,
source.clone(), source.clone(),
@@ -349,6 +346,7 @@ fn append_hook_events(
fn append_matcher_groups( fn append_matcher_groups(
handlers: &mut Vec<ConfiguredHandler>, handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
warnings: &mut Vec<String>, warnings: &mut Vec<String>,
display_order: &mut i64, display_order: &mut i64,
source: HookHandlerSource<'_>, source: HookHandlerSource<'_>,
@@ -356,85 +354,78 @@ fn append_matcher_groups(
groups: Vec<MatcherGroup>, groups: Vec<MatcherGroup>,
) { ) {
for group in groups { for group in groups {
append_group_handlers( let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
handlers, if let Some(matcher) = matcher
warnings, && let Err(err) = validate_matcher_pattern(matcher)
display_order, {
source.clone(), warnings.push(format!(
event_name, "invalid matcher {matcher:?} in {}: {err}",
matcher_pattern_for_event(event_name, group.matcher.as_deref()), source.path.display()
group.hooks, ));
); continue;
} }
}
fn append_group_handlers( for handler in group.hooks {
handlers: &mut Vec<ConfiguredHandler>, match handler {
warnings: &mut Vec<String>, HookHandlerConfig::Command {
display_order: &mut i64,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source.path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source.path.display()
));
continue;
}
let command = source.env.iter().fold(command, |command, (key, value)| {
command.replace(&format!("${{{key}}}"), value)
});
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
matcher: matcher.map(ToOwned::to_owned),
command, command,
timeout_sec, timeout_sec,
r#async,
status_message, status_message,
source_path: source.path.clone(), } => {
source: source.source, if r#async {
display_order: *display_order, warnings.push(format!(
env: source.env.clone(), "skipping async hook in {}: async hooks are not supported yet",
}); source.path.display()
*display_order += 1; ));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source.path.display()
));
continue;
}
let command = source.env.iter().fold(command, |command, (key, value)| {
command.replace(&format!("${{{key}}}"), value)
});
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
hook_entries.push(HookListEntry {
event_name,
handler_type: HookHandlerType::Command,
matcher: matcher.map(ToOwned::to_owned),
command: Some(command.clone()),
timeout_sec,
status_message: status_message.clone(),
source_path: source.path.clone(),
source: source.source,
plugin_id: source.plugin_id.clone(),
display_order: *display_order,
});
handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source.path.clone(),
source: source.source,
display_order: *display_order,
env: source.env.clone(),
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source.path.display()
)),
} }
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source.path.display()
)),
} }
} }
} }
@@ -498,6 +489,7 @@ mod tests {
is_managed: false, is_managed: false,
source: hook_source(), source: hook_source(),
env: std::collections::HashMap::new(), env: std::collections::HashMap::new(),
plugin_id: None,
} }
} }
@@ -522,6 +514,7 @@ mod tests {
append_matcher_groups( append_matcher_groups(
&mut handlers, &mut handlers,
&mut Vec::new(),
&mut warnings, &mut warnings,
&mut display_order, &mut display_order,
hook_handler_source(&source_path), hook_handler_source(&source_path),
@@ -556,6 +549,7 @@ mod tests {
append_matcher_groups( append_matcher_groups(
&mut handlers, &mut handlers,
&mut Vec::new(),
&mut warnings, &mut warnings,
&mut display_order, &mut display_order,
hook_handler_source(&source_path), hook_handler_source(&source_path),
@@ -590,6 +584,7 @@ mod tests {
append_matcher_groups( append_matcher_groups(
&mut handlers, &mut handlers,
&mut Vec::new(),
&mut warnings, &mut warnings,
&mut display_order, &mut display_order,
hook_handler_source(&source_path), hook_handler_source(&source_path),
@@ -611,6 +606,7 @@ mod tests {
append_matcher_groups( append_matcher_groups(
&mut handlers, &mut handlers,
&mut Vec::new(),
&mut warnings, &mut warnings,
&mut display_order, &mut display_order,
hook_handler_source(&source_path), hook_handler_source(&source_path),
+16
View File
@@ -8,6 +8,8 @@ use std::collections::HashMap;
use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource; use codex_plugin::PluginHookSource;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookHandlerType;
use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
@@ -67,6 +69,20 @@ impl ConfiguredHandler {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookListEntry {
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub matcher: Option<String>,
pub command: Option<String>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source: HookSource,
pub plugin_id: Option<String>,
pub display_order: i64,
}
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct ClaudeHooksEngine { pub(crate) struct ClaudeHooksEngine {
handlers: Vec<ConfiguredHandler>, handlers: Vec<ConfiguredHandler>,
+3 -1
View File
@@ -5,6 +5,7 @@ mod registry;
mod schema; mod schema;
mod types; mod types;
pub use engine::HookListEntry;
/// Hook event names as they appear in hooks JSON and config files. /// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 6] = [ pub const HOOK_EVENT_NAMES: [&str; 6] = [
"PreToolUse", "PreToolUse",
@@ -26,7 +27,6 @@ pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 4] = [
"PostToolUse", "PostToolUse",
"SessionStart", "SessionStart",
]; ];
pub use events::permission_request::PermissionRequestDecision; pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome; pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest; pub use events::permission_request::PermissionRequestRequest;
@@ -43,9 +43,11 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome;
pub use events::user_prompt_submit::UserPromptSubmitRequest; pub use events::user_prompt_submit::UserPromptSubmitRequest;
pub use legacy_notify::legacy_notify_json; pub use legacy_notify::legacy_notify_json;
pub use legacy_notify::notify_hook; pub use legacy_notify::notify_hook;
pub use registry::HookListOutcome;
pub use registry::Hooks; pub use registry::Hooks;
pub use registry::HooksConfig; pub use registry::HooksConfig;
pub use registry::command_from_argv; pub use registry::command_from_argv;
pub use registry::list_hooks;
pub use schema::write_schema_fixtures; pub use schema::write_schema_fixtures;
pub use types::Hook; pub use types::Hook;
pub use types::HookEvent; pub use types::HookEvent;
+23
View File
@@ -4,6 +4,7 @@ use tokio::process::Command;
use crate::engine::ClaudeHooksEngine; use crate::engine::ClaudeHooksEngine;
use crate::engine::CommandShell; use crate::engine::CommandShell;
use crate::engine::HookListEntry;
use crate::events::permission_request::PermissionRequestOutcome; use crate::events::permission_request::PermissionRequestOutcome;
use crate::events::permission_request::PermissionRequestRequest; use crate::events::permission_request::PermissionRequestRequest;
use crate::events::post_tool_use::PostToolUseOutcome; use crate::events::post_tool_use::PostToolUseOutcome;
@@ -32,6 +33,12 @@ pub struct HooksConfig {
pub shell_args: Vec<String>, pub shell_args: Vec<String>,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookListOutcome {
pub hooks: Vec<HookListEntry>,
pub warnings: Vec<String>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct Hooks { pub struct Hooks {
after_agent: Vec<Hook>, after_agent: Vec<Hook>,
@@ -173,6 +180,22 @@ impl Hooks {
} }
} }
pub fn list_hooks(config: HooksConfig) -> HookListOutcome {
if !config.feature_enabled {
return HookListOutcome::default();
}
let discovered = crate::engine::discovery::discover_handlers(
config.config_layer_stack.as_ref(),
config.plugin_hook_sources,
config.plugin_hook_load_warnings,
);
HookListOutcome {
hooks: discovered.hook_entries,
warnings: discovered.warnings,
}
}
pub fn command_from_argv(argv: &[String]) -> Option<Command> { pub fn command_from_argv(argv: &[String]) -> Option<Command> {
let (program, args) = argv.split_first()?; let (program, args) = argv.split_first()?;
if program.is_empty() { if program.is_empty() {