From 5b752444763c51c1957125b6e2631b211cf9490c Mon Sep 17 00:00:00 2001 From: jnjpng Date: Wed, 28 Jan 2026 15:40:29 -0800 Subject: [PATCH] feat: add disable all hook toggle (#726) --- src/cli/components/HooksManager.tsx | 98 ++++++++++++++++++++++++----- src/hooks/index.ts | 2 +- src/hooks/loader.ts | 58 +++++++++++++++++ src/hooks/types.ts | 4 ++ src/hooks/writer.ts | 32 +++++++++- src/settings-manager.ts | 2 +- 6 files changed, 178 insertions(+), 18 deletions(-) diff --git a/src/cli/components/HooksManager.tsx b/src/cli/components/HooksManager.tsx index d8d685c..032d2f0 100644 --- a/src/cli/components/HooksManager.tsx +++ b/src/cli/components/HooksManager.tsx @@ -2,7 +2,7 @@ // Interactive TUI for managing hooks configuration import { Box, Text, useInput } from "ink"; -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type HookEvent, type HookMatcher, @@ -18,11 +18,14 @@ import { countTotalHooks, type HookMatcherWithSource, type HookWithSource, + isUserHooksDisabled, loadMatchersWithSource, loadSimpleMatchersWithSource, removeHook, type SaveLocation, + setHooksDisabled, } from "../../hooks/writer"; +import { settingsManager } from "../../settings-manager"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; @@ -145,6 +148,9 @@ export const HooksManager = memo(function HooksManager({ // Dynamic tool names from agent const [toolNames, setToolNames] = useState(FALLBACK_TOOL_NAMES); + // Track whether all hooks are disabled + const [hooksDisabled, setHooksDisabledState] = useState(isUserHooksDisabled); + // Fetch agent tools on mount useEffect(() => { if (!agentId) return; @@ -188,9 +194,30 @@ export const HooksManager = memo(function HooksManager({ // Refresh counts - called when hooks change const refreshCounts = useCallback(() => { setTotalHooks(countTotalHooks()); + setHooksDisabledState(isUserHooksDisabled()); }, []); - // Load total hooks count on mount and when returning to events screen + // Track if initial settings load has been done + const initialLoadDone = useRef(false); + + // Ensure settings are loaded before counting hooks (runs once on mount) + useEffect(() => { + if (initialLoadDone.current) return; + initialLoadDone.current = true; + + const loadSettings = async () => { + try { + await settingsManager.loadProjectSettings(); + await settingsManager.loadLocalProjectSettings(); + } catch { + // Settings may already be loaded or not available + } + refreshCounts(); + }; + loadSettings(); + }, [refreshCounts]); + + // Refresh counts when returning to events screen useEffect(() => { if (screen === "events") { refreshCounts(); @@ -267,6 +294,13 @@ export const HooksManager = memo(function HooksManager({ setSelectedIndex(0); }, [deleteHookIndex, selectedEvent, hooks, loadHooks, refreshCounts]); + // Handle toggling the "disable all hooks" setting + const handleToggleDisableAll = useCallback(() => { + const newValue = !hooksDisabled; + setHooksDisabled(newValue); + setHooksDisabledState(newValue); + }, [hooksDisabled]); + useInput((input, key) => { // CTRL-C: immediately cancel if (key.ctrl && input === "c") { @@ -276,17 +310,26 @@ export const HooksManager = memo(function HooksManager({ // Handle each screen if (screen === "events") { + // Total items: 1 (disable toggle) + HOOK_EVENTS.length + const totalItems = 1 + HOOK_EVENTS.length; + if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setSelectedIndex((prev) => Math.min(HOOK_EVENTS.length - 1, prev + 1)); + setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1)); } else if (key.return) { - const selected = HOOK_EVENTS[selectedIndex]; - if (selected) { - setSelectedEvent(selected.event); - loadHooks(selected.event); - setScreen("hooks-list"); - setSelectedIndex(0); + if (selectedIndex === 0) { + // Toggle "disable all hooks" + handleToggleDisableAll(); + } else { + // Select a hook event (index is shifted by 1) + const selected = HOOK_EVENTS[selectedIndex - 1]; + if (selected) { + setSelectedEvent(selected.event); + loadHooks(selected.event); + setScreen("hooks-list"); + setSelectedIndex(0); + } } } else if (key.escape) { onClose(); @@ -372,20 +415,45 @@ export const HooksManager = memo(function HooksManager({ // Render Events List if (screen === "events") { + const disableToggleSelected = selectedIndex === 0; + const disableToggleLabel = hooksDisabled + ? "Enable all hooks" + : "Disable all hooks"; + const titleBase = " Hooks"; + const titleSuffix = hooksDisabled ? " (disabled)" : ""; + const hooksCountText = `${totalHooks} hooks `; + const titlePadding = + boxWidth - + titleBase.length - + titleSuffix.length - + hooksCountText.length - + 2; + return ( {boxTop(boxWidth)} - {boxLine( - ` Hooks${" ".repeat(boxWidth - 20)}${totalHooks} hooks `, - boxWidth, - )} + {BOX_VERTICAL} + {titleBase} + {titleSuffix} + {" ".repeat(Math.max(0, titlePadding))} + {hooksCountText} + {BOX_VERTICAL} {boxBottom(boxWidth)} + {/* Disable all hooks toggle - first item */} + + + {disableToggleSelected ? "❯" : " "} 1. + + {disableToggleLabel} + + + {/* Hook events */} {HOOK_EVENTS.map((item, index) => { - const isSelected = index === selectedIndex; + const isSelected = index + 1 === selectedIndex; const hookCount = countHooksForEvent(item.event); const prefix = isSelected ? "❯" : " "; const countStr = hookCount > 0 ? ` (${hookCount})` : ""; @@ -393,7 +461,7 @@ export const HooksManager = memo(function HooksManager({ return ( - {prefix} {index + 1}. {item.event} + {prefix} {index + 2}. {item.event} - {item.description} {countStr} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 5ed1354..e4294f5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -20,7 +20,7 @@ import type { UserPromptSubmitHookInput, } from "./types"; -export { clearHooksCache } from "./loader"; +export { areHooksDisabled, clearHooksCache } from "./loader"; // Re-export types for convenience export * from "./types"; diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index d6cb424..1424a1c 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -239,6 +239,59 @@ export function hasHooksForEvent( } } +/** + * Check if all hooks are disabled via hooks.disabled across settings levels. + * + * Precedence: + * 1. If user has disabled: false → ENABLED (explicit user override) + * 2. If user has disabled: true → DISABLED + * 3. If project OR project-local has disabled: true → DISABLED + * 4. Default → ENABLED + */ +export function areHooksDisabled( + workingDirectory: string = process.cwd(), +): boolean { + try { + // Check user-level settings first (highest precedence) + const userDisabled = settingsManager.getSettings().hooks?.disabled; + if (userDisabled === false) { + // User explicitly enabled - overrides project settings + return false; + } + if (userDisabled === true) { + // User explicitly disabled + return true; + } + + // User setting is undefined, check project-level settings + try { + const projectDisabled = + settingsManager.getProjectSettings(workingDirectory)?.hooks?.disabled; + if (projectDisabled === true) { + return true; + } + } catch { + // Project settings not loaded, skip + } + + // Check project-local settings + try { + const localDisabled = + settingsManager.getLocalProjectSettings(workingDirectory)?.hooks + ?.disabled; + if (localDisabled === true) { + return true; + } + } catch { + // Local project settings not loaded, skip + } + + return false; + } catch { + return false; + } +} + /** * Convenience function to load hooks and get matching ones for an event */ @@ -247,6 +300,11 @@ export async function getHooksForEvent( toolName?: string, workingDirectory: string = process.cwd(), ): Promise { + // Check if all hooks are disabled + if (areHooksDisabled(workingDirectory)) { + return []; + } + const config = await loadHooks(workingDirectory); return getMatchingHooks(config, event, toolName); } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 043d600..1816898 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -66,8 +66,12 @@ export interface SimpleHookMatcher { * Full hooks configuration stored in settings * - Tool events (PreToolUse, PostToolUse, PermissionRequest) use HookMatcher[] with matcher patterns * - Simple events use SimpleHookMatcher[] (same structure, just no matcher field) + * - disabled: when true, prevents all hooks from firing (checked across all config levels) */ export type HooksConfig = { + /** When true, disables all hooks. User false overrides project settings; otherwise any true disables. */ + disabled?: boolean; +} & { [K in ToolHookEvent]?: HookMatcher[]; } & { [K in SimpleHookEvent]?: SimpleHookMatcher[]; diff --git a/src/hooks/writer.ts b/src/hooks/writer.ts index f1cb67e..023e7dc 100644 --- a/src/hooks/writer.ts +++ b/src/hooks/writer.ts @@ -309,7 +309,11 @@ export function countTotalHooks( for (const location of locations) { const hooks = loadHooksFromLocation(location, workingDirectory); - for (const event of Object.keys(hooks) as HookEvent[]) { + for (const key of Object.keys(hooks)) { + // Skip non-event keys like 'disabled' + if (key === "disabled") continue; + + const event = key as HookEvent; if (isToolEvent(event)) { // Tool events have HookMatcher[] with nested hooks const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[]; @@ -361,3 +365,29 @@ export function countHooksForEvent( return count; } + +/** + * Check if user-level hooks.disabled is set to true. + * NOTE: This only checks user settings. For full precedence logic + * (user → project → project-local), use areHooksDisabled from loader.ts. + */ +export function isUserHooksDisabled(): boolean { + try { + return settingsManager.getSettings().hooks?.disabled === true; + } catch { + return false; + } +} + +/** + * Set whether all hooks are disabled (writes to user-level hooks.disabled) + */ +export function setHooksDisabled(disabled: boolean): void { + const currentHooks = settingsManager.getSettings().hooks || {}; + settingsManager.updateSettings({ + hooks: { + ...currentHooks, + disabled, + }, + }); +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index ed9cfdc..b7b96ea 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -47,7 +47,7 @@ export interface Settings { pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer createDefaultAgents?: boolean; // Create Memo/Incognito default agents on startup (default: true) permissions?: PermissionRules; - hooks?: HooksConfig; // Hook commands that run at various lifecycle points + hooks?: HooksConfig; // Hook commands that run at various lifecycle points (includes disabled flag) env?: Record; // Server-indexed settings (agent IDs are server-specific) sessionsByServer?: Record; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283")