Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 66 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
],
Expand All @@ -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

Expand All @@ -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

---

Expand Down
219 changes: 149 additions & 70 deletions src/config/global.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string | null> => {
const findProjectConfigFile = async (startDir: string): Promise<string | null> => {
let currentDir = startDir;

// Limit search depth to avoid infinite loops
Expand All @@ -123,7 +137,7 @@ const findConfigFile = async (startDir: string): Promise<string | null> => {
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 {
Expand All @@ -141,99 +155,164 @@ const findConfigFile = async (startDir: string): Promise<string | null> => {
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<GlobalConfigResult> => {
// 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<GlobalConfigResult> => {
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}`,
};
}
Expand Down
Loading