Files
codex/codex-rs/hooks/src/declarations.rs
Abhinav 40e282849c 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"
/>
2026-05-07 00:21:14 -07:00

101 lines
3.7 KiB
Rust

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,
},
]
);
}
}