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:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user