diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 595da002a9..ee321f59af 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -163,8 +163,11 @@ GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host ### 3.4 静态 Hook 目录 Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 展示,而不进入可执行来源管理模块。Desktop 设置页的 -“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks` 消费同一份 Rust 快照;`/help hooks`、`/hooks -h` 与 `/hooks --help` 提供说明,不增加快捷键、 -命名空间变体或生态专用命令。`/hooks` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生 +“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks_external`(别名 `/hooks-external`)消费同一份 Rust 快照; +`/help hooks_external`、`/hooks_external -h` 与 `/hooks_external --help` 提供说明,不增加快捷键、 +命名空间变体或生态专用命令。TUI 的 `/hooks` 属于 BitFun 自身会执行的 Hooks(见 +[agent-hooks](../../features/agent-hooks.zh-CN.md)),与本节的静态目录是两个视图,彼此在帮助文本中互相指向。 +`/hooks_external` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生 同名冲突时由现有命令菜单展示来源限定项,静态 Hook 目录不增加另一套保留字或路由规则。 当前目录的来源与降级边界如下: diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 5be8294062..efb9fbb7a3 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -398,7 +398,7 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| | Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | +| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks_external`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp` 和 Runtime Parts | load 成功后才发布活动状态;close 排空后再卸载;完整历史和配置仍由 Core/ACP 管理 | | Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作 | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | diff --git a/docs/features/agent-hooks.md b/docs/features/agent-hooks.md index fc7b17cb22..75dc0b192a 100644 --- a/docs/features/agent-hooks.md +++ b/docs/features/agent-hooks.md @@ -210,6 +210,9 @@ log level. ## Related -- CLI `/hooks` inspects hooks configured for *other* AI applications (Claude - Code, Codex, OpenCode). That view is read-only and never executes anything; - the hooks described here are BitFun's own and do execute. +- CLI `/hooks` shows the hooks described here — which files they came from, + which layers are active, and what each matcher group would run. It reports + the configuration only; edit `hooks.json` to change it. +- CLI `/hooks_external` inspects hooks configured for *other* AI applications + (Claude Code, Codex, OpenCode). That view is read-only and never executes + anything; the hooks described here are BitFun's own and do execute. diff --git a/docs/features/agent-hooks.zh-CN.md b/docs/features/agent-hooks.zh-CN.md index 033551aad9..3fd909c0b8 100644 --- a/docs/features/agent-hooks.zh-CN.md +++ b/docs/features/agent-hooks.zh-CN.md @@ -194,6 +194,8 @@ Hook 是以你的用户权限运行的任意代码,且每次对应事件触发 ## 相关 -- CLI 的 `/hooks` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode)配置的 - Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks,它们会 - 真正执行。 +- CLI 的 `/hooks` 展示的就是本文描述的 Hooks:来自哪些文件、哪些层级生效、每个 + 匹配组会运行什么。它只报告配置,修改请直接编辑 `hooks.json`。 +- CLI 的 `/hooks_external` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode) + 配置的 Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks, + 它们会真正执行。 diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 13008fd9dd..df9ca0cc50 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -80,7 +80,8 @@ pub(crate) enum ActionHandler { McpServers, Tools, Extensions, - Hooks, + NativeHooks, + ExternalHooks, AcpHelp, Init, History, @@ -444,10 +445,25 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "hooks", name: "Hooks", aliases: &["/hooks"], + description: "View BitFun Hooks for agent lifecycle events", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::NativeHooks, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Tools", false), + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "hooks_external", + name: "External Hooks", + aliases: &["/hooks_external", "/hooks-external"], description: "Inspect external AI application Hooks", contexts: CHAT, availability: ActionAvailability::Always, - handler: ActionHandler::Hooks, + handler: ActionHandler::ExternalHooks, default_bindings: &[], fallback_bindings: &[], shortcut_field: None, @@ -1747,7 +1763,8 @@ mod tests { ActionHandler::McpServers, ActionHandler::Tools, ActionHandler::Extensions, - ActionHandler::Hooks, + ActionHandler::NativeHooks, + ActionHandler::ExternalHooks, ActionHandler::Login, ActionHandler::Usage, ] { diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 7b4c94cd93..a932139a4c 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -82,6 +82,9 @@ use bitfun_core::external_sources::{ ExternalToolCatalogEntry, ExternalToolRuntimeKind, NativePromptCommandDescriptor, PromptCommandAvailability, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; +use bitfun_core::native_hooks::{ + overview as native_hook_overview, NativeHookOverview, NativeHookRuleView, +}; use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; @@ -95,6 +98,7 @@ const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75; include!("chat/external_review.rs"); include!("chat/external_sources.rs"); include!("chat/external_hooks.rs"); +include!("chat/native_hooks.rs"); fn agent_event_stream_failure(error: TryRecvError) -> Option { match error { diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 620b26d023..8ac91f151c 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -683,7 +683,10 @@ impl ChatMode { ActionHandler::Extensions => { self.handle_external_control("", chat_view, chat_state, rt_handle); } - ActionHandler::Hooks => { + ActionHandler::NativeHooks => { + self.handle_native_hooks(chat_view, chat_state, rt_handle); + } + ActionHandler::ExternalHooks => { self.handle_external_hooks(chat_view, chat_state, rt_handle); } ActionHandler::AcpHelp => { @@ -879,7 +882,7 @@ fn action_opens_extension_management(action: &ActionSpec) -> bool { action.handler, ActionHandler::Tools | ActionHandler::Extensions - | ActionHandler::Hooks + | ActionHandler::ExternalHooks | ActionHandler::OpenAgentSelector ) } diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index ebbd25f2eb..217be9a476 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -5,14 +5,16 @@ const MAX_TUI_HOOK_CATALOG_DIAGNOSTICS: usize = 20; fn external_hook_help_text() -> String { [ - "Hooks", + "External Hooks", "", - "Usage: /hooks", + "Usage: /hooks_external", + "Alias: /hooks-external", "", "Shows a read-only static catalog of Hooks configured for OpenCode, Claude Code, and Codex.", "BitFun does not load or run handlers from this view. Coverage mapped means BitFun recognizes an equivalent reviewed Hook point; it does not mean the native handler is active.", + "BitFun's own Hooks, which do run, are shown by /hooks.", "", - "Help: /help hooks, /hooks -h, or /hooks --help", + "Help: /help hooks_external, /hooks_external -h, or /hooks_external --help", ] .join("\n") } @@ -27,7 +29,8 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option return None; }; match requested.to_ascii_lowercase().as_str() { - "hooks" => Some(external_hook_help_text()), + "hooks" => Some(native_hook_help_text()), + "hooks_external" | "hooks-external" => Some(external_hook_help_text()), "extensions" => Some([ "External integrations", "", @@ -72,12 +75,12 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> String { let mut lines = vec![ - "Hooks (read-only)".to_string(), + "External Hooks (read-only)".to_string(), "Static configuration only; no handler was loaded or executed.".to_string(), String::new(), ]; if snapshot.discovery_pending { - lines.push("Hook discovery is still pending. Run /hooks again.".to_string()); + lines.push("Hook discovery is still pending. Run /hooks_external again.".to_string()); return lines.join("\n"); } if snapshot.sources.is_empty() @@ -222,7 +225,7 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str } lines.push(String::new()); lines.push( - "Edit Hooks in the source application's configuration. Help: /help hooks, /hooks -h, or /hooks --help" + "Edit Hooks in the source application's configuration. BitFun's own Hooks: /hooks. Help: /help hooks_external, /hooks_external -h, or /hooks_external --help" .to_string(), ); lines.join("\n") diff --git a/src/apps/cli/src/modes/chat/native_hooks.rs b/src/apps/cli/src/modes/chat/native_hooks.rs new file mode 100644 index 0000000000..44a202b6f5 --- /dev/null +++ b/src/apps/cli/src/modes/chat/native_hooks.rs @@ -0,0 +1,189 @@ +const MAX_TUI_NATIVE_HOOK_RULES: usize = 100; +const MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE: usize = 20; +const MAX_TUI_NATIVE_HOOK_ISSUES: usize = 20; +const MAX_TUI_NATIVE_HOOK_COMMAND_CHARS: usize = 200; + +fn native_hook_help_text() -> String { + [ + "Hooks", + "", + "Usage: /hooks", + "", + "Shows the BitFun Hooks configured for agent lifecycle events, which files they came from, and whether each layer is active.", + "Hooks run your own commands, so this view only reports the configuration; edit hooks.json to change it.", + "Hooks configured for other AI applications are a separate read-only catalog: /hooks_external.", + "", + "Help: /help hooks, /hooks -h, or /hooks --help", + ] + .join("\n") +} + +fn truncate_hook_command(command: &str) -> String { + let command = command.trim(); + if command.chars().count() <= MAX_TUI_NATIVE_HOOK_COMMAND_CHARS { + return command.to_string(); + } + let kept = command + .chars() + .take(MAX_TUI_NATIVE_HOOK_COMMAND_CHARS) + .collect::(); + format!("{kept}…") +} + +fn native_hook_rule_line(rule: &NativeHookRuleView) -> String { + format!( + " matcher: {} [{}; {} handler{}{}]", + rule.matcher, + rule.scope, + rule.handlers.len(), + plural(rule.handlers.len()), + if rule.matcher_is_valid { + "" + } else { + "; invalid pattern, never matches" + }, + ) +} + +fn render_native_hook_overview(overview: &NativeHookOverview) -> String { + let mut lines = vec![ + "Hooks (BitFun)".to_string(), + "Commands BitFun runs at agent lifecycle events. Nothing was executed to build this view." + .to_string(), + String::new(), + ]; + lines.push(format!( + "Hooks: {} (app.hooks.enabled)", + if overview.enabled { + "enabled" + } else { + "disabled" + } + )); + lines.push(format!( + "Project hooks: {} (app.hooks.project_hooks_enabled)", + if overview.project_hooks_enabled { + "enabled" + } else { + "disabled" + } + )); + + lines.push(String::new()); + if overview.files.is_empty() { + lines.push("No hook configuration path is available on this host.".to_string()); + } else { + lines.push("Configuration:".to_string()); + for file in &overview.files { + lines.push(format!( + " {} [{}; {}]: {}", + file.scope, + if file.loaded { "loaded" } else { "not loaded" }, + if file.exists { "present" } else { "missing" }, + file.path.display(), + )); + } + } + + lines.push(String::new()); + if !overview.enabled { + lines.push("All hooks are off; set app.hooks.enabled to run them.".to_string()); + } else if overview.rules.is_empty() { + lines.push("No hooks are configured.".to_string()); + } else { + lines.push(format!( + "{} matcher group{}, {} handler{}:", + overview.rules.len(), + plural(overview.rules.len()), + overview.total_handlers, + plural(overview.total_handlers), + )); + let mut current_event = ""; + for rule in overview.rules.iter().take(MAX_TUI_NATIVE_HOOK_RULES) { + if rule.event != current_event { + current_event = rule.event; + lines.push(String::new()); + lines.push(rule.event.to_string()); + } + lines.push(native_hook_rule_line(rule)); + for handler in rule + .handlers + .iter() + .take(MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE) + { + lines.push(format!( + " - {} [timeout {}s{}]", + truncate_hook_command(&handler.command), + handler.timeout_seconds, + match handler.status_message.as_deref() { + Some(message) if !message.trim().is_empty() => + format!("; status: {}", message.trim()), + _ => String::new(), + }, + )); + } + let omitted_handlers = rule + .handlers + .len() + .saturating_sub(MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE); + if omitted_handlers > 0 { + lines.push(format!(" … omitted {omitted_handlers} handler(s).")); + } + } + let omitted_rules = overview + .rules + .len() + .saturating_sub(MAX_TUI_NATIVE_HOOK_RULES); + if omitted_rules > 0 { + lines.push(String::new()); + lines.push(format!( + "… omitted {omitted_rules} matcher group(s); open the hook files for the full configuration." + )); + } + } + + if !overview.issues.is_empty() { + lines.push(String::new()); + lines.push("Configuration issues:".to_string()); + for issue in overview.issues.iter().take(MAX_TUI_NATIVE_HOOK_ISSUES) { + lines.push(format!(" ! {issue}")); + } + if overview.issues.len() > MAX_TUI_NATIVE_HOOK_ISSUES { + lines.push(format!( + " … {} additional issue(s) omitted.", + overview.issues.len() - MAX_TUI_NATIVE_HOOK_ISSUES + )); + } + } + + lines.push(String::new()); + lines.push( + "Edit hooks.json to change this. Hooks configured for other AI applications: /hooks_external. Help: /help hooks, /hooks -h, or /hooks --help" + .to_string(), + ); + lines.join("\n") +} + +impl ChatMode { + fn handle_native_hooks( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace_root = self.workspace_path_for_sync(chat_state); + let overview = tokio::task::block_in_place(|| { + rt_handle.block_on(native_hook_overview(Some(workspace_root.as_path()))) + }); + chat_view.set_status(Some(if overview.enabled { + format!( + "Hooks: {} handler{}", + overview.total_handlers, + plural(overview.total_handlers) + ) + } else { + "Hooks are disabled".to_string() + })); + chat_state.add_system_message(render_native_hook_overview(&overview)); + } +} diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index ff02b60052..e75a90423a 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -17,15 +17,21 @@ mod tests { mode_change_completion_should_exit, native_command_choice_is_active, native_command_reconfirmation_is_required, parse_external_agent_review_action, parse_external_control_action, parse_external_tool_review_action, - previous_session_mode_change_status, render_external_hook_catalog, CommandRoute, + native_hook_help_text, previous_session_mode_change_status, render_external_hook_catalog, + render_native_hook_overview, CommandRoute, ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, }; - use crate::actions::{action_conflict_behavior_version, ActionState, ResolvedKeymap}; + use crate::actions::{ + action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, + }; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use bitfun_core::external_hooks::ExternalHookCatalogSnapshotV1; + use bitfun_core::native_hooks::{ + NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, + }; use bitfun_core::external_sources::{ native_prompt_command_conflict_key, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlSnapshotV1, ExternalSourceDiagnostic, @@ -654,6 +660,16 @@ mod tests { crate::actions::action_for_alias("/hooks", crate::actions::ActionContext::Chat) .expect("/hooks must be registered"); assert_eq!(action.id, "hooks"); + // /hooks shows BitFun's own executable hooks; the external read-only + // catalog keeps its own command. + assert_eq!(action.handler, ActionHandler::NativeHooks); + for alias in ["/hooks_external", "/hooks-external"] { + let external = + crate::actions::action_for_alias(alias, crate::actions::ActionContext::Chat) + .unwrap_or_else(|| panic!("{alias} must be registered")); + assert_eq!(external.id, "hooks_external"); + assert_eq!(external.handler, ActionHandler::ExternalHooks); + } let collision = external_command("hooks", None); assert_eq!( command_route(true, Some(&collision), false, false), @@ -667,6 +683,7 @@ mod tests { assert!(extension_command_help_request("hooks", "--help").is_some()); assert!(extension_command_help_request("hooks", "unexpected").is_none()); assert!(extension_command_help_request("help", "hooks").is_some()); + assert!(extension_command_help_request("hooks_external", "--help").is_some()); assert!(extension_command_help_request("help", "other").is_none()); assert!(extension_command_help_request("extensions", "-h") .unwrap() @@ -751,7 +768,7 @@ mod tests { .unwrap(); let text = render_external_hook_catalog(&snapshot); - assert!(text.contains("Hooks (read-only)")); + assert!(text.contains("External Hooks (read-only)")); assert!(text.contains("Claude Code")); assert!(text.contains("PreToolUse")); assert!(text.contains("coverage mapped: BitFun tool before")); @@ -762,14 +779,104 @@ mod tests { assert!(!text.contains("command body")); } + fn native_hook_overview() -> NativeHookOverview { + NativeHookOverview { + enabled: true, + project_hooks_enabled: false, + files: vec![ + NativeHookFileView { + scope: "user", + path: std::path::PathBuf::from("/home/u/.config/bitfun/config/hooks.json"), + exists: true, + loaded: true, + }, + NativeHookFileView { + scope: "project", + path: std::path::PathBuf::from("/ws/.bitfun/config/hooks.json"), + exists: true, + loaded: false, + }, + ], + rules: vec![NativeHookRuleView { + event: "PreToolUse", + matcher: "Bash".to_string(), + matcher_is_valid: true, + scope: "user", + source: "/home/u/.config/bitfun/config/hooks.json".to_string(), + handlers: vec![NativeHookHandlerView { + command: "jq -r '.tool_input.command' >> ~/log".to_string(), + timeout_seconds: 600, + status_message: None, + }], + }], + total_handlers: 1, + issues: vec!["Hook event 'PreTool' is not a supported event name: /ws".to_string()], + } + } + + #[test] + fn native_hook_text_reports_gating_layers_and_issues() { + let text = render_native_hook_overview(&native_hook_overview()); + + assert!(text.contains("Hooks (BitFun)")); + assert!(text.contains("Hooks: enabled (app.hooks.enabled)")); + assert!(text.contains("Project hooks: disabled (app.hooks.project_hooks_enabled)")); + assert!(text.contains("user [loaded; present]")); + assert!(text.contains("project [not loaded; present]")); + assert!(text.contains("PreToolUse")); + assert!(text.contains("matcher: Bash [user; 1 handler]")); + assert!(text.contains("timeout 600s")); + assert!(text.contains("is not a supported event name")); + // The external catalog stays discoverable from this view. + assert!(text.contains("/hooks_external")); + } + + #[test] + fn native_hook_text_explains_an_empty_or_disabled_configuration() { + let mut overview = native_hook_overview(); + overview.rules.clear(); + overview.total_handlers = 0; + overview.issues.clear(); + assert!(render_native_hook_overview(&overview).contains("No hooks are configured.")); + + overview.enabled = false; + let disabled = render_native_hook_overview(&overview); + assert!(disabled.contains("Hooks: disabled (app.hooks.enabled)")); + assert!(disabled.contains("set app.hooks.enabled to run them")); + } + + #[test] + fn native_hook_text_flags_a_matcher_that_never_matches() { + let mut overview = native_hook_overview(); + overview.rules[0].matcher = "Bash(".to_string(); + overview.rules[0].matcher_is_valid = false; + + let text = render_native_hook_overview(&overview); + + assert!(text.contains("invalid pattern, never matches")); + } + #[test] - fn hooks_help_uses_the_established_slash_help_pattern() { + fn external_hooks_help_uses_the_established_slash_help_pattern() { let help = external_hook_help_text(); + assert!(help.contains("Usage: /hooks_external")); + assert!(help.contains("Alias: /hooks-external")); + assert!(help.contains("/help hooks_external")); + assert!(help.contains("/hooks_external -h")); + assert!(help.contains("/hooks_external --help")); + assert!(!help.contains("/builtin:hooks")); + } + + #[test] + fn native_hooks_help_uses_the_established_slash_help_pattern() { + let help = native_hook_help_text(); assert!(help.contains("Usage: /hooks")); assert!(help.contains("/help hooks")); assert!(help.contains("/hooks -h")); assert!(help.contains("/hooks --help")); - assert!(!help.contains("/builtin:hooks")); + // The two views must stay distinguishable from their help alone. + assert!(help.contains("/hooks_external")); + assert!(!help.contains("Usage: /hooks_external")); } #[test] diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index e01796ec15..f7e5ae972d 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -53,6 +53,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "mcp_servers", "extensions", "hooks", + "hooks_external", "login", "logout", "help", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 9f58660527..3038998cec 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1070,7 +1070,8 @@ impl StartupPage { | ActionHandler::ReloadSkills | ActionHandler::Tools | ActionHandler::Extensions - | ActionHandler::Hooks + | ActionHandler::NativeHooks + | ActionHandler::ExternalHooks | ActionHandler::History | ActionHandler::ToggleAutoApprove | ActionHandler::Interrupt diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index ee8c13c3f1..04b6bc2687 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -21,9 +21,9 @@ use crate::infrastructure::try_get_path_manager_arc; use crate::service::config::get_global_config_service; pub use crate::service::config::types::AgentHooksConfig; use bitfun_agent_runtime::native_hooks::{ - AgentHookEngine, AgentHookEvent, AgentHookEventPayload, AgentHookOutcome, AgentHookPayload, - AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, AgentHookScope, - AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, + AgentHookEngine, AgentHookEvent, AgentHookEventPayload, AgentHookMatcher, AgentHookOutcome, + AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, + AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, }; use dashmap::DashMap; use log::{debug, info, warn}; @@ -483,20 +483,21 @@ pub(crate) fn hook_settings_paths( paths } -/// Read each existing hook settings file, in the given layer order, and parse -/// them into one engine. Unreadable or oversized files are skipped with a -/// warning so one bad layer cannot disable the rest. -pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngine { +/// Read each existing hook settings file, in the given layer order. Unreadable +/// or oversized files are skipped and reported so one bad layer cannot disable +/// the rest. +fn read_layers(paths: &[(AgentHookScope, PathBuf)]) -> (Vec, Vec) { let mut layers = Vec::new(); + let mut skipped = Vec::new(); for (scope, path) in paths { match std::fs::metadata(path) { Ok(metadata) if metadata.is_file() => { if metadata.len() > MAX_HOOKS_FILE_BYTES as u64 { - warn!( + skipped.push(format!( "Ignoring hook configuration over the {} byte limit: {}", MAX_HOOKS_FILE_BYTES, path.display() - ); + )); continue; } match std::fs::read(path) { @@ -505,16 +506,27 @@ pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngi source: path.to_string_lossy().to_string(), bytes, }), - Err(error) => warn!( + Err(error) => skipped.push(format!( "Failed to read hook configuration: path={}, error={}", path.display(), error - ), + )), } } _ => {} } } + (layers, skipped) +} + +/// Read each existing hook settings file, in the given layer order, and parse +/// them into one engine. Unreadable or oversized files are skipped with a +/// warning so one bad layer cannot disable the rest. +pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngine { + let (layers, skipped) = read_layers(paths); + for message in &skipped { + warn!("{message}"); + } let (settings, issues) = AgentHookSettings::from_layers(&layers); for issue in &issues { warn!("Agent hook configuration issue: {issue}"); @@ -564,3 +576,131 @@ async fn engine_for( ); Some(engine) } + +/// One `type: "command"` handler as configured, for read-only display. +#[derive(Debug, Clone)] +pub struct NativeHookHandlerView { + /// The command this host would run (`commandWindows` already applied). + pub command: String, + /// Timeout actually applied, after the per-event default and cap. + pub timeout_seconds: u64, + pub status_message: Option, +} + +/// One matcher group as configured, for read-only display. +#[derive(Debug, Clone)] +pub struct NativeHookRuleView { + pub event: &'static str, + /// Matcher as written; `*` when the group matches everything. + pub matcher: String, + /// `false` when the pattern is malformed, which never matches anything. + pub matcher_is_valid: bool, + pub scope: &'static str, + /// The file this group came from. + pub source: String, + pub handlers: Vec, +} + +/// One configuration layer, whether or not it currently contributes. +#[derive(Debug, Clone)] +pub struct NativeHookFileView { + pub scope: &'static str, + pub path: PathBuf, + pub exists: bool, + /// `false` when the layer is gated off, so its rules are not loaded. + pub loaded: bool, +} + +/// Everything the hook configuration would contribute to a session in this +/// workspace. Nothing here executes a handler. +#[derive(Debug, Clone)] +pub struct NativeHookOverview { + pub enabled: bool, + pub project_hooks_enabled: bool, + pub files: Vec, + /// Matcher groups in dispatch order, grouped by event. + pub rules: Vec, + pub total_handlers: usize, + /// Configuration problems, in the wording used for the backend log. + pub issues: Vec, +} + +/// Read the hook configuration for a workspace without dispatching anything. +/// +/// This is the read-only view behind the CLI `/hooks` command and any other +/// surface that needs to show what is configured. It re-reads the files rather +/// than consulting the dispatch cache, so it always reflects what is on disk. +pub async fn overview(workspace_root: Option<&Path>) -> NativeHookOverview { + // Ask for every candidate path, then mark which layers a dispatch would + // actually load, so the view can show a gated-off project file. + build_overview( + hooks_config().await, + hook_settings_paths(workspace_root, true), + ) +} + +pub(crate) fn build_overview( + config: AgentHooksConfig, + candidates: Vec<(AgentHookScope, PathBuf)>, +) -> NativeHookOverview { + let files = candidates + .iter() + .map(|(scope, path)| NativeHookFileView { + scope: scope.as_str(), + path: path.clone(), + exists: path.is_file(), + loaded: config.enabled + && (*scope == AgentHookScope::User || config.project_hooks_enabled), + }) + .collect::>(); + + let loaded_paths = candidates + .into_iter() + .zip(files.iter()) + .filter(|(_, file)| file.loaded) + .map(|(candidate, _)| candidate) + .collect::>(); + let (layers, skipped) = read_layers(&loaded_paths); + let (settings, issues) = AgentHookSettings::from_layers(&layers); + + let mut rules = Vec::new(); + for event in AgentHookEvent::ALL { + for rule in settings.rules_for(event) { + rules.push(NativeHookRuleView { + event: event.as_str(), + matcher: rule.matcher.display().to_string(), + // A malformed pattern parses into `Pattern` with no compiled + // regex, which never matches — same practical outcome as an + // outright invalid matcher, so both report as invalid here. + matcher_is_valid: match &rule.matcher { + AgentHookMatcher::Any => true, + AgentHookMatcher::Pattern { regex, .. } => regex.is_some(), + AgentHookMatcher::Invalid { .. } => false, + }, + scope: rule.scope.as_str(), + source: rule.source.clone(), + handlers: rule + .handlers + .iter() + .map(|handler| NativeHookHandlerView { + command: handler.effective_command().to_string(), + timeout_seconds: handler.effective_timeout(event).as_secs(), + status_message: handler.status_message.clone(), + }) + .collect(), + }); + } + } + + NativeHookOverview { + enabled: config.enabled, + project_hooks_enabled: config.project_hooks_enabled, + files, + total_handlers: settings.total_handlers(), + rules, + issues: skipped + .into_iter() + .chain(issues.iter().map(ToString::to_string)) + .collect(), + } +} diff --git a/src/crates/assembly/core/src/native_hooks_tests.rs b/src/crates/assembly/core/src/native_hooks_tests.rs index 811b7cb138..cac31cbcf4 100644 --- a/src/crates/assembly/core/src/native_hooks_tests.rs +++ b/src/crates/assembly/core/src/native_hooks_tests.rs @@ -1,6 +1,6 @@ use crate::native_hooks::{ - build_engine, clear_session_hook_state, dispatch_pre_tool_use, hook_settings_paths, - take_pending_session_context, AgentHooksConfig, NativeHookSessionFacts, + build_engine, build_overview, clear_session_hook_state, dispatch_pre_tool_use, + hook_settings_paths, take_pending_session_context, AgentHooksConfig, NativeHookSessionFacts, }; use bitfun_agent_runtime::native_hooks::{AgentHookEvent, AgentHookScope}; use serde_json::json; @@ -190,6 +190,121 @@ async fn remote_workspaces_skip_hook_dispatch() { assert!(decision.updated_input.is_none()); } +#[test] +fn overview_reports_the_layers_a_dispatch_would_load() { + let temp = tempfile::tempdir().expect("temp dir"); + let user = write_hooks_file( + temp.path(), + "user.json", + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"user-hook","timeout":5}]}]}}"#, + ); + let project = write_hooks_file( + temp.path(), + "project.json", + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"project-hook"}]}]}}"#, + ); + let candidates = vec![ + (AgentHookScope::User, user), + (AgentHookScope::Project, project), + ]; + + let gated = build_overview(AgentHooksConfig::default(), candidates.clone()); + assert!(gated.enabled); + assert!(!gated.project_hooks_enabled); + // The project file is listed so it stays discoverable, but its rules are + // not loaded while the gate is off. + assert_eq!(gated.files.len(), 2); + assert!(gated.files[0].loaded && gated.files[0].exists); + assert!(!gated.files[1].loaded && gated.files[1].exists); + assert_eq!(gated.rules.len(), 1); + assert_eq!(gated.rules[0].event, "PreToolUse"); + assert_eq!(gated.rules[0].matcher, "Bash"); + assert!(gated.rules[0].matcher_is_valid); + assert_eq!(gated.rules[0].scope, "user"); + assert_eq!(gated.rules[0].handlers[0].command, "user-hook"); + assert_eq!(gated.rules[0].handlers[0].timeout_seconds, 5); + assert_eq!(gated.total_handlers, 1); + + let with_project = build_overview( + AgentHooksConfig { + enabled: true, + project_hooks_enabled: true, + }, + candidates.clone(), + ); + assert!(with_project.files.iter().all(|file| file.loaded)); + assert_eq!(with_project.total_handlers, 2); + assert!(with_project + .rules + .iter() + .any(|rule| rule.event == "Stop" && rule.scope == "project")); + + // A disabled master switch loads nothing, but still names both files so + // the reader can see what would run once it is turned back on. + let disabled = build_overview( + AgentHooksConfig { + enabled: false, + project_hooks_enabled: true, + }, + candidates, + ); + assert!(disabled.files.iter().all(|file| !file.loaded)); + assert!(disabled.rules.is_empty()); + assert_eq!(disabled.total_handlers, 0); +} + +#[test] +fn overview_surfaces_configuration_issues_and_a_never_matching_matcher() { + let temp = tempfile::tempdir().expect("temp dir"); + let user = write_hooks_file( + temp.path(), + "user.json", + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash(","hooks":[{"type":"command","command":"never-runs"}]}],"NotAnEvent":[]}}"#, + ); + + let overview = build_overview( + AgentHooksConfig::default(), + vec![(AgentHookScope::User, user)], + ); + + assert_eq!(overview.rules.len(), 1); + assert!(!overview.rules[0].matcher_is_valid); + assert!(overview + .issues + .iter() + .any(|issue| issue.contains("NotAnEvent"))); + assert!(overview + .issues + .iter() + .any(|issue| issue.contains("not a valid pattern"))); +} + +#[test] +fn overview_reports_an_oversized_file_that_dispatch_would_skip() { + let temp = tempfile::tempdir().expect("temp dir"); + let padding = " ".repeat(1024 * 1024 + 1); + let oversized = write_hooks_file( + temp.path(), + "oversized.json", + &format!( + r#"{{"description":"{padding}","hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"too-big"}}]}}]}}}}"# + ), + ); + + let overview = build_overview( + AgentHooksConfig::default(), + vec![(AgentHookScope::User, oversized)], + ); + + assert!(overview.rules.is_empty()); + // Silence here would read as "nothing is configured" instead of "your + // file was skipped". + assert!(overview + .issues + .iter() + .any(|issue| issue.contains("byte limit"))); +} + #[test] fn session_context_buffer_starts_empty_and_clears() { assert!(take_pending_session_context("unknown-session").is_empty());