mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Show plugin hooks in plugin details (#21447)
Supersedes the abandoned #19859, rebuilt on latest `main`. # Why PR #19705 adds discovery for hooks bundled with plugins, but `/plugins` still only shows skills, apps, and MCP servers. This follow-up makes bundled hooks visible in the same plugin detail view so users can inspect the full plugin surface in one place. We also need `PluginHookSummary` to populate Plugin Hooks in the app; `hooks/list` is not enough there because plugin detail needs to show hooks for disabled plugins too. # What - extend `plugin/read` with `PluginHookSummary` entries for bundled hooks - summarize plugin hooks while loading plugin details - render a `Hooks` row in the `/plugins` detail popup <img width="3456" height="848" alt="CleanShot 2026-04-27 at 11 45 34@2x" src="https://github.com/user-attachments/assets/fe3a38d6-a260-4351-8513-fb04c93d725b" />
This commit is contained in:
committed by
GitHub
Unverified
parent
898f5bfeaa
commit
40e282849c
Generated
+1
@@ -2569,6 +2569,7 @@ dependencies = [
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-otel",
|
||||
|
||||
+22
@@ -12144,6 +12144,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginHookSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12175,6 +12181,7 @@
|
||||
},
|
||||
"required": [
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
"mcpServers",
|
||||
"skills",
|
||||
@@ -12182,6 +12189,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginHookSummary": {
|
||||
"properties": {
|
||||
"eventName": {
|
||||
"$ref": "#/definitions/v2/HookEventName"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"eventName",
|
||||
"key"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
+22
@@ -8755,6 +8755,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginHookSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8786,6 +8792,7 @@
|
||||
},
|
||||
"required": [
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
"mcpServers",
|
||||
"skills",
|
||||
@@ -8793,6 +8800,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginHookSummary": {
|
||||
"properties": {
|
||||
"eventName": {
|
||||
"$ref": "#/definitions/HookEventName"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"eventName",
|
||||
"key"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"preCompact",
|
||||
"postCompact",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
"stop"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginAuthPolicy": {
|
||||
"enum": [
|
||||
"ON_INSTALL",
|
||||
@@ -75,6 +88,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginHookSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -106,6 +125,7 @@
|
||||
},
|
||||
"required": [
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
"mcpServers",
|
||||
"skills",
|
||||
@@ -113,6 +133,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginHookSummary": {
|
||||
"properties": {
|
||||
"eventName": {
|
||||
"$ref": "#/definitions/HookEventName"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"eventName",
|
||||
"key"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstallPolicy": {
|
||||
"enum": [
|
||||
"NOT_AVAILABLE",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 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 { AppSummary } from "./AppSummary";
|
||||
import type { PluginHookSummary } from "./PluginHookSummary";
|
||||
import type { PluginSummary } from "./PluginSummary";
|
||||
import type { SkillSummary } from "./SkillSummary";
|
||||
|
||||
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
|
||||
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, mcpServers: 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 { HookEventName } from "./HookEventName";
|
||||
|
||||
export type PluginHookSummary = { key: string, eventName: HookEventName, };
|
||||
@@ -274,6 +274,7 @@ export type { PlanDeltaNotification } from "./PlanDeltaNotification";
|
||||
export type { PluginAuthPolicy } from "./PluginAuthPolicy";
|
||||
export type { PluginAvailability } from "./PluginAvailability";
|
||||
export type { PluginDetail } from "./PluginDetail";
|
||||
export type { PluginHookSummary } from "./PluginHookSummary";
|
||||
export type { PluginInstallParams } from "./PluginInstallParams";
|
||||
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
|
||||
export type { PluginInstallResponse } from "./PluginInstallResponse";
|
||||
|
||||
@@ -552,10 +552,19 @@ pub struct PluginDetail {
|
||||
pub summary: PluginSummary,
|
||||
pub description: Option<String>,
|
||||
pub skills: Vec<SkillSummary>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppSummary>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginHookSummary {
|
||||
pub key: String,
|
||||
pub event_name: HookEventName,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -208,7 +208,7 @@ Example with notification opt-out:
|
||||
- `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.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
|
||||
@@ -615,6 +615,15 @@ impl PluginRequestProcessor {
|
||||
&visible_skills,
|
||||
&outcome.plugin.disabled_skill_paths,
|
||||
),
|
||||
hooks: outcome
|
||||
.plugin
|
||||
.hooks
|
||||
.into_iter()
|
||||
.map(|hook| codex_app_server_protocol::PluginHookSummary {
|
||||
key: hook.key,
|
||||
event_name: hook.event_name.into(),
|
||||
})
|
||||
.collect(),
|
||||
apps: app_summaries,
|
||||
mcp_servers: outcome.plugin.mcp_server_names,
|
||||
}
|
||||
@@ -1490,6 +1499,7 @@ fn remote_plugin_detail_to_info(
|
||||
enabled: skill.enabled,
|
||||
})
|
||||
.collect(),
|
||||
hooks: Vec::new(),
|
||||
apps,
|
||||
mcp_servers: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
@@ -778,6 +779,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::create_dir_all(plugin_root.join("hooks"))?;
|
||||
std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?;
|
||||
std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?;
|
||||
std::fs::write(
|
||||
@@ -881,12 +883,44 @@ description: Visible only for ChatGPT
|
||||
"command": "demo-server"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join("hooks/hooks.json"),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo startup"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo first"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo second"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "demo-plugin:thread-summarizer"
|
||||
@@ -894,6 +928,9 @@ enabled = false
|
||||
|
||||
[plugins."demo-plugin@codex-curated"]
|
||||
enabled = true
|
||||
|
||||
[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?;
|
||||
@@ -980,6 +1017,23 @@ enabled = true
|
||||
"Summarize email threads"
|
||||
);
|
||||
assert!(!response.plugin.skills[0].enabled);
|
||||
assert_eq!(
|
||||
response.plugin.hooks,
|
||||
vec![
|
||||
codex_app_server_protocol::PluginHookSummary {
|
||||
key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
codex_app_server_protocol::PluginHookSummary {
|
||||
key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
codex_app_server_protocol::PluginHookSummary {
|
||||
key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(),
|
||||
event_name: HookEventName::SessionStart,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(response.plugin.apps.len(), 1);
|
||||
assert_eq!(response.plugin.apps[0].id, "gmail");
|
||||
assert_eq!(response.plugin.apps[0].name, "gmail");
|
||||
|
||||
@@ -20,6 +20,7 @@ codex-config = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_mcp_servers;
|
||||
use crate::loader::load_plugin_skills;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
@@ -54,6 +55,7 @@ use codex_config::set_user_plugin_enabled;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_hooks::plugin_hook_declarations;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::AppConnectorId;
|
||||
@@ -61,6 +63,7 @@ use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
@@ -228,11 +231,18 @@ pub struct PluginDetail {
|
||||
pub enabled: bool,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginHookSummary {
|
||||
pub key: String,
|
||||
pub event_name: HookEventName,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginDetailsUnavailableReason {
|
||||
InstallRequiredForRemoteSource,
|
||||
@@ -1300,6 +1310,7 @@ impl PluginsManager {
|
||||
enabled: plugin.enabled,
|
||||
skills: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
mcp_server_names: Vec::new(),
|
||||
details_unavailable_reason: Some(
|
||||
@@ -1357,6 +1368,20 @@ impl PluginsManager {
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let hooks = if config.plugin_hooks_enabled {
|
||||
let plugin_data_root = self.store.plugin_data_root(&plugin_id);
|
||||
let (hook_sources, _hook_load_warnings) =
|
||||
load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths);
|
||||
plugin_hook_declarations(&hook_sources)
|
||||
.into_iter()
|
||||
.map(|hook| PluginHookSummary {
|
||||
key: hook.key,
|
||||
event_name: hook.event_name,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let apps = load_plugin_apps(source_path.as_path()).await;
|
||||
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path())
|
||||
.await
|
||||
@@ -1377,6 +1402,7 @@ impl PluginsManager {
|
||||
enabled: plugin.enabled,
|
||||
skills: resolved_skills.skills,
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
hooks,
|
||||
apps,
|
||||
mcp_server_names,
|
||||
details_unavailable_reason: None,
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -1933,13 +1934,48 @@ async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cl
|
||||
&cached_plugin_root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&cached_plugin_root.join("hooks/hooks.json"),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo startup"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo first"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo second"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
|
||||
[plugins."toolkit@debug"]
|
||||
enabled = true
|
||||
|
||||
[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -1978,6 +2014,23 @@ enabled = true
|
||||
outcome.plugin.apps,
|
||||
vec![AppConnectorId("connector_calendar".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugin.hooks,
|
||||
vec![
|
||||
PluginHookSummary {
|
||||
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
PluginHookSummary {
|
||||
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
PluginHookSummary {
|
||||
key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(),
|
||||
event_name: HookEventName::SessionStart,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
|
||||
assert!(
|
||||
!tmp.path()
|
||||
|
||||
@@ -13,7 +13,7 @@ use codex_config::TomlValue;
|
||||
/// disabled layers, to match the skills config behavior. Project, managed, and
|
||||
/// plugin layers can discover hooks, but they do not get to write user hook
|
||||
/// state.
|
||||
pub(crate) fn hook_states_from_stack(
|
||||
pub fn hook_states_from_stack(
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
) -> HashMap<String, HookStateToml> {
|
||||
let Some(config_layer_stack) = config_layer_stack else {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
|
||||
/// Minimal declaration metadata for one bundled plugin hook handler.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginHookDeclaration {
|
||||
pub key: String,
|
||||
pub event_name: HookEventName,
|
||||
}
|
||||
|
||||
/// Return the hook handlers declared by plugin bundles without projecting live runtime state.
|
||||
pub fn plugin_hook_declarations(hook_sources: &[PluginHookSource]) -> Vec<PluginHookDeclaration> {
|
||||
let mut declarations = Vec::new();
|
||||
|
||||
for source in hook_sources {
|
||||
let key_source = plugin_hook_key_source(
|
||||
source.plugin_id.as_key().as_str(),
|
||||
source.source_relative_path.as_str(),
|
||||
);
|
||||
for (event_name, groups) in source.hooks.clone().into_matcher_groups() {
|
||||
for (group_index, group) in groups.iter().enumerate() {
|
||||
for (handler_index, _) in group.hooks.iter().enumerate() {
|
||||
declarations.push(PluginHookDeclaration {
|
||||
key: crate::hook_key(&key_source, event_name, group_index, handler_index),
|
||||
event_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declarations
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_hook_key_source(plugin_id: &str, source_relative_path: &str) -> String {
|
||||
format!("{plugin_id}:{source_relative_path}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_declared_plugin_handlers_with_persisted_hook_keys() {
|
||||
let plugin_root = test_path_buf("/tmp/plugin").abs();
|
||||
let source_path = plugin_root.join("hooks/hooks.json");
|
||||
let declarations = plugin_hook_declarations(&[PluginHookSource {
|
||||
plugin_id: PluginId::parse("demo@test").expect("plugin id"),
|
||||
plugin_root: plugin_root.clone(),
|
||||
plugin_data_root: plugin_root.join("data"),
|
||||
source_path,
|
||||
source_relative_path: "hooks/hooks.json".to_string(),
|
||||
hooks: HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: None,
|
||||
hooks: vec![
|
||||
HookHandlerConfig::Prompt {},
|
||||
HookHandlerConfig::Command {
|
||||
command: "echo hi".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
status_message: None,
|
||||
},
|
||||
],
|
||||
}],
|
||||
session_start: vec![MatcherGroup {
|
||||
matcher: None,
|
||||
hooks: vec![HookHandlerConfig::Agent {}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
declarations,
|
||||
vec![
|
||||
PluginHookDeclaration {
|
||||
key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
PluginHookDeclaration {
|
||||
key: "demo@test:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
},
|
||||
PluginHookDeclaration {
|
||||
key: "demo@test:hooks/hooks.json:session_start:0:0".to_string(),
|
||||
event_name: HookEventName::SessionStart,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,10 @@ fn append_plugin_hook_sources(
|
||||
display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
key_source: format!("{plugin_id}:{source_relative_path}"),
|
||||
key_source: crate::declarations::plugin_hook_key_source(
|
||||
plugin_id.as_str(),
|
||||
source_relative_path.as_str(),
|
||||
),
|
||||
source: HookSource::Plugin,
|
||||
is_managed: false,
|
||||
hook_states,
|
||||
@@ -416,13 +419,8 @@ fn append_matcher_groups(
|
||||
command.replace(&format!("${{{key}}}"), value)
|
||||
});
|
||||
// TODO(abhinav): replace this positional suffix with a durable hook id.
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
source.key_source,
|
||||
hook_event_key_label(event_name),
|
||||
group_index,
|
||||
handler_index
|
||||
);
|
||||
let key =
|
||||
crate::hook_key(&source.key_source, event_name, group_index, handler_index);
|
||||
let state = source.hook_states.get(&key);
|
||||
let enabled = hook_enabled(source.is_managed, state);
|
||||
let trusted_hash = hook_trusted_hash(source.is_managed, state);
|
||||
@@ -497,7 +495,7 @@ fn command_hook_hash(
|
||||
group.matcher = matcher.map(ToOwned::to_owned);
|
||||
group.hooks = vec![normalized_handler];
|
||||
let identity = NormalizedHookIdentity {
|
||||
event_name: hook_event_key_label(event_name),
|
||||
event_name: crate::hook_event_key_label(event_name),
|
||||
group,
|
||||
};
|
||||
let Ok(value) = TomlValue::try_from(identity) else {
|
||||
@@ -506,19 +504,6 @@ fn command_hook_hash(
|
||||
version_for_toml(&value)
|
||||
}
|
||||
|
||||
fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use",
|
||||
codex_protocol::protocol::HookEventName::PreCompact => "pre_compact",
|
||||
codex_protocol::protocol::HookEventName::PostCompact => "post_compact",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session_start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_trust_status(
|
||||
is_managed: bool,
|
||||
current_hash: &str,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod config_rules;
|
||||
mod declarations;
|
||||
mod engine;
|
||||
pub(crate) mod events;
|
||||
mod legacy_notify;
|
||||
@@ -7,6 +8,11 @@ mod registry;
|
||||
mod schema;
|
||||
mod types;
|
||||
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
|
||||
pub use config_rules::hook_states_from_stack;
|
||||
pub use declarations::PluginHookDeclaration;
|
||||
pub use declarations::plugin_hook_declarations;
|
||||
pub use engine::HookListEntry;
|
||||
/// Hook event names as they appear in hooks JSON and config files.
|
||||
pub const HOOK_EVENT_NAMES: [&str; 8] = [
|
||||
@@ -70,3 +76,30 @@ pub use types::HookResult;
|
||||
pub use types::HookToolInput;
|
||||
pub use types::HookToolInputLocalShell;
|
||||
pub use types::HookToolKind;
|
||||
|
||||
/// Returns the hook event label used in persisted hook-state keys.
|
||||
pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse => "pre_tool_use",
|
||||
HookEventName::PermissionRequest => "permission_request",
|
||||
HookEventName::PostToolUse => "post_tool_use",
|
||||
HookEventName::PreCompact => "pre_compact",
|
||||
HookEventName::PostCompact => "post_compact",
|
||||
HookEventName::SessionStart => "session_start",
|
||||
HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
HookEventName::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the persisted config-state key for one discovered hook handler.
|
||||
pub fn hook_key(
|
||||
key_source: &str,
|
||||
event_name: HookEventName,
|
||||
group_index: usize,
|
||||
handler_index: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"{key_source}:{}:{group_index}:{handler_index}",
|
||||
hook_event_key_label(event_name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1728,6 +1728,12 @@ impl ChatWidget {
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
items.push(SelectionItem {
|
||||
name: "Hooks".to_string(),
|
||||
description: Some(plugin_hook_summary(plugin)),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
items.push(SelectionItem {
|
||||
name: "Apps".to_string(),
|
||||
description: Some(plugin_app_summary(plugin)),
|
||||
@@ -2142,6 +2148,29 @@ fn plugin_app_summary(plugin: &PluginDetail) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_hook_summary(plugin: &PluginDetail) -> String {
|
||||
if plugin.hooks.is_empty() {
|
||||
"No plugin hooks.".to_string()
|
||||
} else {
|
||||
let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new();
|
||||
for hook in &plugin.hooks {
|
||||
if let Some((_, handler_count)) = event_counts
|
||||
.iter_mut()
|
||||
.find(|(event_name, _)| *event_name == hook.event_name)
|
||||
{
|
||||
*handler_count += 1;
|
||||
} else {
|
||||
event_counts.push((hook.event_name, 1));
|
||||
}
|
||||
}
|
||||
event_counts
|
||||
.into_iter()
|
||||
.map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
|
||||
if plugin.mcp_servers.is_empty() {
|
||||
"No plugin MCP servers.".to_string()
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ expression: popup
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
Skills design-review, extract-copy
|
||||
Hooks PreToolUse (1), Stop (2)
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ expression: popup
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Uninstall plugin Remove this plugin now.
|
||||
Skills design-review, extract-copy
|
||||
Hooks PreToolUse (1), Stop (2)
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
|
||||
@@ -1505,6 +1505,7 @@ pub(super) fn plugins_test_detail(
|
||||
summary: PluginSummary,
|
||||
description: Option<&str>,
|
||||
skills: &[&str],
|
||||
hooks: &[(codex_app_server_protocol::HookEventName, usize)],
|
||||
apps: &[(&str, bool)],
|
||||
mcp_servers: &[&str],
|
||||
) -> PluginDetail {
|
||||
@@ -1526,6 +1527,18 @@ pub(super) fn plugins_test_detail(
|
||||
enabled: true,
|
||||
})
|
||||
.collect(),
|
||||
hooks: hooks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(event_index, (event_name, handler_count))| {
|
||||
(0..*handler_count).map(move |handler_index| {
|
||||
codex_app_server_protocol::PluginHookSummary {
|
||||
key: format!("plugin:{event_index}:{handler_index}"),
|
||||
event_name: *event_name,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
apps: apps
|
||||
.iter()
|
||||
.map(|(name, needs_auth)| AppSummary {
|
||||
|
||||
@@ -656,6 +656,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[
|
||||
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
|
||||
(codex_app_server_protocol::HookEventName::Stop, 2),
|
||||
],
|
||||
&[("Figma", true), ("Slack", false)],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
),
|
||||
@@ -696,6 +700,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[
|
||||
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
|
||||
(codex_app_server_protocol::HookEventName::Stop, 2),
|
||||
],
|
||||
&[("Figma", true), ("Slack", false)],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user