@raidiant/notifai 0.5.0 → 1.0.0
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/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/dist/install-hooks.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, realpathSync, statSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { parse as parseToml } from 'smol-toml';
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
6
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
7
7
|
import { hookAdapterPath } from './hook-adapter.js';
|
|
8
8
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js';
|
|
9
|
-
import { HARNESSES
|
|
9
|
+
import { HARNESSES } from './harnesses.js';
|
|
10
10
|
export { HARNESSES } from './harnesses.js';
|
|
11
11
|
/**
|
|
12
12
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
@@ -51,26 +51,52 @@ export const OWNER_MARKER = '--owner notifai';
|
|
|
51
51
|
function quote(value) {
|
|
52
52
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
53
53
|
}
|
|
54
|
-
/** Generated blocking adapters grant the owner budget one fixed minute of host headroom. */
|
|
55
|
-
const HOOK_CEILING_SECONDS = HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds;
|
|
56
54
|
/**
|
|
57
|
-
*
|
|
55
|
+
* Claude Code's Stop handler returns instantly and the same process lives on
|
|
56
|
+
* out of band as the escalation waiter, so this `timeout` bounds a background
|
|
57
|
+
* process rather than the user's turn.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* It is always declared, and declared far above the longest wait a waiter can
|
|
60
|
+
* take. Claude's default is 600 s and the kill is **silent** — the backgrounded
|
|
61
|
+
* process simply vanishes, no error is reported anywhere, and the answer the
|
|
62
|
+
* user already gave is never delivered. Sitting anywhere near that boundary
|
|
63
|
+
* turns a slow reply into a lost one, so this does not: an explicit value
|
|
64
|
+
* raises the ceiling (verified against a 660 s wait under an hour-long
|
|
65
|
+
* declaration), and an hour is several times the waiter's own ceiling.
|
|
64
66
|
*/
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
+
export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600;
|
|
68
|
+
/**
|
|
69
|
+
* The declared process budget for a host that holds its turn while the waiter
|
|
70
|
+
* runs: the waiter's own ceiling plus a minute of host headroom, as one plain
|
|
71
|
+
* number rather than a budget carved into reserves.
|
|
72
|
+
*
|
|
73
|
+
* Codex is deliberately absent from this. It owns its Stop timeout today, and
|
|
74
|
+
* declaring one would change the serialized definition Codex hashes into
|
|
75
|
+
* `trusted_hash` — which silently stops every Notifai handler from running
|
|
76
|
+
* until the user re-approves them in `/hooks`.
|
|
77
|
+
*/
|
|
78
|
+
export const BLOCKING_STOP_TIMEOUT_SECONDS = 540;
|
|
79
|
+
/**
|
|
80
|
+
* The turn-end handler, whose shape is the whole per-harness difference.
|
|
81
|
+
*
|
|
82
|
+
* Claude Code takes the answer over its own inbox socket, so its Stop hook is
|
|
83
|
+
* `async: true`: it returns immediately, the terminal is never held, and the
|
|
84
|
+
* waiter finishes out of band. Every other host still delivers by printing a
|
|
85
|
+
* continuation to this process's stdout, so its Stop hook blocks and needs a
|
|
86
|
+
* declared ceiling above the wait — except Codex, which owns that number
|
|
87
|
+
* itself and whose definition must stay byte-stable for its trust store.
|
|
88
|
+
*/
|
|
89
|
+
function stopHandler(adapterPath, harness) {
|
|
90
|
+
const command = hookCommand(adapterPath, 'stop', harness);
|
|
91
|
+
if (harness === 'claude-code') {
|
|
92
|
+
return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true };
|
|
93
|
+
}
|
|
94
|
+
if (harness === 'codex')
|
|
95
|
+
return { type: 'command', command };
|
|
96
|
+
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS };
|
|
67
97
|
}
|
|
68
98
|
export function buildHookConfig(options) {
|
|
69
99
|
const { adapterPath } = options;
|
|
70
|
-
const hostOwnsStopTimeout = options.harness === 'codex';
|
|
71
|
-
// Runtime preferences fit inside this fixed process budget without becoming
|
|
72
|
-
// part of the serialized definition Codex asks the user to trust.
|
|
73
|
-
const blockingTimeout = blockingHookTimeoutSeconds();
|
|
74
100
|
return {
|
|
75
101
|
UserPromptSubmit: [
|
|
76
102
|
{
|
|
@@ -85,17 +111,7 @@ export function buildHookConfig(options) {
|
|
|
85
111
|
],
|
|
86
112
|
},
|
|
87
113
|
],
|
|
88
|
-
Stop: [
|
|
89
|
-
{
|
|
90
|
-
hooks: [
|
|
91
|
-
{
|
|
92
|
-
type: 'command',
|
|
93
|
-
command: hookCommand(adapterPath, 'stop', options.harness),
|
|
94
|
-
...(hostOwnsStopTimeout ? {} : { timeout: blockingTimeout }),
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
},
|
|
98
|
-
],
|
|
114
|
+
Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
|
|
99
115
|
SessionEnd: [
|
|
100
116
|
{
|
|
101
117
|
hooks: [
|
|
@@ -113,7 +129,6 @@ export function buildHookConfig(options) {
|
|
|
113
129
|
}
|
|
114
130
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
115
131
|
export function buildCursorHookConfig(options) {
|
|
116
|
-
const blockingTimeout = blockingHookTimeoutSeconds();
|
|
117
132
|
return {
|
|
118
133
|
beforeSubmitPrompt: [
|
|
119
134
|
{
|
|
@@ -124,7 +139,7 @@ export function buildCursorHookConfig(options) {
|
|
|
124
139
|
stop: [
|
|
125
140
|
{
|
|
126
141
|
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
127
|
-
timeout:
|
|
142
|
+
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
128
143
|
// A continuation may register a real follow-up question. Match the
|
|
129
144
|
// session-state cap so those chains are useful but never unbounded.
|
|
130
145
|
loop_limit: 3,
|
|
@@ -160,9 +175,7 @@ export function settingsFile(harness, global, cwd, env = process.env) {
|
|
|
160
175
|
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
161
176
|
: path.join(cwd, '.claude', 'settings.local.json');
|
|
162
177
|
case 'codex':
|
|
163
|
-
return global
|
|
164
|
-
? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
|
|
165
|
-
: path.join(codexProjectRoot(cwd), '.codex', 'hooks.json');
|
|
178
|
+
return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget;
|
|
166
179
|
default:
|
|
167
180
|
return assertNeverHarness(harness);
|
|
168
181
|
}
|
|
@@ -264,6 +277,133 @@ export function configHome(env, variable, fallback) {
|
|
|
264
277
|
const override = env[variable];
|
|
265
278
|
return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback);
|
|
266
279
|
}
|
|
280
|
+
/** The two files Codex will look at for one config layer. */
|
|
281
|
+
export function codexLayerPaths(global, cwd, env = process.env) {
|
|
282
|
+
const dir = global
|
|
283
|
+
? configHome(env, 'CODEX_HOME', '.codex')
|
|
284
|
+
: path.join(codexProjectRoot(cwd), '.codex');
|
|
285
|
+
return {
|
|
286
|
+
dir,
|
|
287
|
+
hooksJson: path.join(dir, 'hooks.json'),
|
|
288
|
+
configToml: path.join(dir, 'config.toml'),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Which representation this Codex layer already uses.
|
|
293
|
+
*
|
|
294
|
+
* Codex loads `hooks.json` and inline `[hooks]` side by side, runs every
|
|
295
|
+
* matching handler, and warns when both exist. `[hooks.state]` is the trust
|
|
296
|
+
* store, not a hook definition, so it does not count as a representation.
|
|
297
|
+
*/
|
|
298
|
+
export function inspectCodexLayer(paths) {
|
|
299
|
+
const jsonDocument = tryLoadSettings(paths.hooksJson);
|
|
300
|
+
const tomlDocument = tryLoadSettings(paths.configToml);
|
|
301
|
+
const jsonEvents = hookEventNames(jsonDocument?.hooks);
|
|
302
|
+
const tomlEvents = hookEventNames(tomlDocument?.hooks);
|
|
303
|
+
const jsonForeign = documentHasForeignHandlers(jsonDocument);
|
|
304
|
+
const tomlForeign = documentHasForeignHandlers(tomlDocument);
|
|
305
|
+
const jsonOurs = documentHasOurHandlers(jsonDocument);
|
|
306
|
+
const tomlOurs = documentHasOurHandlers(tomlDocument);
|
|
307
|
+
const dual = jsonEvents.length > 0 && tomlEvents.length > 0;
|
|
308
|
+
let writeTarget = paths.hooksJson;
|
|
309
|
+
if (tomlEvents.length > 0 && jsonEvents.length === 0)
|
|
310
|
+
writeTarget = paths.configToml;
|
|
311
|
+
else if (jsonEvents.length > 0 && tomlEvents.length === 0)
|
|
312
|
+
writeTarget = paths.hooksJson;
|
|
313
|
+
else if (dual) {
|
|
314
|
+
if (tomlForeign && !jsonForeign)
|
|
315
|
+
writeTarget = paths.configToml;
|
|
316
|
+
else if (jsonForeign && !tomlForeign)
|
|
317
|
+
writeTarget = paths.hooksJson;
|
|
318
|
+
else if (!jsonForeign && !tomlForeign)
|
|
319
|
+
writeTarget = paths.hooksJson;
|
|
320
|
+
else if (tomlOurs && !jsonOurs)
|
|
321
|
+
writeTarget = paths.configToml;
|
|
322
|
+
else if (jsonOurs && !tomlOurs)
|
|
323
|
+
writeTarget = paths.hooksJson;
|
|
324
|
+
else
|
|
325
|
+
writeTarget = paths.hooksJson;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
paths,
|
|
329
|
+
jsonEvents,
|
|
330
|
+
tomlEvents,
|
|
331
|
+
writeTarget,
|
|
332
|
+
dual,
|
|
333
|
+
overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
|
|
334
|
+
canCollapse: dual && !(jsonForeign && tomlForeign),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Every file in this layer Codex might already be reading hooks from. */
|
|
338
|
+
export function hookDefinitionFiles(harness, global, cwd, env = process.env) {
|
|
339
|
+
if (harness !== 'codex')
|
|
340
|
+
return [settingsFile(harness, global, cwd, env)];
|
|
341
|
+
const paths = codexLayerPaths(global, cwd, env);
|
|
342
|
+
return [paths.hooksJson, paths.configToml];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Drop the unused representation after writing the one this layer already had.
|
|
346
|
+
*
|
|
347
|
+
* Only safe when the discarded file has no one else's handlers. `hooks.json`
|
|
348
|
+
* is deleted once it has no events left so Codex stops warning; `config.toml`
|
|
349
|
+
* is never deleted.
|
|
350
|
+
*/
|
|
351
|
+
export function collapseUnusedCodexRepresentation(keepFile, scriptPath, paths) {
|
|
352
|
+
const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson;
|
|
353
|
+
if (!existsSync(other))
|
|
354
|
+
return null;
|
|
355
|
+
const document = tryLoadSettings(other);
|
|
356
|
+
if (document === null || documentHasForeignHandlers(document))
|
|
357
|
+
return null;
|
|
358
|
+
const stripped = removeHooks(document, scriptPath);
|
|
359
|
+
if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
|
|
360
|
+
rmSync(other);
|
|
361
|
+
return other;
|
|
362
|
+
}
|
|
363
|
+
applyPlan(other, stripped.document);
|
|
364
|
+
return other;
|
|
365
|
+
}
|
|
366
|
+
export function codexRepresentationProblems(cwd, env = process.env) {
|
|
367
|
+
return [false, true].flatMap((global) => {
|
|
368
|
+
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env));
|
|
369
|
+
if (!layer.dual)
|
|
370
|
+
return [];
|
|
371
|
+
const overlap = layer.overlappingEvents.length > 0
|
|
372
|
+
? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
|
|
373
|
+
: ' Matching handlers from both files all run.';
|
|
374
|
+
const collapse = layer.canCollapse
|
|
375
|
+
? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
|
|
376
|
+
: ' Both files have other hooks, so Notifai cannot collapse them.';
|
|
377
|
+
return [
|
|
378
|
+
`loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
|
|
379
|
+
];
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function tryLoadSettings(file) {
|
|
383
|
+
if (!existsSync(file))
|
|
384
|
+
return null;
|
|
385
|
+
try {
|
|
386
|
+
return loadSettings(file);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function hookEventNames(hooks) {
|
|
393
|
+
return Object.entries(hooks ?? {})
|
|
394
|
+
.filter(([, value]) => Array.isArray(value))
|
|
395
|
+
.map(([event]) => event);
|
|
396
|
+
}
|
|
397
|
+
function documentHasForeignHandlers(document) {
|
|
398
|
+
if (document === null)
|
|
399
|
+
return false;
|
|
400
|
+
return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command));
|
|
401
|
+
}
|
|
402
|
+
function documentHasOurHandlers(document) {
|
|
403
|
+
if (document === null)
|
|
404
|
+
return false;
|
|
405
|
+
return locateHandlers(document).length > 0;
|
|
406
|
+
}
|
|
267
407
|
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
268
408
|
/**
|
|
269
409
|
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
@@ -292,8 +432,8 @@ function localHarnessEvidence(cwd) {
|
|
|
292
432
|
return found;
|
|
293
433
|
}
|
|
294
434
|
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
295
|
-
function globalHarnessEvidence() {
|
|
296
|
-
const home = os.homedir();
|
|
435
|
+
function globalHarnessEvidence(env = process.env) {
|
|
436
|
+
const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir();
|
|
297
437
|
const found = [];
|
|
298
438
|
if (existsSync(path.join(home, '.claude')))
|
|
299
439
|
found.push('claude-code');
|
|
@@ -306,27 +446,30 @@ function globalHarnessEvidence() {
|
|
|
306
446
|
return found;
|
|
307
447
|
}
|
|
308
448
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
449
|
+
* Every supported harness this project or machine shows evidence of, in
|
|
450
|
+
* declared order. Project markers come first, then machine installs that the
|
|
451
|
+
* project did not already name.
|
|
311
452
|
*
|
|
312
|
-
*
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
*
|
|
453
|
+
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
454
|
+
*/
|
|
455
|
+
export function detectedHarnesses(cwd, env = process.env) {
|
|
456
|
+
const seen = new Set([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)]);
|
|
457
|
+
return HARNESSES.filter((harness) => seen.has(harness));
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Which *single* harness to wire when a caller still needs exactly one, or
|
|
461
|
+
* null when it is genuinely unclear.
|
|
320
462
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
463
|
+
* Project evidence decides, and machine evidence is consulted only when the
|
|
464
|
+
* project offers none. Several markers are not a failure of detection — they
|
|
465
|
+
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
466
|
+
* this helper stays for uninstall and any caller that cannot take a list.
|
|
324
467
|
*/
|
|
325
|
-
export function detectHarness(cwd) {
|
|
468
|
+
export function detectHarness(cwd, env = process.env) {
|
|
326
469
|
const local = localHarnessEvidence(cwd);
|
|
327
470
|
if (local.length > 0)
|
|
328
471
|
return local.length === 1 ? local[0] : null;
|
|
329
|
-
const global = globalHarnessEvidence();
|
|
472
|
+
const global = globalHarnessEvidence(env);
|
|
330
473
|
return global.length === 1 ? global[0] : null;
|
|
331
474
|
}
|
|
332
475
|
function readCursorSettings(file) {
|
|
@@ -446,6 +589,12 @@ export function mergeHooks(existing, incoming, scriptPath) {
|
|
|
446
589
|
const replaced = [];
|
|
447
590
|
const removed = [];
|
|
448
591
|
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
592
|
+
if (!Array.isArray(groups)) {
|
|
593
|
+
// Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
|
|
594
|
+
;
|
|
595
|
+
hooks[event] = groups;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
449
598
|
const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath);
|
|
450
599
|
if (hadOurs) {
|
|
451
600
|
if (event in incoming)
|
|
@@ -467,6 +616,11 @@ export function removeHooks(existing, scriptPath) {
|
|
|
467
616
|
const hooks = {};
|
|
468
617
|
const replaced = [];
|
|
469
618
|
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
619
|
+
if (!Array.isArray(groups)) {
|
|
620
|
+
;
|
|
621
|
+
hooks[event] = groups;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
470
624
|
const { groups: foreign, removed } = withoutOurs(groups, scriptPath);
|
|
471
625
|
if (removed)
|
|
472
626
|
replaced.push(event);
|
|
@@ -486,12 +640,30 @@ export function removeHooks(existing, scriptPath) {
|
|
|
486
640
|
* support.
|
|
487
641
|
*/
|
|
488
642
|
export function applyPlan(file, document) {
|
|
489
|
-
|
|
643
|
+
const body = isTomlSettingsPath(file)
|
|
644
|
+
? `${stringifyToml(document)}\n`
|
|
645
|
+
: `${JSON.stringify(document, null, 2)}\n`;
|
|
646
|
+
atomicWriteFileSync(file, body, {
|
|
490
647
|
requireCurrentUserOwner: true,
|
|
491
648
|
});
|
|
492
649
|
}
|
|
493
650
|
export function loadSettings(file) {
|
|
494
|
-
return readSettings(file);
|
|
651
|
+
return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file);
|
|
652
|
+
}
|
|
653
|
+
function isTomlSettingsPath(file) {
|
|
654
|
+
return file.endsWith('.toml');
|
|
655
|
+
}
|
|
656
|
+
function readTomlSettings(file) {
|
|
657
|
+
if (!existsSync(file))
|
|
658
|
+
return {};
|
|
659
|
+
const source = readOwnedRegularFile(file);
|
|
660
|
+
try {
|
|
661
|
+
const parsed = parseToml(source);
|
|
662
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`);
|
|
666
|
+
}
|
|
495
667
|
}
|
|
496
668
|
export function loadCursorSettings(file) {
|
|
497
669
|
return readCursorSettings(file);
|
|
@@ -600,85 +772,84 @@ export function codexTrustProblems(installations, env = process.env) {
|
|
|
600
772
|
function isNotifaiCommand(command) {
|
|
601
773
|
return / hook (user-prompt-submit|stop|session-end)\b/.test(command);
|
|
602
774
|
}
|
|
603
|
-
function isNotifaiHandler(handler) {
|
|
604
|
-
return isNotifaiCommand(handler.command);
|
|
605
|
-
}
|
|
606
775
|
/** Every place either harness would read a Notifai handler from. */
|
|
607
776
|
export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
608
777
|
const found = [];
|
|
609
778
|
for (const harness of HARNESSES) {
|
|
610
779
|
for (const global of [false, true]) {
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
780
|
+
const files = hookDefinitionFiles(harness, global, cwd, env);
|
|
781
|
+
for (const file of files) {
|
|
782
|
+
if (!existsSync(file))
|
|
783
|
+
continue;
|
|
784
|
+
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
785
|
+
// is reported as one installation covering all three events rather than
|
|
786
|
+
// parsed for handlers.
|
|
787
|
+
if (harness === 'opencode') {
|
|
788
|
+
let source;
|
|
789
|
+
try {
|
|
790
|
+
source = readOwnedRegularFile(file);
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const target = opencodePluginTarget(source);
|
|
796
|
+
if (target === null)
|
|
797
|
+
continue;
|
|
798
|
+
const problems = [
|
|
799
|
+
...(!target.current
|
|
800
|
+
? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
|
|
801
|
+
: []),
|
|
802
|
+
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
803
|
+
? [
|
|
804
|
+
'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
|
|
805
|
+
]
|
|
806
|
+
: []),
|
|
807
|
+
];
|
|
808
|
+
found.push({
|
|
809
|
+
harness,
|
|
810
|
+
file,
|
|
811
|
+
global,
|
|
812
|
+
...(problems.length > 0 ? { problems } : {}),
|
|
813
|
+
// Reported as the command line the plugin will actually run, not as
|
|
814
|
+
// the plugin's own path: every check downstream asks which build a
|
|
815
|
+
// handler invokes, and for a module that answer lives inside it.
|
|
816
|
+
handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
|
|
817
|
+
event,
|
|
818
|
+
groupIndex: 0,
|
|
819
|
+
handlerIndex: 0,
|
|
820
|
+
command: hookCommand(target.adapter, hookEvent, 'opencode'),
|
|
821
|
+
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
822
|
+
})),
|
|
823
|
+
});
|
|
623
824
|
continue;
|
|
624
825
|
}
|
|
625
|
-
|
|
626
|
-
|
|
826
|
+
if (harness === 'cursor') {
|
|
827
|
+
let document;
|
|
828
|
+
try {
|
|
829
|
+
document = readCursorSettings(file);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const handlers = locateCursorHandlers(document);
|
|
835
|
+
if (handlers.length > 0) {
|
|
836
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome);
|
|
837
|
+
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
838
|
+
}
|
|
627
839
|
continue;
|
|
628
|
-
|
|
629
|
-
...(!target.current
|
|
630
|
-
? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
|
|
631
|
-
: []),
|
|
632
|
-
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
633
|
-
? [
|
|
634
|
-
'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
|
|
635
|
-
]
|
|
636
|
-
: []),
|
|
637
|
-
];
|
|
638
|
-
found.push({
|
|
639
|
-
harness,
|
|
640
|
-
file,
|
|
641
|
-
global,
|
|
642
|
-
...(problems.length > 0 ? { problems } : {}),
|
|
643
|
-
// Reported as the command line the plugin will actually run, not as
|
|
644
|
-
// the plugin's own path: every check downstream asks which build a
|
|
645
|
-
// handler invokes, and for a module that answer lives inside it.
|
|
646
|
-
handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
|
|
647
|
-
event,
|
|
648
|
-
groupIndex: 0,
|
|
649
|
-
handlerIndex: 0,
|
|
650
|
-
command: hookCommand(target.adapter, hookEvent, 'opencode'),
|
|
651
|
-
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
652
|
-
})),
|
|
653
|
-
});
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
if (harness === 'cursor') {
|
|
840
|
+
}
|
|
657
841
|
let document;
|
|
658
842
|
try {
|
|
659
|
-
document =
|
|
843
|
+
document = loadSettings(file);
|
|
660
844
|
}
|
|
661
845
|
catch {
|
|
662
846
|
continue;
|
|
663
847
|
}
|
|
664
|
-
const handlers =
|
|
848
|
+
const handlers = locateHandlers(document);
|
|
665
849
|
if (handlers.length > 0) {
|
|
666
850
|
const problems = harnessMarkerProblems(harness, handlers, adapterHome);
|
|
667
851
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
668
852
|
}
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
let document;
|
|
672
|
-
try {
|
|
673
|
-
document = readSettings(file);
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
const handlers = locateHandlers(document);
|
|
679
|
-
if (handlers.length > 0) {
|
|
680
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome);
|
|
681
|
-
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
682
853
|
}
|
|
683
854
|
}
|
|
684
855
|
}
|
|
@@ -712,27 +883,32 @@ function locateCursorHandlers(document) {
|
|
|
712
883
|
}
|
|
713
884
|
return handlers;
|
|
714
885
|
}
|
|
715
|
-
function
|
|
886
|
+
function locateAllHandlers(document) {
|
|
716
887
|
const handlers = [];
|
|
717
888
|
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
889
|
+
if (!Array.isArray(groups))
|
|
890
|
+
continue;
|
|
718
891
|
groups.forEach((group, groupIndex) => {
|
|
719
892
|
group.hooks?.forEach((handler, handlerIndex) => {
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
})
|
|
730
|
-
}
|
|
893
|
+
if (typeof handler?.command !== 'string')
|
|
894
|
+
return;
|
|
895
|
+
handlers.push({
|
|
896
|
+
event,
|
|
897
|
+
groupIndex,
|
|
898
|
+
handlerIndex,
|
|
899
|
+
command: handler.command,
|
|
900
|
+
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
901
|
+
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
902
|
+
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
903
|
+
});
|
|
731
904
|
});
|
|
732
905
|
});
|
|
733
906
|
}
|
|
734
907
|
return handlers;
|
|
735
908
|
}
|
|
909
|
+
function locateHandlers(document) {
|
|
910
|
+
return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command));
|
|
911
|
+
}
|
|
736
912
|
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
737
913
|
export function handlerEvent(command) {
|
|
738
914
|
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null;
|