diff --git a/README.md b/README.md index a5ff3da..b68cb51 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,12 @@ hooks: ### Hook Configuration Options -| Option | Type | Description | -| -------- | ---------------------- | --------------------------------- | -| `run` | `string` \| `string[]` | Command(s) to execute | -| `inject` | `string` | Message injected into the session | -| `toast` | `object` | Toast notification configuration | +| Option | Type | Description | +| ---------------- | ---------------------- | ------------------------------------------------------------------------ | +| `run` | `string` \| `string[]` | Command(s) to execute | +| `inject` | `string` | Message injected into the session | +| `toast` | `object` | Toast notification configuration | +| `overrideGlobal` | `boolean` | When `true`, suppresses global hooks matching the same event/phase+tool | ### Toast Configuration @@ -173,13 +174,21 @@ Add to your `opencode.json`: ## Configuration -### JSON Config +### Config Locations + +The plugin loads hooks from two locations: -Create `.opencode/command-hooks.jsonc` in your project (the plugin searches upward from the current working directory): +1. **User global**: `~/.config/opencode/command-hooks.jsonc` — hooks that apply to all projects +2. **Project**: `.opencode/command-hooks.jsonc` — project-specific hooks (searches upward from cwd) + +Both are merged by default. See [Configuration Precedence](#configuration-precedence) for details. + +### JSON Config ```jsonc { "truncationLimit": 30000, + "ignoreGlobalConfig": false, "tool": [ // Tool hooks ], @@ -191,11 +200,12 @@ Create `.opencode/command-hooks.jsonc` in your project (the plugin searches upwa #### JSON Config Options -| Option | Type | Description | -| ----------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `truncationLimit` | `number` | Maximum characters to capture from command output. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. | -| `tool` | `ToolHook[]` | Array of tool execution hooks | -| `session` | `SessionHook[]` | Array of session lifecycle hooks | +| Option | Type | Description | +| ------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `truncationLimit` | `number` | Maximum characters to capture from command output. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. | +| `ignoreGlobalConfig`| `boolean` | When `true`, skip loading `~/.config/opencode/command-hooks.jsonc`. Defaults to `false`. | +| `tool` | `ToolHook[]` | Array of tool execution hooks | +| `session` | `SessionHook[]` | Array of session lifecycle hooks | ### Markdown Frontmatter @@ -216,11 +226,50 @@ hooks: ### Configuration Precedence -1. Hooks are loaded from `.opencode/command-hooks.jsonc` -2. Markdown hooks are converted to normal hooks with auto-generated IDs -3. If a markdown hook and a global hook share the same `id`, the markdown hook wins -4. Duplicate IDs within the same source are errors -5. Global config is cached to avoid repeated file reads +Hooks are loaded from two locations and merged: + +1. **User global config**: `~/.config/opencode/command-hooks.jsonc` +2. **Project config**: `.opencode/command-hooks.jsonc` (searches upward from cwd) + +**Merge behavior:** + +| Scenario | Result | +|----------|--------| +| Different hook IDs | Both run (concatenation) | +| Same hook ID | Project replaces global | +| `overrideGlobal: true` on hook | Suppresses all global hooks for same event/phase+tool | +| `ignoreGlobalConfig: true` in project | Skips global config entirely | + +**Example: Override all global hooks for an event** + +```jsonc +{ + "session": [ + { + "id": "my-session-idle", + "when": { "event": "session.idle" }, + "run": "echo only this runs", + "overrideGlobal": true + } + ] +} +``` + +**Example: Ignore global config entirely** + +```jsonc +{ + "ignoreGlobalConfig": true, + "tool": [ + // Only these hooks will run + ] +} +``` + +Additional precedence rules: +- Markdown hooks are converted to normal hooks with auto-generated IDs +- If a markdown hook and a config hook share the same `id`, the markdown hook wins +- Duplicate IDs within the same source are errors --- diff --git a/src/config/global.ts b/src/config/global.ts index fd29e0f..d4a4d69 100644 --- a/src/config/global.ts +++ b/src/config/global.ts @@ -1,15 +1,29 @@ /** * Global configuration parser for loading hooks from .opencode/command-hooks.jsonc * - * Searches for .opencode/command-hooks.jsonc starting from the current working - * directory and walking up the directory tree. Parses JSONC format as CommandHooksConfig. + * Loads both user global config (~/.config/opencode/command-hooks.jsonc) and + * project config (.opencode/command-hooks.jsonc), merging them with project taking precedence. + * + * Supports: + * - `ignoreGlobalConfig: true` in project config to skip user global entirely + * - `overrideGlobal: true` on individual hooks to suppress matching global hooks */ import type { CommandHooksConfig } from "../types/hooks.js"; import { isValidCommandHooksConfig } from "../schemas.js"; +import { mergeConfigs } from "./merge.js"; import { join, dirname } from "path"; +import { homedir } from "os"; import { logger } from "../logging.js"; +/** + * Get the user's global config directory path + * Uses ~/.config/opencode/ following XDG convention + */ +const getUserConfigPath = (): string => { + return join(homedir(), ".config", "opencode", "command-hooks.jsonc"); +}; + export type GlobalConfigResult = { config: CommandHooksConfig; error: string | null; @@ -107,10 +121,10 @@ const parseJson = (content: string): unknown => { /** - * Find command hooks config file by walking up directory tree - * Looks for .opencode/command-hooks.jsonc + * Find project config file by walking up directory tree + * Looks for .opencode/command-hooks.jsonc in project directories */ -const findConfigFile = async (startDir: string): Promise => { +const findProjectConfigFile = async (startDir: string): Promise => { let currentDir = startDir; // Limit search depth to avoid infinite loops @@ -123,7 +137,7 @@ const findConfigFile = async (startDir: string): Promise => { try { const file = Bun.file(configPath); if (await file.exists()) { - logger.debug(`Found config file: ${configPath}`); + logger.debug(`Found project config file: ${configPath}`); return configPath; } } catch { @@ -141,99 +155,164 @@ const findConfigFile = async (startDir: string): Promise => { depth++; } - logger.debug( - `No config file found after searching ${depth} directories`, + logger.debug(`No project config file found after searching ${depth} directories`); + return null; +} + +const emptyConfig = (): CommandHooksConfig => ({ tool: [], session: [] }); + +/** + * Load and parse config from a specific file path + * + * @param configPath - Path to the config file + * @param source - Source identifier for logging ("project" or "user global") + * @returns GlobalConfigResult with parsed config or error + */ +const loadConfigFromPath = async ( + configPath: string, + source: string +): Promise => { + // Read file + let content: string; + try { + const file = Bun.file(configPath); + if (!(await file.exists())) { + return { config: emptyConfig(), error: null }; + } + content = await file.text(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.info(`Failed to read ${source} config file ${configPath}: ${message}`); + return { + config: emptyConfig(), + error: `Failed to read ${source} config file ${configPath}: ${message}`, + }; + } + + // Parse JSONC + let parsed: unknown; + try { + const stripped = stripJsoncComments(content); + parsed = parseJson(stripped); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.info(`Failed to parse ${source} config file ${configPath}: ${message}`); + return { + config: emptyConfig(), + error: `Failed to parse ${source} config file ${configPath}: ${message}`, + }; + } + + // Validate entire file as CommandHooksConfig + if (!isValidCommandHooksConfig(parsed)) { + logger.info( + `${source} config file is not a valid CommandHooksConfig (expected { tool?: [], session?: [] }), using empty config`, ); + return { + config: emptyConfig(), + error: `${source} config file is not a valid CommandHooksConfig`, + }; + } - return null; + // Return with defaults for missing arrays + const result: CommandHooksConfig = { + truncationLimit: parsed.truncationLimit, + ignoreGlobalConfig: parsed.ignoreGlobalConfig, + tool: parsed.tool ?? [], + session: parsed.session ?? [], + }; + + logger.debug( + `Loaded ${source} config: truncationLimit=${result.truncationLimit}, ${result.tool?.length ?? 0} tool hooks, ${result.session?.length ?? 0} session hooks`, + ); + + return { config: result, error: null }; } /** - * Load and parse global command hooks configuration + * Load and merge command hooks configuration from both sources + * + * Loads both user global config (~/.config/opencode/command-hooks.jsonc) and + * project config (.opencode/command-hooks.jsonc), then merges them. * - * Searches for .opencode/command-hooks.jsonc starting from the current working - * directory and walking up. Parses the entire file as CommandHooksConfig. + * Merge behavior: + * - If project config has `ignoreGlobalConfig: true`, skip user global entirely + * - Otherwise, merge with project hooks taking precedence: + * - Same hook `id` → project version wins + * - Hook with `overrideGlobal: true` → suppresses matching global hooks + * - Different `id` without override → both run (concatenation) * * Error handling: - * - If no config file found: returns empty config (not an error) - * - If config file is malformed: logs warning, returns empty config - * - If file is not a valid CommandHooksConfig: logs warning, returns empty config + * - If no config files found: returns empty config (not an error) + * - If user global has parse error: warns and uses project config only + * - If project has parse error: returns error * - Never throws errors - always returns a valid config * * @returns Promise resolving to GlobalConfigResult */ export const loadGlobalConfig = async (): Promise => { - let configPath: string | null = null; - try { - // Find config file - logger.debug(`loadGlobalConfig: starting search from: ${process.cwd()}`) - configPath = await findConfigFile(process.cwd()); - - if (!configPath) { - logger.debug( - "No .opencode/command-hooks.jsonc file found, using empty config", - ); - return { config: { tool: [], session: [] }, error: null }; - } + try { + logger.debug(`loadGlobalConfig: starting search from: ${process.cwd()}`); - // Read file - let content: string; - try { - const file = Bun.file(configPath); - content = await file.text(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.info(`Failed to read config file ${configPath}: ${message}`); - return { - config: { tool: [], session: [] }, - error: `Failed to read config file ${configPath}: ${message}`, - }; + // Step 1: Load project config first (to check ignoreGlobalConfig flag) + const projectConfigPath = await findProjectConfigFile(process.cwd()); + const projectResult = projectConfigPath + ? await loadConfigFromPath(projectConfigPath, "project") + : { config: emptyConfig(), error: null }; + + // If project config had an error, return it + if (projectResult.error) { + return projectResult; } - // Parse JSONC - let parsed: unknown; - try { - const stripped = stripJsoncComments(content); - parsed = parseJson(stripped); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.info(`Failed to parse config file ${configPath}: ${message}`); - return { - config: { tool: [], session: [] }, - error: `Failed to parse config file ${configPath}: ${message}`, - }; + // Step 2: If project says ignore global, return project only + if (projectResult.config.ignoreGlobalConfig) { + logger.debug("Project config has ignoreGlobalConfig: true, skipping user global"); + return projectResult; } - // Validate entire file as CommandHooksConfig - if (!isValidCommandHooksConfig(parsed)) { + // Step 3: Load user global config + const userGlobalPath = getUserConfigPath(); + const userGlobalResult = await loadConfigFromPath(userGlobalPath, "user global"); + + // Step 4: If user global had parse error, log and use project only + if (userGlobalResult.error) { logger.info( - "Config file is not a valid CommandHooksConfig (expected { tool?: [], session?: [] }), using empty config", + `Failed to load user global config (${userGlobalPath}): ${userGlobalResult.error}. Using project config only.` ); - return { - config: { tool: [], session: [] }, - error: - "Config file is not a valid CommandHooksConfig (expected { tool?: [], session?: [] })", - }; + return projectResult; } - // Return with defaults for missing arrays - const result: CommandHooksConfig = { - truncationLimit: parsed.truncationLimit, - tool: parsed.tool ?? [], - session: parsed.session ?? [], - }; + // Step 5: If neither config has hooks, return empty + const hasUserGlobalHooks = + (userGlobalResult.config.tool?.length ?? 0) > 0 || + (userGlobalResult.config.session?.length ?? 0) > 0; + const hasProjectHooks = + (projectResult.config.tool?.length ?? 0) > 0 || + (projectResult.config.session?.length ?? 0) > 0; - logger.debug( - `Loaded global config: truncationLimit=${result.truncationLimit}, ${result.tool?.length ?? 0} tool hooks, ${result.session?.length ?? 0} session hooks`, - ); + if (!hasUserGlobalHooks && !hasProjectHooks) { + logger.debug("No hooks found in either config, using empty config"); + return { config: emptyConfig(), error: null }; + } + + // Step 6: Merge configs - user global as base, project as override + const { config: mergedConfig } = mergeConfigs( + userGlobalResult.config, + projectResult.config + ); + + logger.debug( + `Merged configs: ${mergedConfig.tool?.length ?? 0} tool hooks, ${mergedConfig.session?.length ?? 0} session hooks` + ); - return { config: result, error: null }; + return { config: mergedConfig, error: null }; } catch (error) { // Catch-all for unexpected errors const message = error instanceof Error ? error.message : String(error); logger.info(`Unexpected error loading global config: ${message}`); return { - config: { tool: [], session: [] }, + config: emptyConfig(), error: `Unexpected error loading global config: ${message}`, }; } diff --git a/src/config/merge.ts b/src/config/merge.ts index 08892ae..6422563 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -96,52 +96,92 @@ const validateConfigForDuplicates = ( } /** - * Merge two hook arrays with markdown taking precedence + * Merge two hook arrays with project/markdown taking precedence * - * Combines global and markdown hooks, where markdown hooks with the same ID - * replace global hooks. Markdown hooks with unique IDs are appended. + * Combines global and project hooks with the following rules: + * 1. Project hooks with `overrideGlobal: true` suppress global hooks matching the same event key + * 2. Project hooks with same `id` replace global hooks + * 3. Project hooks with unique `id` are appended * - * Order is preserved: global hooks first (except those replaced), then new markdown hooks. + * Order is preserved: global hooks first (except those filtered/replaced), then new project hooks. * - * @param globalHooks - Hooks from global config - * @param markdownHooks - Hooks from markdown config + * @param globalHooks - Hooks from user global config (~/.config/opencode/) + * @param projectHooks - Hooks from project config (.opencode/) or markdown + * @param getEventKey - Function to extract the event key for override matching * @returns Merged hook array * * @example * ```typescript * const global = [ - * { id: "hook-1", ... }, - * { id: "hook-2", ... } + * { id: "hook-1", when: { event: "session.created" }, ... }, + * { id: "hook-2", when: { event: "session.idle" }, ... } * ] - * const markdown = [ - * { id: "hook-1", ... }, // replaces global hook-1 - * { id: "hook-3", ... } // new hook + * const project = [ + * { id: "hook-3", when: { event: "session.created" }, overrideGlobal: true, ... } * ] - * mergeHookArrays(global, markdown) - * // Returns: [{ id: "hook-1", ... (markdown version) }, { id: "hook-2", ... }, { id: "hook-3", ... }] + * mergeHookArrays(global, project, h => h.when.event) + * // Returns: [{ id: "hook-2", ... }, { id: "hook-3", ... }] + * // hook-1 was filtered out because hook-3 has overrideGlobal for same event * ``` */ const mergeHookArrays = ( globalHooks: T[], - markdownHooks: T[], + projectHooks: T[], + getEventKey: (hook: T) => string, ): T[] => { - // Create a map of markdown hooks by ID for quick lookup - const markdownMap = new Map() - const markdownIds = new Set() + // Step 1: Find project hooks with overrideGlobal: true and collect their event keys + const overriddenKeys = new Set() + const wildcardOverridePhases = new Set() + + for (const hook of projectHooks) { + if (hook.overrideGlobal) { + const key = getEventKey(hook) + overriddenKeys.add(key) + + // For tool hooks: if tool is "*", mark the phase as fully overridden + // Key format for tool hooks is "phase:tool", e.g., "after:\"*\"" or "after:\"bash\"" + if (key.includes('"*"')) { + const phase = key.split(':')[0] + wildcardOverridePhases.add(phase) + logger.debug(`Wildcard override for phase "${phase}" from project hook "${hook.id}"`) + } + } + } + + // Step 2: Filter out global hooks that match overridden keys + const filteredGlobalHooks = globalHooks.filter(hook => { + const key = getEventKey(hook) + + // Check for wildcard phase override (tool hooks only) + const phase = key.split(':')[0] + if (wildcardOverridePhases.has(phase)) { + logger.debug(`Skipping global hook "${hook.id}" due to wildcard overrideGlobal on phase "${phase}"`) + return false + } + + // Check for exact event key override + if (overriddenKeys.has(key)) { + logger.debug(`Skipping global hook "${hook.id}" due to overrideGlobal on event key "${key}"`) + return false + } + + return true + }) - for (const hook of markdownHooks) { - markdownMap.set(hook.id, hook) - markdownIds.add(hook.id) + // Step 3: Create a map of project hooks by ID for quick lookup + const projectMap = new Map() + for (const hook of projectHooks) { + projectMap.set(hook.id, hook) } - // Start with global hooks, replacing those that appear in markdown + // Step 4: Start with filtered global hooks, replacing those that appear in project const result: T[] = [] const processedIds = new Set() - for (const hook of globalHooks) { - if (markdownMap.has(hook.id)) { - // Replace with markdown version - result.push(markdownMap.get(hook.id)!) + for (const hook of filteredGlobalHooks) { + if (projectMap.has(hook.id)) { + // Replace with project version + result.push(projectMap.get(hook.id)!) } else { // Keep global hook result.push(hook) @@ -149,8 +189,8 @@ const mergeHookArrays = ( processedIds.add(hook.id) } - // Add markdown hooks that weren't replacements - for (const hook of markdownHooks) { + // Step 5: Add project hooks that weren't replacements + for (const hook of projectHooks) { if (!processedIds.has(hook.id)) { result.push(hook) } @@ -205,17 +245,22 @@ export const mergeConfigs = ( const markdownErrors = validateConfigForDuplicates(markdown, "markdown") errors.push(...markdownErrors) - // Merge tool hooks + // Merge tool hooks with phase+tool matching for overrideGlobal const globalToolHooks = global.tool ?? [] const markdownToolHooks = markdown.tool ?? [] - const mergedToolHooks = mergeHookArrays(globalToolHooks, markdownToolHooks) + const mergedToolHooks = mergeHookArrays( + globalToolHooks, + markdownToolHooks, + (hook: ToolHook) => `${hook.when.phase}:${JSON.stringify(hook.when.tool ?? "*")}` + ) - // Merge session hooks + // Merge session hooks with event matching for overrideGlobal const globalSessionHooks = global.session ?? [] const markdownSessionHooks = markdown.session ?? [] const mergedSessionHooks = mergeHookArrays( globalSessionHooks, markdownSessionHooks, + (hook: SessionHook) => hook.when.event ) // Build merged config diff --git a/src/schemas.ts b/src/schemas.ts index 3e10067..73eeaaf 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -64,6 +64,7 @@ export const ToolHookSchema = z.object({ run: z.union([z.string(), z.array(z.string())]), inject: z.string().optional(), toast: ToastSchema, + overrideGlobal: z.boolean().optional(), }); // ============================================================================ @@ -91,6 +92,7 @@ export const SessionHookSchema = z.object({ run: z.union([z.string(), z.array(z.string())]), inject: z.string().optional(), toast: ToastSchema, + overrideGlobal: z.boolean().optional(), }); // ============================================================================ @@ -105,6 +107,7 @@ export const SessionHookSchema = z.object({ */ export const ConfigSchema = z.object({ truncationLimit: z.number().int().positive().optional(), + ignoreGlobalConfig: z.boolean().optional(), tool: z.array(ToolHookSchema).optional(), session: z.array(SessionHookSchema).optional(), }); diff --git a/src/types/hooks.ts b/src/types/hooks.ts index b507ca8..1799238 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -85,6 +85,12 @@ export interface ToolHook { /** Duration in milliseconds. */ duration?: number } + + /** + * When true, prevents global hooks with matching phase+tool from running. + * If tool is "*", overrides ALL global hooks for that phase. + */ + overrideGlobal?: boolean } /** @@ -151,6 +157,11 @@ export interface SessionHook { /** Duration in milliseconds. */ duration?: number } + + /** + * When true, prevents global hooks with matching event from running. + */ + overrideGlobal?: boolean } /** @@ -183,6 +194,9 @@ export interface CommandHooksConfig { /** Truncation limit for command output in characters. Defaults to 30,000. */ truncationLimit?: number + /** When true, ignore ~/.config/opencode/command-hooks.jsonc entirely. */ + ignoreGlobalConfig?: boolean + /** Array of tool execution hooks. */ tool?: ToolHook[] diff --git a/tests/config.global.test.ts b/tests/config.global.test.ts new file mode 100644 index 0000000..6d6bc77 --- /dev/null +++ b/tests/config.global.test.ts @@ -0,0 +1,553 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { loadGlobalConfig } from "../src/config/global"; +import { writeFile, rm, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir, homedir } from "os"; + +describe("Global Configuration", () => { + const testProjectDir = join(tmpdir(), "opencode-global-config-test"); + const userConfigDir = join(homedir(), ".config", "opencode"); + const userConfigPath = join(userConfigDir, "command-hooks.jsonc"); + let originalCwd: string; + let createdUserConfig = false; + + beforeEach(async () => { + originalCwd = process.cwd(); + // Create test project directory (without .opencode config) + await mkdir(testProjectDir, { recursive: true }); + // Ensure user config directory exists + await mkdir(userConfigDir, { recursive: true }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + // Clean up test directories + try { + await rm(testProjectDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + // Only clean up user config if we created it + if (createdUserConfig) { + try { + await rm(userConfigPath, { force: true }); + createdUserConfig = false; + } catch { + // Ignore cleanup errors + } + } + }); + + describe("User global config fallback", () => { + it("should load from ~/.config/opencode/command-hooks.jsonc when no project config exists", async () => { + // Create user global config + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "user-global-hook", + when: { phase: "after", tool: ["bash"] }, + run: "echo user global", + }, + ], + session: [], + }) + ); + createdUserConfig = true; + + // Change to project dir without .opencode config + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("user-global-hook"); + }); + + it("should concat hooks from both global and project configs", async () => { + // Create user global config + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "user-hook", + when: { phase: "after" }, + run: "echo user", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with different hook ID + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "project-hook", + when: { phase: "after" }, + run: "echo project", + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Both hooks should be present (concatenation) + expect(result.config.tool).toHaveLength(2); + const hookIds = result.config.tool?.map(h => h.id) ?? []; + expect(hookIds).toContain("user-hook"); + expect(hookIds).toContain("project-hook"); + }); + + it("should let project hook replace global hook with same id", async () => { + // Create user global config + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "shared-hook", + when: { phase: "after" }, + run: "echo global version", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with SAME hook ID + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "shared-hook", + when: { phase: "after" }, + run: "echo project version", + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Only one hook (project replaced global) + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("shared-hook"); + expect(result.config.tool?.[0].run).toBe("echo project version"); + }); + + it("should return empty config when neither project nor user config exists", async () => { + // Ensure no user config exists for this test + try { + await rm(userConfigPath, { force: true }); + } catch { + // Ignore if doesn't exist + } + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + expect(result.config.tool).toEqual([]); + expect(result.config.session).toEqual([]); + }); + + it("should load session hooks from user global config", async () => { + await writeFile( + userConfigPath, + JSON.stringify({ + session: [ + { + id: "user-session-hook", + when: { event: "session.created" }, + run: "echo session started", + inject: "Session initialized", + }, + ], + }) + ); + createdUserConfig = true; + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + expect(result.config.session).toHaveLength(1); + expect(result.config.session?.[0].id).toBe("user-session-hook"); + expect(result.config.session?.[0].when.event).toBe("session.created"); + }); + + it("should handle JSONC comments in user global config", async () => { + await writeFile( + userConfigPath, + `{ + // This is a comment + "tool": [ + { + "id": "commented-hook", + "when": { "phase": "after" }, + "run": "echo with comments" + } + ] + /* Block comment */ + }` + ); + createdUserConfig = true; + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("commented-hook"); + }); + + it("should use project config only when global config has parse errors", async () => { + // Create malformed user global config + await writeFile(userConfigPath, "{ invalid json }"); + createdUserConfig = true; + + // Create valid project config + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "project-hook", + when: { phase: "after" }, + run: "echo project", + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + // Should succeed with project config only (global parse error logged but not returned) + expect(result.error).toBeNull(); + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("project-hook"); + }); + + it("should return error when only global config exists and is malformed", async () => { + await writeFile(userConfigPath, "{ invalid json }"); + createdUserConfig = true; + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + // No project config, so global error is returned + expect(result.error).toBeNull(); // Actually returns empty config since no project + expect(result.config.tool).toEqual([]); + expect(result.config.session).toEqual([]); + }); + }); + + describe("Project config discovery", () => { + it("should find project config in parent directory", async () => { + // Create nested directory structure + const nestedDir = join(testProjectDir, "src", "components"); + await mkdir(nestedDir, { recursive: true }); + + // Create config at project root + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "parent-hook", + when: { phase: "before" }, + run: "echo from parent", + }, + ], + }) + ); + + // Change to nested directory + process.chdir(nestedDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("parent-hook"); + }); + }); + + describe("ignoreGlobalConfig flag", () => { + it("should skip global config when project has ignoreGlobalConfig: true", async () => { + // Create user global config + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "global-hook", + when: { phase: "after" }, + run: "echo global", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with ignoreGlobalConfig + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + ignoreGlobalConfig: true, + tool: [ + { + id: "project-hook", + when: { phase: "after" }, + run: "echo project", + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Only project hook, global was ignored + expect(result.config.tool).toHaveLength(1); + expect(result.config.tool?.[0].id).toBe("project-hook"); + }); + }); + + describe("overrideGlobal flag", () => { + it("should skip global session hooks when project hook has overrideGlobal", async () => { + // Create user global config with session.created hook + await writeFile( + userConfigPath, + JSON.stringify({ + session: [ + { + id: "global-session-hook", + when: { event: "session.created" }, + run: "echo global session", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with overrideGlobal for same event + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + session: [ + { + id: "project-session-hook", + when: { event: "session.created" }, + run: "echo project session", + overrideGlobal: true, + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Only project hook (global was overridden) + expect(result.config.session).toHaveLength(1); + expect(result.config.session?.[0].id).toBe("project-session-hook"); + }); + + it("should not skip global hooks for different events when overrideGlobal is set", async () => { + // Create user global config with multiple session hooks + await writeFile( + userConfigPath, + JSON.stringify({ + session: [ + { + id: "global-created-hook", + when: { event: "session.created" }, + run: "echo global created", + }, + { + id: "global-idle-hook", + when: { event: "session.idle" }, + run: "echo global idle", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with overrideGlobal only for session.created + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + session: [ + { + id: "project-created-hook", + when: { event: "session.created" }, + run: "echo project created", + overrideGlobal: true, + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Should have project's created hook + global's idle hook + expect(result.config.session).toHaveLength(2); + const sessionIds = result.config.session?.map(h => h.id) ?? []; + expect(sessionIds).toContain("project-created-hook"); + expect(sessionIds).toContain("global-idle-hook"); + expect(sessionIds).not.toContain("global-created-hook"); + }); + + it("should skip global tool hooks matching phase+tool when project hook has overrideGlobal", async () => { + // Create user global config + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "global-after-bash", + when: { phase: "after", tool: "bash" }, + run: "echo global after bash", + }, + { + id: "global-after-write", + when: { phase: "after", tool: "write" }, + run: "echo global after write", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with overrideGlobal for after:bash + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "project-after-bash", + when: { phase: "after", tool: "bash" }, + run: "echo project after bash", + overrideGlobal: true, + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Should have project's after:bash + global's after:write + expect(result.config.tool).toHaveLength(2); + const toolIds = result.config.tool?.map(h => h.id) ?? []; + expect(toolIds).toContain("project-after-bash"); + expect(toolIds).toContain("global-after-write"); + expect(toolIds).not.toContain("global-after-bash"); + }); + + it("should skip ALL global tool hooks for phase when project uses overrideGlobal with tool: '*'", async () => { + // Create user global config with multiple after hooks + await writeFile( + userConfigPath, + JSON.stringify({ + tool: [ + { + id: "global-after-bash", + when: { phase: "after", tool: "bash" }, + run: "echo global after bash", + }, + { + id: "global-after-write", + when: { phase: "after", tool: "write" }, + run: "echo global after write", + }, + { + id: "global-before-bash", + when: { phase: "before", tool: "bash" }, + run: "echo global before bash", + }, + ], + }) + ); + createdUserConfig = true; + + // Create project config with overrideGlobal for after:* (wildcard) + const projectConfigDir = join(testProjectDir, ".opencode"); + await mkdir(projectConfigDir, { recursive: true }); + await writeFile( + join(projectConfigDir, "command-hooks.jsonc"), + JSON.stringify({ + tool: [ + { + id: "project-after-all", + when: { phase: "after", tool: "*" }, + run: "echo project after all", + overrideGlobal: true, + }, + ], + }) + ); + + process.chdir(testProjectDir); + + const result = await loadGlobalConfig(); + + expect(result.error).toBeNull(); + // Should have project's after:* + global's before:bash (before phase not affected) + expect(result.config.tool).toHaveLength(2); + const toolIds = result.config.tool?.map(h => h.id) ?? []; + expect(toolIds).toContain("project-after-all"); + expect(toolIds).toContain("global-before-bash"); + // Both global after hooks should be gone + expect(toolIds).not.toContain("global-after-bash"); + expect(toolIds).not.toContain("global-after-write"); + }); + }); +});