agent-switchboard 0.4.16 → 0.4.18
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 +11 -5
- package/dist/hooks/codex-distribute.d.ts +2 -2
- package/dist/hooks/codex-distribute.js +339 -75
- package/dist/hooks/codex-distribute.js.map +1 -1
- package/dist/hooks/distribution.d.ts +5 -3
- package/dist/hooks/distribution.js +100 -12
- package/dist/hooks/distribution.js.map +1 -1
- package/dist/library/distribute-bundle.d.ts +6 -0
- package/dist/library/distribute-bundle.js +258 -65
- package/dist/library/distribute-bundle.js.map +1 -1
- package/dist/targets/builtin/codex.js +7 -1
- package/dist/targets/builtin/codex.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,9 +38,10 @@ Library entries are agent-agnostic Markdown files (or directories for skills, JS
|
|
|
38
38
|
| Commands | ✓ | ✓\* | ✓ | ✓ | ✓ | | |
|
|
39
39
|
| Agents | ✓ | ✓ | ✓ | | ✓ | | |
|
|
40
40
|
| Skills | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
|
|
41
|
-
| Hooks | ✓ |
|
|
41
|
+
| Hooks | ✓ | ✓† | | | | | |
|
|
42
42
|
|
|
43
|
-
\* Codex commands use deprecated `~/.codex/prompts/`; prefer skills instead.
|
|
43
|
+
\* Codex commands use deprecated `~/.codex/prompts/`; prefer skills instead.
|
|
44
|
+
† ASB currently distributes Codex hooks as command handlers. It writes `~/.codex/hooks.json` or `<project>/.codex/hooks.json`, filters unsupported hook entries, and reports config, trust, and review prerequisites. Trae column applies to both `trae` and `trae-cn` variants.
|
|
44
45
|
|
|
45
46
|
Cursor rules are composed into a single `asb-rules.mdc` file at `~/.cursor/rules/` with `alwaysApply: true`.
|
|
46
47
|
|
|
@@ -93,7 +94,7 @@ Library content lives under `~/.agent-switchboard/` and agent configs are update
|
|
|
93
94
|
| `asb command` | Interactive command selector |
|
|
94
95
|
| `asb agent` | Interactive agent selector |
|
|
95
96
|
| `asb skill` | Interactive skill selector |
|
|
96
|
-
| `asb hook` | Interactive hook selector (Claude Code
|
|
97
|
+
| `asb hook` | Interactive hook selector (Claude Code and Codex) |
|
|
97
98
|
| `asb sync` | Push all libraries + MCP to applications (no UI) |
|
|
98
99
|
| `asb <lib> load` | Import files from a platform into the library |
|
|
99
100
|
| `asb <lib> list` | Show inventory, enabled state, and sync timestamps |
|
|
@@ -316,7 +317,7 @@ Entire directories are copied to each agent's skill location. Deactivated skills
|
|
|
316
317
|
|
|
317
318
|
### Hooks
|
|
318
319
|
|
|
319
|
-
JSON-based hook definitions distributed to Claude Code's `settings.json`. Two storage formats:
|
|
320
|
+
JSON-based hook definitions distributed to Claude Code's `settings.json` and Codex's `hooks.json`. Two storage formats:
|
|
320
321
|
|
|
321
322
|
- **Single file**: `~/.agent-switchboard/hooks/<id>.json`
|
|
322
323
|
- **Bundle**: `~/.agent-switchboard/hooks/<id>/hook.json` plus script files
|
|
@@ -327,7 +328,12 @@ asb hook load /path/to/hook.json # import a JSON file
|
|
|
327
328
|
asb hook load /path/to/hook-dir/ # import a bundle directory
|
|
328
329
|
```
|
|
329
330
|
|
|
330
|
-
Bundle scripts are copied to
|
|
331
|
+
Bundle scripts are copied to the target agent's ASB hook bundle directory and the `${HOOK_DIR}` placeholder in commands is resolved to the absolute path at distribution time:
|
|
332
|
+
|
|
333
|
+
- Claude Code: `~/.claude/hooks/asb/<id>/`
|
|
334
|
+
- Codex: `~/.codex/hooks/asb/<id>/` or `<project>/.codex/hooks/asb/<id>/`
|
|
335
|
+
|
|
336
|
+
Codex hook sync writes `~/.codex/hooks.json` for global scope or `<project>/.codex/hooks.json` for project scope. ASB emits command handlers for `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SessionStart`, `UserPromptSubmit`, and `Stop`; unsupported events and non-command handler types are filtered from Codex output and reported in sync results. Codex uses `[features].hooks` in `~/.codex/config.toml` (enabled by default when absent; legacy `[features].codex_hooks` is accepted for compatibility). Project-scoped hooks require the project to be trusted, and new or changed Codex hooks must be reviewed from `/hooks` in Codex before they run.
|
|
331
337
|
|
|
332
338
|
## Plugins
|
|
333
339
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* <project>/.codex/hooks.json (project scope). Unlike Claude Code which
|
|
6
6
|
* embeds hooks inside settings.json, Codex uses a dedicated file.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* runtime expansion. ASB must filter unsupported entries and rewrite
|
|
8
|
+
* ASB currently distributes command handlers to Codex and Codex has no
|
|
9
|
+
* ${HOOK_DIR} runtime expansion. ASB must filter unsupported entries and rewrite
|
|
10
10
|
* bundle paths to absolute paths before writing.
|
|
11
11
|
*/
|
|
12
12
|
import type { ConfigScope } from '../config/scope.js';
|
|
@@ -5,22 +5,26 @@
|
|
|
5
5
|
* <project>/.codex/hooks.json (project scope). Unlike Claude Code which
|
|
6
6
|
* embeds hooks inside settings.json, Codex uses a dedicated file.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* runtime expansion. ASB must filter unsupported entries and rewrite
|
|
8
|
+
* ASB currently distributes command handlers to Codex and Codex has no
|
|
9
|
+
* ${HOOK_DIR} runtime expansion. ASB must filter unsupported entries and rewrite
|
|
10
10
|
* bundle paths to absolute paths before writing.
|
|
11
11
|
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import os from 'node:os';
|
|
14
15
|
import path from 'node:path';
|
|
15
|
-
import {
|
|
16
|
+
import { parse as parseToml } from '@iarna/toml';
|
|
16
17
|
import { getCodexConfigPath, getCodexDir, getCodexHooksJsonPath, getProjectCodexDir, getProjectCodexHooksJsonPath, } from '../config/paths.js';
|
|
17
|
-
import { distributeBundle } from '../library/distribute-bundle.js';
|
|
18
|
-
import { ensureParentDir
|
|
18
|
+
import { assertNoSymlinkAncestor, assertUsableBundleRoot, distributeBundle, } from '../library/distribute-bundle.js';
|
|
19
|
+
import { ensureParentDir } from '../library/fs.js';
|
|
19
20
|
import { listHookBundleFiles } from './library.js';
|
|
20
21
|
import { HOOK_DIR_PLACEHOLDER } from './schema.js';
|
|
21
22
|
const CODEX_SUPPORTED_EVENTS = new Set([
|
|
22
23
|
'PreToolUse',
|
|
24
|
+
'PermissionRequest',
|
|
23
25
|
'PostToolUse',
|
|
26
|
+
'PreCompact',
|
|
27
|
+
'PostCompact',
|
|
24
28
|
'SessionStart',
|
|
25
29
|
'UserPromptSubmit',
|
|
26
30
|
'Stop',
|
|
@@ -49,6 +53,13 @@ function resolveHooksBundleParentDir(scope) {
|
|
|
49
53
|
}
|
|
50
54
|
return path.join(getCodexDir(), 'hooks', ASB_HOOKS_SUBDIR);
|
|
51
55
|
}
|
|
56
|
+
function resolveHooksBundleSafetyRoot(scope) {
|
|
57
|
+
const projectRoot = scope?.project?.trim();
|
|
58
|
+
if (projectRoot && projectRoot.length > 0) {
|
|
59
|
+
return getProjectCodexDir(projectRoot);
|
|
60
|
+
}
|
|
61
|
+
return getCodexDir();
|
|
62
|
+
}
|
|
52
63
|
function resolveHookBundleTargetDir(entry, scope) {
|
|
53
64
|
return path.join(resolveHooksBundleParentDir(scope), entry.id);
|
|
54
65
|
}
|
|
@@ -61,16 +72,40 @@ function preferHomeVar(command) {
|
|
|
61
72
|
return command;
|
|
62
73
|
return command.replaceAll(home, '$HOME');
|
|
63
74
|
}
|
|
75
|
+
function formatUnsupportedReason(diagnostic) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (diagnostic.unsupportedEvents.length > 0) {
|
|
78
|
+
parts.push(`unsupported events: ${diagnostic.unsupportedEvents.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
if (diagnostic.unsupportedHandlerTypes.length > 0) {
|
|
81
|
+
parts.push(`unsupported handler types: ${diagnostic.unsupportedHandlerTypes.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const prefix = diagnostic.fullyFiltered
|
|
84
|
+
? 'hook has no Codex-compatible command handlers after filtering'
|
|
85
|
+
: 'hook was partially filtered for Codex compatibility';
|
|
86
|
+
return `${prefix} (${parts.join('; ')})`;
|
|
87
|
+
}
|
|
64
88
|
function filterForCodex(entries) {
|
|
65
89
|
const result = [];
|
|
90
|
+
const diagnostics = [];
|
|
66
91
|
for (const entry of entries) {
|
|
67
92
|
const filteredHooks = {};
|
|
93
|
+
const unsupportedEvents = new Set();
|
|
94
|
+
const unsupportedHandlerTypes = new Set();
|
|
68
95
|
for (const [event, groups] of Object.entries(entry.hooks)) {
|
|
69
|
-
if (!CODEX_SUPPORTED_EVENTS.has(event))
|
|
96
|
+
if (!CODEX_SUPPORTED_EVENTS.has(event)) {
|
|
97
|
+
unsupportedEvents.add(event);
|
|
70
98
|
continue;
|
|
99
|
+
}
|
|
71
100
|
const filteredGroups = [];
|
|
72
101
|
for (const group of groups) {
|
|
73
|
-
const filteredHandlers = (group.hooks ?? []).filter((h) =>
|
|
102
|
+
const filteredHandlers = (group.hooks ?? []).filter((h) => {
|
|
103
|
+
const type = h.type ?? 'unknown';
|
|
104
|
+
const supported = CODEX_SUPPORTED_HANDLER_TYPES.has(type);
|
|
105
|
+
if (!supported)
|
|
106
|
+
unsupportedHandlerTypes.add(type);
|
|
107
|
+
return supported;
|
|
108
|
+
});
|
|
74
109
|
if (filteredHandlers.length > 0) {
|
|
75
110
|
filteredGroups.push({ ...group, hooks: filteredHandlers });
|
|
76
111
|
}
|
|
@@ -82,8 +117,16 @@ function filterForCodex(entries) {
|
|
|
82
117
|
if (Object.keys(filteredHooks).length > 0) {
|
|
83
118
|
result.push({ entry, hooks: filteredHooks });
|
|
84
119
|
}
|
|
120
|
+
if (unsupportedEvents.size > 0 || unsupportedHandlerTypes.size > 0) {
|
|
121
|
+
diagnostics.push({
|
|
122
|
+
entryId: entry.id,
|
|
123
|
+
unsupportedEvents: [...unsupportedEvents].sort(),
|
|
124
|
+
unsupportedHandlerTypes: [...unsupportedHandlerTypes].sort(),
|
|
125
|
+
fullyFiltered: Object.keys(filteredHooks).length === 0,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
85
128
|
}
|
|
86
|
-
return result;
|
|
129
|
+
return { entries: result, diagnostics };
|
|
87
130
|
}
|
|
88
131
|
// ---------------------------------------------------------------------------
|
|
89
132
|
// hooks.json I/O
|
|
@@ -93,7 +136,7 @@ function readHooksJson(filePath) {
|
|
|
93
136
|
if (fs.existsSync(filePath)) {
|
|
94
137
|
return {
|
|
95
138
|
ok: true,
|
|
96
|
-
data: JSON.parse(fs.readFileSync(filePath, 'utf-8')),
|
|
139
|
+
data: parseHooksJsonRoot(JSON.parse(fs.readFileSync(filePath, 'utf-8'))),
|
|
97
140
|
};
|
|
98
141
|
}
|
|
99
142
|
}
|
|
@@ -102,6 +145,12 @@ function readHooksJson(filePath) {
|
|
|
102
145
|
}
|
|
103
146
|
return { ok: true, data: {} };
|
|
104
147
|
}
|
|
148
|
+
function parseHooksJsonRoot(value) {
|
|
149
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
150
|
+
throw new Error('hooks.json has invalid shape: root must be an object');
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
105
154
|
function writeHooksJson(filePath, data) {
|
|
106
155
|
ensureParentDir(filePath);
|
|
107
156
|
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
|
|
@@ -109,7 +158,7 @@ function writeHooksJson(filePath, data) {
|
|
|
109
158
|
// ---------------------------------------------------------------------------
|
|
110
159
|
// Config merge
|
|
111
160
|
// ---------------------------------------------------------------------------
|
|
112
|
-
function rewriteHookDir(hooks, distributedDir) {
|
|
161
|
+
function rewriteHookDir(hooks, distributedDir, bundleHash) {
|
|
113
162
|
const result = {};
|
|
114
163
|
for (const [event, groups] of Object.entries(hooks)) {
|
|
115
164
|
result[event] = groups.map((group) => ({
|
|
@@ -119,17 +168,33 @@ function rewriteHookDir(hooks, distributedDir) {
|
|
|
119
168
|
return handler;
|
|
120
169
|
return {
|
|
121
170
|
...handler,
|
|
122
|
-
command: preferHomeVar(handler.command
|
|
171
|
+
command: preferHomeVar(annotateBundleCommand(handler.command
|
|
123
172
|
.replaceAll(HOOK_DIR_PLACEHOLDER, distributedDir)
|
|
124
173
|
.replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX, distributedDir)
|
|
125
|
-
.replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX_WINDOWS, distributedDir)),
|
|
174
|
+
.replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX_WINDOWS, distributedDir), bundleHash)),
|
|
126
175
|
};
|
|
127
176
|
}),
|
|
128
177
|
}));
|
|
129
178
|
}
|
|
130
179
|
return result;
|
|
131
180
|
}
|
|
132
|
-
function
|
|
181
|
+
function annotateBundleCommand(command, bundleHash) {
|
|
182
|
+
if (!bundleHash)
|
|
183
|
+
return command;
|
|
184
|
+
return `${command}\n# asb-bundle-sha256=${bundleHash}`;
|
|
185
|
+
}
|
|
186
|
+
function computeBundleHash(entry) {
|
|
187
|
+
const hash = createHash('sha256');
|
|
188
|
+
const files = listHookBundleFiles(entry).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
hash.update(file.relativePath);
|
|
191
|
+
hash.update('\0');
|
|
192
|
+
hash.update(fs.readFileSync(file.sourcePath));
|
|
193
|
+
hash.update('\0');
|
|
194
|
+
}
|
|
195
|
+
return hash.digest('hex');
|
|
196
|
+
}
|
|
197
|
+
function mergeHooksIntoFile(fileData, filteredEntries, scope, bundleHashes) {
|
|
133
198
|
const existingHooks = (fileData.hooks ?? {});
|
|
134
199
|
// Remove previously ASB-managed matcher groups
|
|
135
200
|
const cleanedHooks = {};
|
|
@@ -141,7 +206,7 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
|
|
|
141
206
|
// Merge filtered entries
|
|
142
207
|
for (const { entry, hooks } of filteredEntries) {
|
|
143
208
|
const resolvedHooks = entry.isBundle
|
|
144
|
-
? rewriteHookDir(hooks, resolveHookBundleTargetDir(entry, scope))
|
|
209
|
+
? rewriteHookDir(hooks, resolveHookBundleTargetDir(entry, scope), bundleHashes?.get(entry.id))
|
|
145
210
|
: hooks;
|
|
146
211
|
for (const [event, groups] of Object.entries(resolvedHooks)) {
|
|
147
212
|
if (!cleanedHooks[event])
|
|
@@ -152,7 +217,12 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
|
|
|
152
217
|
}
|
|
153
218
|
}
|
|
154
219
|
fileData.hooks = cleanedHooks;
|
|
155
|
-
|
|
220
|
+
if (filteredEntries.length > 0) {
|
|
221
|
+
fileData[ASB_MANAGED_KEY] = filteredEntries.map((f) => f.entry.id);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
delete fileData[ASB_MANAGED_KEY];
|
|
225
|
+
}
|
|
156
226
|
}
|
|
157
227
|
// ---------------------------------------------------------------------------
|
|
158
228
|
// Orphan cleanup for bundles
|
|
@@ -160,19 +230,24 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
|
|
|
160
230
|
function cleanOrphanBundleDirs(activeIds, scope, dryRun) {
|
|
161
231
|
const parentDir = resolveHooksBundleParentDir(scope);
|
|
162
232
|
const results = [];
|
|
163
|
-
|
|
233
|
+
const parentError = getOrphanBundleParentError(scope);
|
|
234
|
+
if (parentError) {
|
|
235
|
+
results.push(parentError);
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
if (!lstatIfExists(parentDir))
|
|
164
239
|
return results;
|
|
165
240
|
try {
|
|
166
241
|
const entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
167
242
|
for (const entry of entries) {
|
|
168
|
-
if (!entry.isDirectory())
|
|
243
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
169
244
|
continue;
|
|
170
245
|
if (activeIds.has(entry.name))
|
|
171
246
|
continue;
|
|
172
247
|
const dirPath = path.join(parentDir, entry.name);
|
|
173
248
|
try {
|
|
174
249
|
if (!dryRun)
|
|
175
|
-
|
|
250
|
+
removeHookBundlePath(dirPath);
|
|
176
251
|
results.push({
|
|
177
252
|
platform: 'codex',
|
|
178
253
|
targetDir: dirPath,
|
|
@@ -193,70 +268,163 @@ function cleanOrphanBundleDirs(activeIds, scope, dryRun) {
|
|
|
193
268
|
}
|
|
194
269
|
}
|
|
195
270
|
}
|
|
196
|
-
catch {
|
|
197
|
-
|
|
271
|
+
catch (error) {
|
|
272
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
273
|
+
results.push({
|
|
274
|
+
platform: 'codex',
|
|
275
|
+
targetDir: parentDir,
|
|
276
|
+
status: 'error',
|
|
277
|
+
error: `Failed to scan orphan parent: ${msg}`,
|
|
278
|
+
});
|
|
198
279
|
}
|
|
199
280
|
return results;
|
|
200
281
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
282
|
+
function getOrphanBundleParentError(scope) {
|
|
283
|
+
const parentDir = resolveHooksBundleParentDir(scope);
|
|
284
|
+
try {
|
|
285
|
+
const safetyRoot = resolveHooksBundleSafetyRoot(scope);
|
|
286
|
+
assertUsableBundleRoot(safetyRoot);
|
|
287
|
+
assertNoSymlinkAncestor(safetyRoot, parentDir);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
291
|
+
return {
|
|
292
|
+
platform: 'codex',
|
|
293
|
+
targetDir: parentDir,
|
|
294
|
+
status: 'error',
|
|
295
|
+
error: `Failed to scan orphan parent: ${msg}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const stat = lstatIfExists(parentDir);
|
|
299
|
+
if (!stat)
|
|
300
|
+
return undefined;
|
|
301
|
+
if (!stat.isDirectory()) {
|
|
302
|
+
return {
|
|
303
|
+
platform: 'codex',
|
|
304
|
+
targetDir: parentDir,
|
|
305
|
+
status: 'error',
|
|
306
|
+
error: `Failed to scan orphan parent: bundle root exists and is not a directory: ${parentDir}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
function lstatIfExists(filePath) {
|
|
312
|
+
try {
|
|
313
|
+
return fs.lstatSync(filePath);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function removeHookBundlePath(targetPath) {
|
|
323
|
+
const stat = lstatIfExists(targetPath);
|
|
324
|
+
if (!stat)
|
|
207
325
|
return;
|
|
208
|
-
|
|
209
|
-
|
|
326
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
327
|
+
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
removeHookBundlePath(path.join(targetPath, entry.name));
|
|
330
|
+
}
|
|
331
|
+
fs.rmdirSync(targetPath);
|
|
210
332
|
return;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
platform: 'codex',
|
|
214
|
-
filePath: configPath,
|
|
215
|
-
status: 'written',
|
|
216
|
-
reason: 'codex_hooks feature flag may not be enabled in config.toml',
|
|
217
|
-
});
|
|
333
|
+
}
|
|
334
|
+
fs.unlinkSync(targetPath);
|
|
218
335
|
}
|
|
219
|
-
function
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
filePath: globalPath,
|
|
227
|
-
status: 'skipped',
|
|
228
|
-
reason: trustResult.warning,
|
|
229
|
-
});
|
|
336
|
+
function readCodexConfigToml() {
|
|
337
|
+
const configPath = getCodexConfigPath();
|
|
338
|
+
if (!fs.existsSync(configPath))
|
|
339
|
+
return { ok: true, filePath: configPath, data: {} };
|
|
340
|
+
try {
|
|
341
|
+
const data = parseToml(fs.readFileSync(configPath, 'utf-8'));
|
|
342
|
+
return { ok: true, filePath: configPath, data };
|
|
230
343
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
344
|
+
catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
filePath: configPath,
|
|
348
|
+
error: error instanceof Error ? error.message : String(error),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function asBoolean(value) {
|
|
353
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
354
|
+
}
|
|
355
|
+
function getHooksFeatureValue(features) {
|
|
356
|
+
return asBoolean(features?.hooks) ?? asBoolean(features?.codex_hooks);
|
|
357
|
+
}
|
|
358
|
+
function getObject(value) {
|
|
359
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
360
|
+
return undefined;
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
function getEffectiveHooksEnabled(config) {
|
|
364
|
+
const baseValue = getHooksFeatureValue(getObject(config.features));
|
|
365
|
+
const profileName = typeof config.profile === 'string' ? config.profile : undefined;
|
|
366
|
+
const profiles = getObject(config.profiles);
|
|
367
|
+
const profileConfig = profileName ? getObject(profiles?.[profileName]) : undefined;
|
|
368
|
+
const profileValue = getHooksFeatureValue(getObject(profileConfig?.features));
|
|
369
|
+
return profileValue ?? baseValue ?? true;
|
|
370
|
+
}
|
|
371
|
+
function addCodexHooksFeatureResult(config, results) {
|
|
372
|
+
const effectiveHooksEnabled = getEffectiveHooksEnabled(config.data);
|
|
373
|
+
if (!effectiveHooksEnabled) {
|
|
234
374
|
results.push({
|
|
235
375
|
platform: 'codex',
|
|
236
|
-
filePath:
|
|
237
|
-
status: '
|
|
238
|
-
reason: '
|
|
376
|
+
filePath: config.filePath,
|
|
377
|
+
status: 'conflict',
|
|
378
|
+
reason: 'features.hooks is disabled; enable it before Codex hooks can run',
|
|
239
379
|
});
|
|
240
380
|
}
|
|
241
381
|
}
|
|
382
|
+
function addProjectTrustResult(config, projectRoot, results) {
|
|
383
|
+
const projects = getObject(config.data.projects);
|
|
384
|
+
const project = getObject(projects?.[path.resolve(projectRoot)]);
|
|
385
|
+
const trustLevel = project?.trust_level;
|
|
386
|
+
if (trustLevel === 'trusted')
|
|
387
|
+
return;
|
|
388
|
+
const reason = typeof trustLevel === 'string'
|
|
389
|
+
? `project is not trusted (trust_level="${trustLevel}"); Codex will ignore project hooks`
|
|
390
|
+
: 'project is not trusted; Codex will ignore project hooks until trust_level = "trusted" is configured';
|
|
391
|
+
results.push({ platform: 'codex', filePath: config.filePath, status: 'conflict', reason });
|
|
392
|
+
}
|
|
393
|
+
function addReviewResult(hooksJsonPath, results) {
|
|
394
|
+
results.push({
|
|
395
|
+
platform: 'codex',
|
|
396
|
+
filePath: hooksJsonPath,
|
|
397
|
+
status: 'conflict',
|
|
398
|
+
reason: 'open /hooks in Codex to review new or modified hooks before they can run',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function addConfigParseResult(config, results) {
|
|
402
|
+
results.push({
|
|
403
|
+
platform: 'codex',
|
|
404
|
+
filePath: config.filePath,
|
|
405
|
+
status: 'conflict',
|
|
406
|
+
reason: `Cannot parse config.toml to verify Codex hook prerequisites: ${config.error}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
242
409
|
export function distributeCodexHooks(options) {
|
|
243
410
|
const { scope, selected, dryRun = false, projectMode } = options;
|
|
244
411
|
if (scope?.project && projectMode === 'none') {
|
|
245
412
|
return { results: [] };
|
|
246
413
|
}
|
|
247
414
|
const results = [];
|
|
248
|
-
// Ensure Codex hooks feature flag is enabled
|
|
249
|
-
if (!dryRun) {
|
|
250
|
-
ensureCodexHooksFeature(results);
|
|
251
|
-
}
|
|
252
|
-
// Ensure project trust for project-scoped distribution
|
|
253
|
-
if (!dryRun && scope?.project) {
|
|
254
|
-
ensureProjectTrust(scope.project, results);
|
|
255
|
-
}
|
|
256
|
-
// Filter entries for Codex compatibility
|
|
257
|
-
const filteredEntries = filterForCodex(selected);
|
|
258
415
|
// Pre-validate hooks.json before making any filesystem changes
|
|
259
416
|
const hooksJsonPath = resolveHooksJsonPath(scope);
|
|
417
|
+
const filterResult = filterForCodex(selected);
|
|
418
|
+
const filteredEntries = filterResult.entries;
|
|
419
|
+
for (const diagnostic of filterResult.diagnostics) {
|
|
420
|
+
results.push({
|
|
421
|
+
platform: 'codex',
|
|
422
|
+
filePath: hooksJsonPath,
|
|
423
|
+
status: 'skipped',
|
|
424
|
+
reason: formatUnsupportedReason(diagnostic),
|
|
425
|
+
entryId: diagnostic.entryId,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
260
428
|
const fileResult = readHooksJson(hooksJsonPath);
|
|
261
429
|
if (!fileResult.ok) {
|
|
262
430
|
results.push({
|
|
@@ -293,55 +461,142 @@ export function distributeCodexHooks(options) {
|
|
|
293
461
|
}
|
|
294
462
|
}
|
|
295
463
|
}
|
|
464
|
+
if (filteredEntries.length > 0) {
|
|
465
|
+
const config = readCodexConfigToml();
|
|
466
|
+
if (!config.ok) {
|
|
467
|
+
addConfigParseResult(config, results);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
addCodexHooksFeatureResult(config, results);
|
|
471
|
+
if (scope?.project)
|
|
472
|
+
addProjectTrustResult(config, scope.project, results);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
296
475
|
// Phase 1: Copy bundle files (only after validation passes)
|
|
297
476
|
const bundleEntries = filteredEntries.filter((f) => f.entry.isBundle).map((f) => f.entry);
|
|
477
|
+
const activeBundleIds = new Set(bundleEntries.map((e) => e.id));
|
|
478
|
+
const bundleHashes = new Map();
|
|
298
479
|
if (bundleEntries.length > 0) {
|
|
299
480
|
const bundleOutcome = distributeBundle({
|
|
300
481
|
section: 'hooks',
|
|
301
482
|
selected: bundleEntries,
|
|
302
483
|
platforms: ['codex'],
|
|
303
484
|
resolveTargetDir: (_p, entry) => resolveHookBundleTargetDir(entry, scope),
|
|
485
|
+
resolveBundleRootDir: () => resolveHooksBundleSafetyRoot(scope),
|
|
304
486
|
listFiles: listHookBundleFiles,
|
|
305
487
|
getId: (entry) => entry.id,
|
|
306
488
|
scope,
|
|
307
489
|
dryRun,
|
|
308
490
|
});
|
|
309
491
|
results.push(...bundleOutcome.results);
|
|
492
|
+
if (bundleOutcome.results.some((result) => result.status === 'error' || result.status === 'conflict')) {
|
|
493
|
+
return { results };
|
|
494
|
+
}
|
|
495
|
+
for (const entry of bundleEntries) {
|
|
496
|
+
try {
|
|
497
|
+
bundleHashes.set(entry.id, computeBundleHash(entry));
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
501
|
+
results.push({
|
|
502
|
+
platform: 'codex',
|
|
503
|
+
filePath: hooksJsonPath,
|
|
504
|
+
status: 'error',
|
|
505
|
+
error: `Failed to hash bundle ${entry.id}: ${msg}`,
|
|
506
|
+
entryId: entry.id,
|
|
507
|
+
});
|
|
508
|
+
return { results };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
310
511
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
512
|
+
const cleanupParentError = getOrphanBundleParentError(scope);
|
|
513
|
+
if (cleanupParentError) {
|
|
514
|
+
results.push(cleanupParentError);
|
|
515
|
+
return { results };
|
|
516
|
+
}
|
|
517
|
+
const appendCleanupResults = () => {
|
|
518
|
+
const cleanupResults = cleanOrphanBundleDirs(activeBundleIds, scope, dryRun);
|
|
519
|
+
results.push(...cleanupResults);
|
|
520
|
+
return cleanupResults.some((result) => result.status === 'error');
|
|
521
|
+
};
|
|
314
522
|
// Phase 2: Merge hook configs into hooks.json
|
|
315
523
|
const previouslyManaged = (fileData[ASB_MANAGED_KEY] ?? []);
|
|
316
524
|
// Check for existing ASB groups
|
|
317
525
|
const existingHooks = (fileData.hooks ?? {});
|
|
318
526
|
const hasAsbGroups = Object.values(existingHooks).some((groups) => groups.some((g) => g._asb_source === true));
|
|
319
527
|
if (filteredEntries.length === 0 && previouslyManaged.length === 0 && !hasAsbGroups) {
|
|
528
|
+
appendCleanupResults();
|
|
320
529
|
return { results };
|
|
321
530
|
}
|
|
322
531
|
const before = JSON.stringify(fileData);
|
|
323
|
-
mergeHooksIntoFile(fileData, filteredEntries, scope);
|
|
532
|
+
mergeHooksIntoFile(fileData, filteredEntries, scope, bundleHashes);
|
|
533
|
+
const preserveCleanupMarker = filteredEntries.length === 0 && previouslyManaged.length > 0;
|
|
534
|
+
if (preserveCleanupMarker) {
|
|
535
|
+
fileData[ASB_MANAGED_KEY] = previouslyManaged;
|
|
536
|
+
}
|
|
324
537
|
// Clean up empty state: if no hooks remain, remove the file entirely
|
|
325
538
|
const mergedHooks = fileData.hooks;
|
|
326
539
|
const totalGroups = Object.values(mergedHooks).reduce((sum, groups) => sum + groups.length, 0);
|
|
327
540
|
const hasNoHooks = totalGroups === 0;
|
|
328
541
|
if (hasNoHooks && filteredEntries.length === 0) {
|
|
329
|
-
|
|
330
|
-
|
|
542
|
+
if (!preserveCleanupMarker) {
|
|
543
|
+
delete fileData[ASB_MANAGED_KEY];
|
|
544
|
+
}
|
|
331
545
|
// If file has only hooks (now empty) and ASB keys, consider deleting
|
|
332
546
|
const remainingKeys = Object.keys(fileData).filter((k) => k !== 'hooks');
|
|
333
547
|
if (remainingKeys.length === 0 && fs.existsSync(hooksJsonPath) && !dryRun) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
548
|
+
try {
|
|
549
|
+
fs.unlinkSync(hooksJsonPath);
|
|
550
|
+
results.push({
|
|
551
|
+
platform: 'codex',
|
|
552
|
+
filePath: hooksJsonPath,
|
|
553
|
+
status: 'deleted',
|
|
554
|
+
reason: 'no hooks remain',
|
|
555
|
+
});
|
|
556
|
+
appendCleanupResults();
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
560
|
+
results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'error', error: msg });
|
|
561
|
+
}
|
|
341
562
|
return { results };
|
|
342
563
|
}
|
|
343
564
|
}
|
|
344
565
|
const after = JSON.stringify(fileData);
|
|
566
|
+
const finalizeCleanupMarker = () => {
|
|
567
|
+
if (!preserveCleanupMarker || dryRun)
|
|
568
|
+
return;
|
|
569
|
+
delete fileData[ASB_MANAGED_KEY];
|
|
570
|
+
const finalHooks = fileData.hooks;
|
|
571
|
+
const finalTotalGroups = Object.values(finalHooks).reduce((sum, groups) => sum + groups.length, 0);
|
|
572
|
+
const finalRemainingKeys = Object.keys(fileData).filter((k) => k !== 'hooks');
|
|
573
|
+
try {
|
|
574
|
+
if (finalTotalGroups === 0 &&
|
|
575
|
+
finalRemainingKeys.length === 0 &&
|
|
576
|
+
fs.existsSync(hooksJsonPath)) {
|
|
577
|
+
fs.unlinkSync(hooksJsonPath);
|
|
578
|
+
results.push({
|
|
579
|
+
platform: 'codex',
|
|
580
|
+
filePath: hooksJsonPath,
|
|
581
|
+
status: 'deleted',
|
|
582
|
+
reason: 'no hooks remain',
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
writeHooksJson(hooksJsonPath, fileData);
|
|
587
|
+
results.push({
|
|
588
|
+
platform: 'codex',
|
|
589
|
+
filePath: hooksJsonPath,
|
|
590
|
+
status: 'written',
|
|
591
|
+
reason: 'cleanup finalized',
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
597
|
+
results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'error', error: msg });
|
|
598
|
+
}
|
|
599
|
+
};
|
|
345
600
|
if (before === after) {
|
|
346
601
|
results.push({
|
|
347
602
|
platform: 'codex',
|
|
@@ -349,16 +604,25 @@ export function distributeCodexHooks(options) {
|
|
|
349
604
|
status: 'skipped',
|
|
350
605
|
reason: 'up-to-date',
|
|
351
606
|
});
|
|
607
|
+
if (!appendCleanupResults())
|
|
608
|
+
finalizeCleanupMarker();
|
|
352
609
|
}
|
|
353
610
|
else if (dryRun) {
|
|
354
611
|
const reason = filteredEntries.length === 0 ? 'hooks cleared' : `${filteredEntries.length} hook(s) merged`;
|
|
355
612
|
results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'written', reason });
|
|
613
|
+
if (filteredEntries.length > 0)
|
|
614
|
+
addReviewResult(hooksJsonPath, results);
|
|
615
|
+
appendCleanupResults();
|
|
356
616
|
}
|
|
357
617
|
else {
|
|
358
618
|
try {
|
|
359
619
|
writeHooksJson(hooksJsonPath, fileData);
|
|
360
620
|
const reason = filteredEntries.length === 0 ? 'hooks cleared' : `${filteredEntries.length} hook(s) merged`;
|
|
361
621
|
results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'written', reason });
|
|
622
|
+
if (filteredEntries.length > 0)
|
|
623
|
+
addReviewResult(hooksJsonPath, results);
|
|
624
|
+
if (!appendCleanupResults())
|
|
625
|
+
finalizeCleanupMarker();
|
|
362
626
|
}
|
|
363
627
|
catch (error) {
|
|
364
628
|
const msg = error instanceof Error ? error.message : String(error);
|