opencode-command-hooks 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -17
- package/dist/config/agent.d.ts +13 -6
- package/dist/config/agent.d.ts.map +1 -1
- package/dist/config/agent.js +29 -29
- package/dist/config/agent.js.map +1 -1
- package/dist/config/global.d.ts +20 -8
- package/dist/config/global.d.ts.map +1 -1
- package/dist/config/global.js +189 -71
- package/dist/config/global.js.map +1 -1
- package/dist/config/markdown.d.ts.map +1 -1
- package/dist/config/markdown.js +36 -15
- package/dist/config/markdown.js.map +1 -1
- package/dist/config/merge.d.ts.map +1 -1
- package/dist/config/merge.js +87 -34
- package/dist/config/merge.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +15 -22
- package/dist/executor.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +245 -20
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +27 -4
- package/dist/schemas.js.map +1 -1
- package/dist/types/hooks.d.ts +17 -6
- package/dist/types/hooks.d.ts.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -75,11 +75,12 @@ hooks:
|
|
|
75
75
|
|
|
76
76
|
### Hook Configuration Options
|
|
77
77
|
|
|
78
|
-
| Option
|
|
79
|
-
|
|
|
80
|
-
| `run`
|
|
81
|
-
| `inject`
|
|
82
|
-
| `toast`
|
|
78
|
+
| Option | Type | Description |
|
|
79
|
+
| ---------------- | ---------------------- | ------------------------------------------------------------------------ |
|
|
80
|
+
| `run` | `string` \| `string[]` | Command(s) to execute |
|
|
81
|
+
| `inject` | `string` | Message injected into the session |
|
|
82
|
+
| `toast` | `object` | Toast notification configuration |
|
|
83
|
+
| `overrideGlobal` | `boolean` | When `true`, suppresses global hooks matching the same event/phase+tool. Must be a JSON boolean (`true`/`false`), not a string. |
|
|
83
84
|
|
|
84
85
|
### Toast Configuration
|
|
85
86
|
|
|
@@ -173,13 +174,21 @@ Add to your `opencode.json`:
|
|
|
173
174
|
|
|
174
175
|
## Configuration
|
|
175
176
|
|
|
176
|
-
###
|
|
177
|
+
### Config Locations
|
|
178
|
+
|
|
179
|
+
The plugin loads hooks from two locations:
|
|
177
180
|
|
|
178
|
-
|
|
181
|
+
1. **User global**: `~/.config/opencode/command-hooks.jsonc` — hooks that apply to all projects
|
|
182
|
+
2. **Project**: `.opencode/command-hooks.jsonc` — project-specific hooks (searches upward from cwd)
|
|
183
|
+
|
|
184
|
+
Both are merged by default. See [Configuration Precedence](#configuration-precedence) for details.
|
|
185
|
+
|
|
186
|
+
### JSON Config
|
|
179
187
|
|
|
180
188
|
```jsonc
|
|
181
189
|
{
|
|
182
190
|
"truncationLimit": 30000,
|
|
191
|
+
"ignoreGlobalConfig": false,
|
|
183
192
|
"tool": [
|
|
184
193
|
// Tool hooks
|
|
185
194
|
],
|
|
@@ -191,11 +200,12 @@ Create `.opencode/command-hooks.jsonc` in your project (the plugin searches upwa
|
|
|
191
200
|
|
|
192
201
|
#### JSON Config Options
|
|
193
202
|
|
|
194
|
-
| Option
|
|
195
|
-
|
|
|
196
|
-
| `truncationLimit`
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
203
|
+
| Option | Type | Description |
|
|
204
|
+
| ------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
205
|
+
| `truncationLimit` | `number` | Maximum characters to capture from command output. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. Project config overrides global when both are set. |
|
|
206
|
+
| `ignoreGlobalConfig`| `boolean` | When `true`, skip loading `~/.config/opencode/command-hooks.jsonc`. Defaults to `false`. Must be a JSON boolean (`true`/`false`), not a string. |
|
|
207
|
+
| `tool` | `ToolHook[]` | Array of tool execution hooks |
|
|
208
|
+
| `session` | `SessionHook[]` | Array of session lifecycle hooks |
|
|
199
209
|
|
|
200
210
|
### Markdown Frontmatter
|
|
201
211
|
|
|
@@ -216,11 +226,53 @@ hooks:
|
|
|
216
226
|
|
|
217
227
|
### Configuration Precedence
|
|
218
228
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
229
|
+
Hooks are loaded from two locations and merged:
|
|
230
|
+
|
|
231
|
+
1. **User global config**: `~/.config/opencode/command-hooks.jsonc`
|
|
232
|
+
2. **Project config**: `.opencode/command-hooks.jsonc` (searches upward from cwd)
|
|
233
|
+
|
|
234
|
+
**Merge behavior:**
|
|
235
|
+
|
|
236
|
+
| Scenario | Result |
|
|
237
|
+
|----------|--------|
|
|
238
|
+
| Different hook IDs | Both run (concatenation) |
|
|
239
|
+
| Same hook ID | Project replaces global |
|
|
240
|
+
| `overrideGlobal: true` on hook | Suppresses all global hooks for same event/phase+tool |
|
|
241
|
+
| `ignoreGlobalConfig: true` in project | Skips global config entirely |
|
|
242
|
+
| Both set `truncationLimit` | Project value wins |
|
|
243
|
+
|
|
244
|
+
**Example: Override all global hooks for an event**
|
|
245
|
+
|
|
246
|
+
```jsonc
|
|
247
|
+
{
|
|
248
|
+
"session": [
|
|
249
|
+
{
|
|
250
|
+
"id": "my-session-idle",
|
|
251
|
+
"when": { "event": "session.idle" },
|
|
252
|
+
"run": "echo only this runs",
|
|
253
|
+
"overrideGlobal": true
|
|
254
|
+
}
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Example: Ignore global config entirely**
|
|
260
|
+
|
|
261
|
+
```jsonc
|
|
262
|
+
{
|
|
263
|
+
"ignoreGlobalConfig": true,
|
|
264
|
+
"tool": [
|
|
265
|
+
// Only these hooks will run
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Additional precedence rules:
|
|
271
|
+
- Markdown hooks are converted to normal hooks with auto-generated IDs
|
|
272
|
+
- If a markdown hook and a config hook share the same `id`, the markdown hook wins
|
|
273
|
+
- Duplicate IDs within the same source are errors
|
|
274
|
+
- Tool override matching uses canonical keys, so `"bash"` and `["bash"]` are treated as equivalent
|
|
275
|
+
- Config files are schema-validated; invalid value types (for example `"false"` for a boolean field) make that source invalid
|
|
224
276
|
|
|
225
277
|
---
|
|
226
278
|
|
package/dist/config/agent.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent configuration resolution and loading for command hooks
|
|
3
3
|
*
|
|
4
|
-
* Handles finding and parsing agent markdown files (.opencode/
|
|
5
|
-
* ~/.config/opencode/
|
|
4
|
+
* Handles finding and parsing agent markdown files (.opencode/agents/*.md or
|
|
5
|
+
* ~/.config/opencode/agents/*.md) to extract command_hooks from YAML frontmatter.
|
|
6
|
+
*
|
|
7
|
+
* Backward compatibility: legacy singular directories (.opencode/agent and
|
|
8
|
+
* ~/.config/opencode/agent) are also supported as fallbacks.
|
|
6
9
|
* These hooks are applied when the specific subagent is invoked via the task tool.
|
|
7
10
|
*/
|
|
8
11
|
import type { CommandHooksConfig } from "../types/hooks.js";
|
|
@@ -10,8 +13,10 @@ import type { CommandHooksConfig } from "../types/hooks.js";
|
|
|
10
13
|
* Resolve agent markdown file path by agent name
|
|
11
14
|
*
|
|
12
15
|
* Searches for agent markdown files in the following order:
|
|
13
|
-
* 1. Project-level: .opencode/
|
|
14
|
-
* 2.
|
|
16
|
+
* 1. Project-level: .opencode/agents/{name}.md
|
|
17
|
+
* 2. Project-level legacy fallback: .opencode/agent/{name}.md
|
|
18
|
+
* 3. User-level: ~/.config/opencode/agents/{name}.md
|
|
19
|
+
* 4. User-level legacy fallback: ~/.config/opencode/agent/{name}.md
|
|
15
20
|
*
|
|
16
21
|
* Returns the first existing path found, or null if no file exists.
|
|
17
22
|
*
|
|
@@ -21,8 +26,10 @@ import type { CommandHooksConfig } from "../types/hooks.js";
|
|
|
21
26
|
* @example
|
|
22
27
|
* ```typescript
|
|
23
28
|
* const path = await resolveAgentPath("engineer");
|
|
24
|
-
* // Returns: "/Users/example/project/.opencode/
|
|
25
|
-
* // Or: "/Users/example/.
|
|
29
|
+
* // Returns: "/Users/example/project/.opencode/agents/engineer.md" (if exists)
|
|
30
|
+
* // Or: "/Users/example/project/.opencode/agent/engineer.md" (legacy fallback)
|
|
31
|
+
* // Or: "/Users/example/.config/opencode/agents/engineer.md" (if project paths don't exist)
|
|
32
|
+
* // Or: "/Users/example/.config/opencode/agent/engineer.md" (legacy fallback)
|
|
26
33
|
* // Or: null (if neither exists)
|
|
27
34
|
* ```
|
|
28
35
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/config/agent.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/config/agent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAM5D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BhF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAqBpF"}
|
package/dist/config/agent.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent configuration resolution and loading for command hooks
|
|
3
3
|
*
|
|
4
|
-
* Handles finding and parsing agent markdown files (.opencode/
|
|
5
|
-
* ~/.config/opencode/
|
|
4
|
+
* Handles finding and parsing agent markdown files (.opencode/agents/*.md or
|
|
5
|
+
* ~/.config/opencode/agents/*.md) to extract command_hooks from YAML frontmatter.
|
|
6
|
+
*
|
|
7
|
+
* Backward compatibility: legacy singular directories (.opencode/agent and
|
|
8
|
+
* ~/.config/opencode/agent) are also supported as fallbacks.
|
|
6
9
|
* These hooks are applied when the specific subagent is invoked via the task tool.
|
|
7
10
|
*/
|
|
8
11
|
import { join } from "path";
|
|
@@ -13,8 +16,10 @@ import { logger } from "../logging.js";
|
|
|
13
16
|
* Resolve agent markdown file path by agent name
|
|
14
17
|
*
|
|
15
18
|
* Searches for agent markdown files in the following order:
|
|
16
|
-
* 1. Project-level: .opencode/
|
|
17
|
-
* 2.
|
|
19
|
+
* 1. Project-level: .opencode/agents/{name}.md
|
|
20
|
+
* 2. Project-level legacy fallback: .opencode/agent/{name}.md
|
|
21
|
+
* 3. User-level: ~/.config/opencode/agents/{name}.md
|
|
22
|
+
* 4. User-level legacy fallback: ~/.config/opencode/agent/{name}.md
|
|
18
23
|
*
|
|
19
24
|
* Returns the first existing path found, or null if no file exists.
|
|
20
25
|
*
|
|
@@ -24,8 +29,10 @@ import { logger } from "../logging.js";
|
|
|
24
29
|
* @example
|
|
25
30
|
* ```typescript
|
|
26
31
|
* const path = await resolveAgentPath("engineer");
|
|
27
|
-
* // Returns: "/Users/example/project/.opencode/
|
|
28
|
-
* // Or: "/Users/example/.
|
|
32
|
+
* // Returns: "/Users/example/project/.opencode/agents/engineer.md" (if exists)
|
|
33
|
+
* // Or: "/Users/example/project/.opencode/agent/engineer.md" (legacy fallback)
|
|
34
|
+
* // Or: "/Users/example/.config/opencode/agents/engineer.md" (if project paths don't exist)
|
|
35
|
+
* // Or: "/Users/example/.config/opencode/agent/engineer.md" (legacy fallback)
|
|
29
36
|
* // Or: null (if neither exists)
|
|
30
37
|
* ```
|
|
31
38
|
*/
|
|
@@ -36,32 +43,25 @@ export async function resolveAgentPath(agentName) {
|
|
|
36
43
|
return null;
|
|
37
44
|
}
|
|
38
45
|
const agentFileName = `${agentName}.md`;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const candidatePaths = [
|
|
47
|
+
join(process.cwd(), ".opencode", "agents", agentFileName),
|
|
48
|
+
join(process.cwd(), ".opencode", "agent", agentFileName),
|
|
49
|
+
join(homedir(), ".config", "opencode", "agents", agentFileName),
|
|
50
|
+
join(homedir(), ".config", "opencode", "agent", agentFileName),
|
|
51
|
+
];
|
|
52
|
+
for (const candidatePath of candidatePaths) {
|
|
53
|
+
try {
|
|
54
|
+
const file = Bun.file(candidatePath);
|
|
55
|
+
if (await file.exists()) {
|
|
56
|
+
logger.debug(`Found agent file: ${candidatePath}`);
|
|
57
|
+
return candidatePath;
|
|
58
|
+
}
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
logger.debug(`Error checking project agent file ${projectAgentPath}: ${message}`);
|
|
51
|
-
}
|
|
52
|
-
// Check user-level agent file
|
|
53
|
-
const userAgentPath = join(homedir(), ".config", "opencode", "agent", agentFileName);
|
|
54
|
-
try {
|
|
55
|
-
const userFile = Bun.file(userAgentPath);
|
|
56
|
-
if (await userFile.exists()) {
|
|
57
|
-
logger.debug(`Found user agent file: ${userAgentPath}`);
|
|
58
|
-
return userAgentPath;
|
|
60
|
+
catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
logger.debug(`Error checking agent file ${candidatePath}: ${message}`);
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
-
logger.debug(`Error checking user agent file ${userAgentPath}: ${message}`);
|
|
64
|
-
}
|
|
65
65
|
logger.debug(`No agent file found for: ${agentName}`);
|
|
66
66
|
return null;
|
|
67
67
|
}
|
package/dist/config/agent.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.js","sourceRoot":"","sources":["../../src/config/agent.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"agent.js","sourceRoot":"","sources":["../../src/config/agent.ts"],"names":[],"mappings":"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"}
|
package/dist/config/global.d.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global configuration parser for loading hooks from .opencode/command-hooks.jsonc
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Loads both user global config (~/.config/opencode/command-hooks.jsonc) and
|
|
5
|
+
* project config (.opencode/command-hooks.jsonc), merging them with project taking precedence.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - `ignoreGlobalConfig: true` in project config to skip user global entirely
|
|
9
|
+
* - `overrideGlobal: true` on individual hooks to suppress matching global hooks
|
|
6
10
|
*/
|
|
7
11
|
import type { CommandHooksConfig } from "../types/hooks.js";
|
|
8
12
|
export type GlobalConfigResult = {
|
|
9
13
|
config: CommandHooksConfig;
|
|
10
14
|
error: string | null;
|
|
11
15
|
};
|
|
16
|
+
export declare const clearGlobalConfigCacheForTests: () => void;
|
|
12
17
|
/**
|
|
13
|
-
* Load and
|
|
18
|
+
* Load and merge command hooks configuration from both sources
|
|
19
|
+
*
|
|
20
|
+
* Loads both user global config (~/.config/opencode/command-hooks.jsonc) and
|
|
21
|
+
* project config (.opencode/command-hooks.jsonc), then merges them.
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
23
|
+
* Merge behavior:
|
|
24
|
+
* - If project config has `ignoreGlobalConfig: true`, skip user global entirely
|
|
25
|
+
* - Otherwise, merge with project hooks taking precedence:
|
|
26
|
+
* - Same hook `id` → project version wins
|
|
27
|
+
* - Hook with `overrideGlobal: true` → suppresses matching global hooks
|
|
28
|
+
* - Different `id` without override → both run (concatenation)
|
|
17
29
|
*
|
|
18
30
|
* Error handling:
|
|
19
|
-
* - If no config
|
|
20
|
-
* - If
|
|
21
|
-
* - If
|
|
31
|
+
* - If no config files found: returns empty config (not an error)
|
|
32
|
+
* - If user global has parse error: warns and uses project config only
|
|
33
|
+
* - If project has parse error: returns error
|
|
22
34
|
* - Never throws errors - always returns a valid config
|
|
23
35
|
*
|
|
24
36
|
* @returns Promise resolving to GlobalConfigResult
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"global.d.ts","sourceRoot":"","sources":["../../src/config/global.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"global.d.ts","sourceRoot":"","sources":["../../src/config/global.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAkB5D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AA0KF,eAAO,MAAM,8BAA8B,QAAO,IAGjD,CAAA;AAqGD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,gBAAgB,QAAa,OAAO,CAAC,kBAAkB,CAkEnE,CAAA"}
|
package/dist/config/global.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global configuration parser for loading hooks from .opencode/command-hooks.jsonc
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Loads both user global config (~/.config/opencode/command-hooks.jsonc) and
|
|
5
|
+
* project config (.opencode/command-hooks.jsonc), merging them with project taking precedence.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - `ignoreGlobalConfig: true` in project config to skip user global entirely
|
|
9
|
+
* - `overrideGlobal: true` on individual hooks to suppress matching global hooks
|
|
6
10
|
*/
|
|
7
|
-
import {
|
|
11
|
+
import { ConfigSchema } from "../schemas.js";
|
|
12
|
+
import { mergeConfigs } from "./merge.js";
|
|
8
13
|
import { join, dirname } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { stat } from "fs/promises";
|
|
9
16
|
import { logger } from "../logging.js";
|
|
17
|
+
/**
|
|
18
|
+
* Get the user's global config directory path
|
|
19
|
+
* Uses ~/.config/opencode/ following XDG convention
|
|
20
|
+
*/
|
|
21
|
+
const getUserConfigPath = () => {
|
|
22
|
+
const envHome = process.env.HOME || process.env.USERPROFILE;
|
|
23
|
+
const baseHome = envHome && envHome.length > 0 ? envHome : homedir();
|
|
24
|
+
return join(baseHome, ".config", "opencode", "command-hooks.jsonc");
|
|
25
|
+
};
|
|
26
|
+
const CONFIG_CACHE_TTL_MS = 250;
|
|
27
|
+
const projectConfigPathCache = new Map();
|
|
28
|
+
const configBlobCache = new Map();
|
|
10
29
|
/**
|
|
11
30
|
* Strip comments from JSONC content
|
|
12
31
|
* Handles both line comments and block comments
|
|
@@ -90,27 +109,45 @@ const parseJson = (content) => {
|
|
|
90
109
|
throw new Error(`Failed to parse JSON: ${message}`);
|
|
91
110
|
}
|
|
92
111
|
};
|
|
112
|
+
const getFileMtimeMs = async (path) => {
|
|
113
|
+
try {
|
|
114
|
+
const stats = await stat(path);
|
|
115
|
+
if (!stats.isFile()) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return stats.mtimeMs;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
93
124
|
/**
|
|
94
|
-
* Find
|
|
95
|
-
* Looks for .opencode/command-hooks.jsonc
|
|
125
|
+
* Find project config file by walking up directory tree
|
|
126
|
+
* Looks for .opencode/command-hooks.jsonc in project directories
|
|
96
127
|
*/
|
|
97
|
-
const
|
|
128
|
+
const findProjectConfigFile = async (startDir) => {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const cached = projectConfigPathCache.get(startDir);
|
|
131
|
+
if (cached && now - cached.cachedAt < CONFIG_CACHE_TTL_MS) {
|
|
132
|
+
if (cached.path === null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const cachedPathStillExists = (await getFileMtimeMs(cached.path)) !== null;
|
|
136
|
+
if (cachedPathStillExists) {
|
|
137
|
+
return cached.path;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
98
140
|
let currentDir = startDir;
|
|
99
141
|
// Limit search depth to avoid infinite loops
|
|
100
142
|
const maxDepth = 20;
|
|
101
143
|
let depth = 0;
|
|
102
144
|
while (depth < maxDepth) {
|
|
103
|
-
// Try .opencode/command-hooks.jsonc
|
|
104
145
|
const configPath = join(currentDir, ".opencode", "command-hooks.jsonc");
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// Continue searching
|
|
146
|
+
const mtimeMs = await getFileMtimeMs(configPath);
|
|
147
|
+
if (mtimeMs !== null) {
|
|
148
|
+
logger.debug(`Found project config file: ${configPath}`);
|
|
149
|
+
projectConfigPathCache.set(startDir, { path: configPath, cachedAt: now });
|
|
150
|
+
return configPath;
|
|
114
151
|
}
|
|
115
152
|
// Move up one directory
|
|
116
153
|
const parentDir = dirname(currentDir);
|
|
@@ -121,84 +158,165 @@ const findConfigFile = async (startDir) => {
|
|
|
121
158
|
currentDir = parentDir;
|
|
122
159
|
depth++;
|
|
123
160
|
}
|
|
124
|
-
logger.debug(`No config file found after searching ${depth} directories`);
|
|
161
|
+
logger.debug(`No project config file found after searching ${depth} directories`);
|
|
162
|
+
projectConfigPathCache.set(startDir, { path: null, cachedAt: now });
|
|
125
163
|
return null;
|
|
126
164
|
};
|
|
165
|
+
const emptyConfig = () => ({ tool: [], session: [] });
|
|
166
|
+
export const clearGlobalConfigCacheForTests = () => {
|
|
167
|
+
projectConfigPathCache.clear();
|
|
168
|
+
configBlobCache.clear();
|
|
169
|
+
};
|
|
127
170
|
/**
|
|
128
|
-
* Load and parse
|
|
171
|
+
* Load and parse config from a specific file path
|
|
129
172
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
173
|
+
* @param configPath - Path to the config file
|
|
174
|
+
* @param source - Source identifier for logging ("project" or "user global")
|
|
175
|
+
* @returns GlobalConfigResult with parsed config or error
|
|
176
|
+
*/
|
|
177
|
+
const loadConfigFromPath = async (configPath, source) => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const mtimeMs = await getFileMtimeMs(configPath);
|
|
180
|
+
const cached = configBlobCache.get(configPath);
|
|
181
|
+
if (cached) {
|
|
182
|
+
if (mtimeMs === null && cached.mtimeMs === null && now - cached.cachedAt < CONFIG_CACHE_TTL_MS) {
|
|
183
|
+
return { config: cached.config, error: cached.error };
|
|
184
|
+
}
|
|
185
|
+
if (mtimeMs !== null && cached.mtimeMs === mtimeMs) {
|
|
186
|
+
return { config: cached.config, error: cached.error };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (mtimeMs === null) {
|
|
190
|
+
const missingResult = { config: emptyConfig(), error: null };
|
|
191
|
+
configBlobCache.set(configPath, { ...missingResult, mtimeMs: null, cachedAt: now });
|
|
192
|
+
return missingResult;
|
|
193
|
+
}
|
|
194
|
+
// Read file
|
|
195
|
+
let content;
|
|
196
|
+
try {
|
|
197
|
+
content = await Bun.file(configPath).text();
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
logger.info(`Failed to read ${source} config file ${configPath}: ${message}`);
|
|
202
|
+
const readErrorResult = {
|
|
203
|
+
config: emptyConfig(),
|
|
204
|
+
error: `Failed to read ${source} config file ${configPath}: ${message}`,
|
|
205
|
+
};
|
|
206
|
+
configBlobCache.set(configPath, { ...readErrorResult, mtimeMs, cachedAt: now });
|
|
207
|
+
return readErrorResult;
|
|
208
|
+
}
|
|
209
|
+
// Parse JSONC
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
const stripped = stripJsoncComments(content);
|
|
213
|
+
parsed = parseJson(stripped);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
217
|
+
logger.info(`Failed to parse ${source} config file ${configPath}: ${message}`);
|
|
218
|
+
const parseErrorResult = {
|
|
219
|
+
config: emptyConfig(),
|
|
220
|
+
error: `Failed to parse ${source} config file ${configPath}: ${message}`,
|
|
221
|
+
};
|
|
222
|
+
configBlobCache.set(configPath, { ...parseErrorResult, mtimeMs, cachedAt: now });
|
|
223
|
+
return parseErrorResult;
|
|
224
|
+
}
|
|
225
|
+
// Validate and parse with the full schema
|
|
226
|
+
const parseResult = ConfigSchema.safeParse(parsed);
|
|
227
|
+
if (!parseResult.success) {
|
|
228
|
+
const issueSummary = parseResult.error.issues
|
|
229
|
+
.map(issue => {
|
|
230
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
231
|
+
return `${path}: ${issue.message}`;
|
|
232
|
+
})
|
|
233
|
+
.join("; ");
|
|
234
|
+
logger.info(`${source} config file failed schema validation, using empty config: ${issueSummary}`);
|
|
235
|
+
const validationErrorResult = {
|
|
236
|
+
config: emptyConfig(),
|
|
237
|
+
error: `${source} config file failed schema validation`,
|
|
238
|
+
};
|
|
239
|
+
configBlobCache.set(configPath, { ...validationErrorResult, mtimeMs, cachedAt: now });
|
|
240
|
+
return validationErrorResult;
|
|
241
|
+
}
|
|
242
|
+
// Return with defaults for missing arrays
|
|
243
|
+
const result = {
|
|
244
|
+
truncationLimit: parseResult.data.truncationLimit,
|
|
245
|
+
ignoreGlobalConfig: parseResult.data.ignoreGlobalConfig,
|
|
246
|
+
tool: parseResult.data.tool ?? [],
|
|
247
|
+
session: parseResult.data.session ?? [],
|
|
248
|
+
};
|
|
249
|
+
logger.debug(`Loaded ${source} config: truncationLimit=${result.truncationLimit}, ${result.tool?.length ?? 0} tool hooks, ${result.session?.length ?? 0} session hooks`);
|
|
250
|
+
const successResult = { config: result, error: null };
|
|
251
|
+
configBlobCache.set(configPath, { ...successResult, mtimeMs, cachedAt: now });
|
|
252
|
+
return successResult;
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Load and merge command hooks configuration from both sources
|
|
256
|
+
*
|
|
257
|
+
* Loads both user global config (~/.config/opencode/command-hooks.jsonc) and
|
|
258
|
+
* project config (.opencode/command-hooks.jsonc), then merges them.
|
|
259
|
+
*
|
|
260
|
+
* Merge behavior:
|
|
261
|
+
* - If project config has `ignoreGlobalConfig: true`, skip user global entirely
|
|
262
|
+
* - Otherwise, merge with project hooks taking precedence:
|
|
263
|
+
* - Same hook `id` → project version wins
|
|
264
|
+
* - Hook with `overrideGlobal: true` → suppresses matching global hooks
|
|
265
|
+
* - Different `id` without override → both run (concatenation)
|
|
132
266
|
*
|
|
133
267
|
* Error handling:
|
|
134
|
-
* - If no config
|
|
135
|
-
* - If
|
|
136
|
-
* - If
|
|
268
|
+
* - If no config files found: returns empty config (not an error)
|
|
269
|
+
* - If user global has parse error: warns and uses project config only
|
|
270
|
+
* - If project has parse error: returns error
|
|
137
271
|
* - Never throws errors - always returns a valid config
|
|
138
272
|
*
|
|
139
273
|
* @returns Promise resolving to GlobalConfigResult
|
|
140
274
|
*/
|
|
141
275
|
export const loadGlobalConfig = async () => {
|
|
142
|
-
let configPath = null;
|
|
143
276
|
try {
|
|
144
|
-
// Find config file
|
|
145
277
|
logger.debug(`loadGlobalConfig: starting search from: ${process.cwd()}`);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const file = Bun.file(configPath);
|
|
155
|
-
content = await file.text();
|
|
278
|
+
// Step 1: Load project config first (to check ignoreGlobalConfig flag)
|
|
279
|
+
const projectConfigPath = await findProjectConfigFile(process.cwd());
|
|
280
|
+
const projectResult = projectConfigPath
|
|
281
|
+
? await loadConfigFromPath(projectConfigPath, "project")
|
|
282
|
+
: { config: emptyConfig(), error: null };
|
|
283
|
+
// If project config had an error, return it
|
|
284
|
+
if (projectResult.error) {
|
|
285
|
+
return projectResult;
|
|
156
286
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
logger.
|
|
160
|
-
return
|
|
161
|
-
config: { tool: [], session: [] },
|
|
162
|
-
error: `Failed to read config file ${configPath}: ${message}`,
|
|
163
|
-
};
|
|
287
|
+
// Step 2: If project says ignore global, return project only
|
|
288
|
+
if (projectResult.config.ignoreGlobalConfig) {
|
|
289
|
+
logger.debug("Project config has ignoreGlobalConfig: true, skipping user global");
|
|
290
|
+
return projectResult;
|
|
164
291
|
}
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
292
|
+
// Step 3: Load user global config
|
|
293
|
+
const userGlobalPath = getUserConfigPath();
|
|
294
|
+
const userGlobalResult = await loadConfigFromPath(userGlobalPath, "user global");
|
|
295
|
+
// Step 4: If user global had parse error, log and use project only
|
|
296
|
+
if (userGlobalResult.error) {
|
|
297
|
+
logger.info(`Failed to load user global config (${userGlobalPath}): ${userGlobalResult.error}. Using project config only.`);
|
|
298
|
+
return projectResult;
|
|
170
299
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
300
|
+
// Step 5: If neither config has hooks, return empty
|
|
301
|
+
const hasUserGlobalHooks = (userGlobalResult.config.tool?.length ?? 0) > 0 ||
|
|
302
|
+
(userGlobalResult.config.session?.length ?? 0) > 0;
|
|
303
|
+
const hasProjectHooks = (projectResult.config.tool?.length ?? 0) > 0 ||
|
|
304
|
+
(projectResult.config.session?.length ?? 0) > 0;
|
|
305
|
+
if (!hasUserGlobalHooks && !hasProjectHooks) {
|
|
306
|
+
logger.debug("No hooks found in either config, using empty config");
|
|
307
|
+
return { config: emptyConfig(), error: null };
|
|
178
308
|
}
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
config: { tool: [], session: [] },
|
|
184
|
-
error: "Config file is not a valid CommandHooksConfig (expected { tool?: [], session?: [] })",
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
// Return with defaults for missing arrays
|
|
188
|
-
const result = {
|
|
189
|
-
truncationLimit: parsed.truncationLimit,
|
|
190
|
-
tool: parsed.tool ?? [],
|
|
191
|
-
session: parsed.session ?? [],
|
|
192
|
-
};
|
|
193
|
-
logger.debug(`Loaded global config: truncationLimit=${result.truncationLimit}, ${result.tool?.length ?? 0} tool hooks, ${result.session?.length ?? 0} session hooks`);
|
|
194
|
-
return { config: result, error: null };
|
|
309
|
+
// Step 6: Merge configs - user global as base, project as override
|
|
310
|
+
const { config: mergedConfig } = mergeConfigs(userGlobalResult.config, projectResult.config);
|
|
311
|
+
logger.debug(`Merged configs: ${mergedConfig.tool?.length ?? 0} tool hooks, ${mergedConfig.session?.length ?? 0} session hooks`);
|
|
312
|
+
return { config: mergedConfig, error: null };
|
|
195
313
|
}
|
|
196
314
|
catch (error) {
|
|
197
315
|
// Catch-all for unexpected errors
|
|
198
316
|
const message = error instanceof Error ? error.message : String(error);
|
|
199
317
|
logger.info(`Unexpected error loading global config: ${message}`);
|
|
200
318
|
return {
|
|
201
|
-
config:
|
|
319
|
+
config: emptyConfig(),
|
|
202
320
|
error: `Unexpected error loading global config: ${message}`,
|
|
203
321
|
};
|
|
204
322
|
}
|