@raidiant/notifai 0.1.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/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
20
|
+
* `notifai hook` event it runs). The plugin is a module and has no settings
|
|
21
|
+
* entries to read, so `findInstallations` reconstructs its handlers from this.
|
|
22
|
+
*/
|
|
23
|
+
const OPENCODE_EVENTS = [
|
|
24
|
+
['UserPromptSubmit', 'user-prompt-submit'],
|
|
25
|
+
['Stop', 'stop'],
|
|
26
|
+
['SessionEnd', 'session-end'],
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Harness hook installation.
|
|
31
|
+
*
|
|
32
|
+
* Claude Code's `settings.json` and Codex's `hooks.json` use the same shape —
|
|
33
|
+
* `hooks` maps an event name to matcher groups, each holding command handlers
|
|
34
|
+
* with an optional timeout — so one generator serves both. Only the file
|
|
35
|
+
* location and the event set differ.
|
|
36
|
+
*
|
|
37
|
+
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
38
|
+
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
39
|
+
* adapter instead of being forced through this shared document shape.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
43
|
+
export type Harness = (typeof HARNESSES)[number]
|
|
44
|
+
|
|
45
|
+
export interface HookHandler {
|
|
46
|
+
type: 'command'
|
|
47
|
+
command: string
|
|
48
|
+
timeout?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HookGroup {
|
|
52
|
+
matcher?: string
|
|
53
|
+
hooks: HookHandler[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type HookConfig = Record<string, HookGroup[]>
|
|
57
|
+
|
|
58
|
+
export interface InstallPlan {
|
|
59
|
+
harness: Harness
|
|
60
|
+
/** Absolute path of the settings file that will be written. */
|
|
61
|
+
file: string
|
|
62
|
+
/** True when the file is machine-wide rather than per-project. */
|
|
63
|
+
global: boolean
|
|
64
|
+
config: HookConfig
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The command each hook runs. Uses the absolute interpreter and script path
|
|
69
|
+
* rather than the bare binary name: hooks inherit a login-ish environment, and
|
|
70
|
+
* a `notifai` that is only on an interactive shell's PATH fails silently.
|
|
71
|
+
*/
|
|
72
|
+
export function hookCommand(
|
|
73
|
+
execPath: string,
|
|
74
|
+
scriptPath: string,
|
|
75
|
+
event: string,
|
|
76
|
+
harness?: 'cursor',
|
|
77
|
+
): string {
|
|
78
|
+
return (
|
|
79
|
+
`${quote(execPath)} ${quote(scriptPath)} hook ${event} ${OWNER_MARKER}` +
|
|
80
|
+
(harness === 'cursor' ? ' --harness cursor' : '')
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Says "NotifAI installed this" without saying which checkout did.
|
|
86
|
+
*
|
|
87
|
+
* Ownership used to be matched on the absolute script path, so installing from
|
|
88
|
+
* a second checkout did not recognise the first one's handlers as ours. Both
|
|
89
|
+
* stayed, the harness ran both, and one question produced two notifications;
|
|
90
|
+
* uninstalling the second silently left the first running. A marker that every
|
|
91
|
+
* NotifAI build writes and no build's path appears in fixes that by making
|
|
92
|
+
* ownership a property of the handler rather than of the machine it was
|
|
93
|
+
* installed from.
|
|
94
|
+
*
|
|
95
|
+
* It is a real (ignored) CLI flag rather than a comment because not every
|
|
96
|
+
* harness runs the command through a shell.
|
|
97
|
+
*/
|
|
98
|
+
export const OWNER_MARKER = '--owner notifai'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
|
|
102
|
+
* and backticks, so a checkout path containing shell syntax became command
|
|
103
|
+
* execution on every hook event. Single quotes suppress all expansion; the
|
|
104
|
+
* only escape needed is for a literal single quote.
|
|
105
|
+
*/
|
|
106
|
+
function quote(value: string): string {
|
|
107
|
+
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BuildOptions {
|
|
111
|
+
execPath: string
|
|
112
|
+
scriptPath: string
|
|
113
|
+
/** Seconds a blocking hook may wait for a companion-device answer. */
|
|
114
|
+
replyTimeoutSeconds: number
|
|
115
|
+
/** Seconds the question waits in the terminal before it is pushed at all. */
|
|
116
|
+
graceSeconds: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Command-hook harnesses kill at 600s; generated adapters use the same safe ceiling. */
|
|
120
|
+
const HOOK_CEILING_SECONDS = 590
|
|
121
|
+
|
|
122
|
+
/** Grace, reply wait, and teardown headroom for any blocking turn-end adapter. */
|
|
123
|
+
export function blockingHookTimeoutSeconds(graceSeconds: number, replyTimeoutSeconds: number): number {
|
|
124
|
+
return Math.min(HOOK_CEILING_SECONDS, graceSeconds + replyTimeoutSeconds + 60)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
128
|
+
const { execPath, scriptPath } = options
|
|
129
|
+
// Stop can spend the grace window AND then the reply wait, so the declared
|
|
130
|
+
// timeout must cover both. Headroom on top so the harness never kills the
|
|
131
|
+
// hook first; a killed hook loses the answer the user already gave.
|
|
132
|
+
const blockingTimeout = blockingHookTimeoutSeconds(
|
|
133
|
+
options.graceSeconds,
|
|
134
|
+
options.replyTimeoutSeconds,
|
|
135
|
+
)
|
|
136
|
+
return {
|
|
137
|
+
UserPromptSubmit: [
|
|
138
|
+
{
|
|
139
|
+
hooks: [
|
|
140
|
+
{
|
|
141
|
+
type: 'command',
|
|
142
|
+
command: hookCommand(execPath, scriptPath, 'user-prompt-submit'),
|
|
143
|
+
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
144
|
+
// a slow network can never delay the user's own prompt.
|
|
145
|
+
timeout: 15,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
Stop: [
|
|
151
|
+
{
|
|
152
|
+
hooks: [
|
|
153
|
+
{
|
|
154
|
+
type: 'command',
|
|
155
|
+
command: hookCommand(execPath, scriptPath, 'stop'),
|
|
156
|
+
timeout: blockingTimeout,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
SessionEnd: [
|
|
162
|
+
{
|
|
163
|
+
hooks: [
|
|
164
|
+
{
|
|
165
|
+
type: 'command',
|
|
166
|
+
command: hookCommand(execPath, scriptPath, 'session-end'),
|
|
167
|
+
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
168
|
+
// only touches local state.
|
|
169
|
+
timeout: 3,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface CursorHookHandler {
|
|
178
|
+
command: string
|
|
179
|
+
timeout?: number
|
|
180
|
+
loop_limit?: number | null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type CursorHookConfig = Record<string, CursorHookHandler[]>
|
|
184
|
+
|
|
185
|
+
interface CursorSettingsDocument {
|
|
186
|
+
version?: number
|
|
187
|
+
hooks?: CursorHookConfig
|
|
188
|
+
[key: string]: unknown
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
192
|
+
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
193
|
+
const blockingTimeout = blockingHookTimeoutSeconds(
|
|
194
|
+
options.graceSeconds,
|
|
195
|
+
options.replyTimeoutSeconds,
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
beforeSubmitPrompt: [
|
|
199
|
+
{
|
|
200
|
+
command: hookCommand(options.execPath, options.scriptPath, 'user-prompt-submit', 'cursor'),
|
|
201
|
+
timeout: 15,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
stop: [
|
|
205
|
+
{
|
|
206
|
+
command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
|
|
207
|
+
timeout: blockingTimeout,
|
|
208
|
+
// One companion-device answer produces one automatic follow-up turn. There is no
|
|
209
|
+
// useful reason for Cursor to repeat it, even if state cleanup fails.
|
|
210
|
+
loop_limit: 1,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
sessionEnd: [
|
|
214
|
+
{
|
|
215
|
+
command: hookCommand(options.execPath, options.scriptPath, 'session-end', 'cursor'),
|
|
216
|
+
timeout: 3,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Where each harness reads hooks from. Project installs deliberately target the
|
|
224
|
+
* gitignored file where one exists: which devices a person wants their
|
|
225
|
+
* questions pushed to is not a property of the repository.
|
|
226
|
+
*/
|
|
227
|
+
export function settingsFile(
|
|
228
|
+
harness: Harness,
|
|
229
|
+
global: boolean,
|
|
230
|
+
cwd: string,
|
|
231
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
232
|
+
): string {
|
|
233
|
+
// OpenCode has no settings document to merge into — its adapter is a
|
|
234
|
+
// generated plugin module, so it owns a whole file rather than a handler
|
|
235
|
+
// inside one. `opencodePluginPath` is its equivalent.
|
|
236
|
+
if (harness === 'opencode') return opencodePluginPath(global, cwd, env)
|
|
237
|
+
if (harness === 'cursor') {
|
|
238
|
+
const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
|
|
239
|
+
return global
|
|
240
|
+
? path.join(home, '.cursor', 'hooks.json')
|
|
241
|
+
: path.join(cwd, '.cursor', 'hooks.json')
|
|
242
|
+
}
|
|
243
|
+
if (harness === 'claude-code') {
|
|
244
|
+
return global
|
|
245
|
+
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
246
|
+
: path.join(cwd, '.claude', 'settings.local.json')
|
|
247
|
+
}
|
|
248
|
+
return global
|
|
249
|
+
? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
|
|
250
|
+
: path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
255
|
+
* working directory.
|
|
256
|
+
*
|
|
257
|
+
* Run from a linked git worktree, Codex reads `<main repo>/.codex/hooks.json`
|
|
258
|
+
* and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
259
|
+
* `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
|
|
260
|
+
* fired and an identical one at the worktree root did not; removing the main
|
|
261
|
+
* one left nothing firing at all. Writing to cwd therefore produces a silent
|
|
262
|
+
* no-op for anyone whose agent runs in a worktree, which is the normal case
|
|
263
|
+
* under tooling like Orca.
|
|
264
|
+
*
|
|
265
|
+
* Claude Code does not share this behaviour — it reads the worktree's own
|
|
266
|
+
* `.claude/settings.local.json` — so this deliberately applies to Codex only.
|
|
267
|
+
*/
|
|
268
|
+
export function codexProjectRoot(cwd: string): string {
|
|
269
|
+
const entry = findGitEntry(cwd)
|
|
270
|
+
if (entry === null) return cwd
|
|
271
|
+
try {
|
|
272
|
+
if (statSync(entry).isDirectory()) return path.dirname(entry)
|
|
273
|
+
const linked = worktreeGitDir(entry)
|
|
274
|
+
return linked === null ? cwd : path.dirname(commonGitDir(linked))
|
|
275
|
+
} catch {
|
|
276
|
+
// An unreadable or exotic checkout is not worth failing an install over;
|
|
277
|
+
// cwd is what we used to do and is right for every non-worktree case.
|
|
278
|
+
return cwd
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The `.codex` directory that has to exist for Codex to load project hooks at
|
|
284
|
+
* all, or null when nothing extra is needed.
|
|
285
|
+
*
|
|
286
|
+
* Codex splits the two halves of "which project am I in": it discovers the
|
|
287
|
+
* project layer by walking up from cwd for a `.codex` directory, but resolves
|
|
288
|
+
* `hooks.json` inside that layer against the main repository. In an ordinary
|
|
289
|
+
* checkout both land on the same directory and the split is invisible. In a
|
|
290
|
+
* worktree they diverge, and writing only the main repository's file leaves it
|
|
291
|
+
* unread — Codex never looks, because nothing at or above cwd told it there was
|
|
292
|
+
* a project layer to load. Proven 2026-08-03: with cwd in a worktree, an *empty*
|
|
293
|
+
* `.codex` directory there was the difference between the main repository's
|
|
294
|
+
* handler running and nothing running.
|
|
295
|
+
*/
|
|
296
|
+
export function codexLayerDir(cwd: string): string | null {
|
|
297
|
+
const entry = findGitEntry(cwd)
|
|
298
|
+
if (entry === null) return null
|
|
299
|
+
const worktreeRoot = path.dirname(entry)
|
|
300
|
+
if (path.resolve(worktreeRoot) === path.resolve(codexProjectRoot(cwd))) return null
|
|
301
|
+
return path.join(worktreeRoot, '.codex')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The nearest `.git` at or above `from`, or null outside a repository. */
|
|
305
|
+
function findGitEntry(from: string): string | null {
|
|
306
|
+
let dir = path.resolve(from)
|
|
307
|
+
for (;;) {
|
|
308
|
+
const entry = path.join(dir, '.git')
|
|
309
|
+
if (existsSync(entry)) return entry
|
|
310
|
+
const parent = path.dirname(dir)
|
|
311
|
+
if (parent === dir) return null
|
|
312
|
+
dir = parent
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** `.git` in a linked worktree is a file naming that worktree's git directory. */
|
|
317
|
+
function worktreeGitDir(file: string): string | null {
|
|
318
|
+
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(file, 'utf8'))
|
|
319
|
+
if (match === null) return null
|
|
320
|
+
return path.resolve(path.dirname(file), match[1]!.trim())
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A worktree's git directory records the shared one in `commondir`; that shared
|
|
325
|
+
* directory is the main repository's `.git`, whose parent is its root.
|
|
326
|
+
*/
|
|
327
|
+
function commonGitDir(gitDir: string): string {
|
|
328
|
+
const marker = path.join(gitDir, 'commondir')
|
|
329
|
+
if (!existsSync(marker)) return gitDir
|
|
330
|
+
return path.resolve(gitDir, readFileSync(marker, 'utf8').trim())
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Both harnesses let an environment variable relocate their whole config
|
|
335
|
+
* directory, and tooling that manages sessions does exactly that — Orca points
|
|
336
|
+
* `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
|
|
337
|
+
* into a file Codex never reads and the failure is silent. Observed 2026-08-02.
|
|
338
|
+
*/
|
|
339
|
+
export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string): string {
|
|
340
|
+
const override = env[variable]
|
|
341
|
+
return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
345
|
+
export function detectHarness(cwd: string): Harness | null {
|
|
346
|
+
const found: Harness[] = []
|
|
347
|
+
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(os.homedir(), '.claude'))) {
|
|
348
|
+
found.push('claude-code')
|
|
349
|
+
}
|
|
350
|
+
if (existsSync(path.join(cwd, '.codex')) || existsSync(path.join(os.homedir(), '.codex'))) {
|
|
351
|
+
found.push('codex')
|
|
352
|
+
}
|
|
353
|
+
if (existsSync(path.join(cwd, '.cursor')) || existsSync(path.join(os.homedir(), '.cursor'))) {
|
|
354
|
+
found.push('cursor')
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
existsSync(path.join(cwd, '.opencode')) ||
|
|
358
|
+
existsSync(path.join(os.homedir(), '.config', 'opencode'))
|
|
359
|
+
) {
|
|
360
|
+
found.push('opencode')
|
|
361
|
+
}
|
|
362
|
+
return found.length === 1 ? found[0]! : null
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
interface SettingsDocument {
|
|
366
|
+
hooks?: HookConfig
|
|
367
|
+
[key: string]: unknown
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readCursorSettings(file: string): CursorSettingsDocument {
|
|
371
|
+
if (!existsSync(file)) return { version: 1 }
|
|
372
|
+
try {
|
|
373
|
+
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
374
|
+
return typeof parsed === 'object' && parsed !== null
|
|
375
|
+
? (parsed as CursorSettingsDocument)
|
|
376
|
+
: { version: 1 }
|
|
377
|
+
} catch {
|
|
378
|
+
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isOurCommand(command: string, scriptPath: string): boolean {
|
|
383
|
+
if (command.includes(OWNER_MARKER)) return true
|
|
384
|
+
return (
|
|
385
|
+
command.includes(`${scriptPath}' hook `) ||
|
|
386
|
+
command.includes(`${scriptPath}" hook `) ||
|
|
387
|
+
command.includes(`${scriptPath} hook `)
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function mergeCursorHooks(
|
|
392
|
+
existing: CursorSettingsDocument,
|
|
393
|
+
incoming: CursorHookConfig,
|
|
394
|
+
scriptPath: string,
|
|
395
|
+
): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
|
|
396
|
+
const hooks: CursorHookConfig = {}
|
|
397
|
+
const added: string[] = []
|
|
398
|
+
const replaced: string[] = []
|
|
399
|
+
const removed: string[] = []
|
|
400
|
+
|
|
401
|
+
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
402
|
+
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
|
|
403
|
+
if (foreign.length !== handlers.length) {
|
|
404
|
+
if (event in incoming) replaced.push(event)
|
|
405
|
+
else removed.push(event)
|
|
406
|
+
}
|
|
407
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
408
|
+
}
|
|
409
|
+
for (const [event, handlers] of Object.entries(incoming)) {
|
|
410
|
+
if (!replaced.includes(event)) added.push(event)
|
|
411
|
+
hooks[event] = [...(hooks[event] ?? []), ...handlers]
|
|
412
|
+
}
|
|
413
|
+
return { document: { ...existing, version: 1, hooks }, added, replaced, removed }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function removeCursorHooks(
|
|
417
|
+
existing: CursorSettingsDocument,
|
|
418
|
+
scriptPath: string,
|
|
419
|
+
): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
|
|
420
|
+
const hooks: CursorHookConfig = {}
|
|
421
|
+
const replaced: string[] = []
|
|
422
|
+
for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
|
|
423
|
+
const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
|
|
424
|
+
if (foreign.length !== handlers.length) replaced.push(event)
|
|
425
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
426
|
+
}
|
|
427
|
+
return { document: { ...existing, version: 1, hooks }, added: [], replaced, removed: [] }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readSettings(file: string): SettingsDocument {
|
|
431
|
+
if (!existsSync(file)) return {}
|
|
432
|
+
try {
|
|
433
|
+
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
434
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
|
|
435
|
+
} catch {
|
|
436
|
+
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* True when a single handler is ours. Deliberately per-handler, not per-group:
|
|
442
|
+
* removing a whole group destroyed a user's own handler if they had added one
|
|
443
|
+
* beside ours inside the same matcher group.
|
|
444
|
+
*/
|
|
445
|
+
function isOurHandler(handler: HookHandler, scriptPath: string): boolean {
|
|
446
|
+
return isOurCommand(handler.command, scriptPath)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Drops only our handlers, keeping the group and anyone else's handlers. */
|
|
450
|
+
function withoutOurs(groups: HookGroup[], scriptPath: string): { groups: HookGroup[]; removed: boolean } {
|
|
451
|
+
let removed = false
|
|
452
|
+
const kept: HookGroup[] = []
|
|
453
|
+
for (const group of groups) {
|
|
454
|
+
const hooks = group.hooks.filter((handler) => {
|
|
455
|
+
const ours = isOurHandler(handler, scriptPath)
|
|
456
|
+
if (ours) removed = true
|
|
457
|
+
return !ours
|
|
458
|
+
})
|
|
459
|
+
if (hooks.length > 0) kept.push({ ...group, hooks })
|
|
460
|
+
}
|
|
461
|
+
return { groups: kept, removed }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface MergeResult {
|
|
465
|
+
document: SettingsDocument
|
|
466
|
+
added: string[]
|
|
467
|
+
replaced: string[]
|
|
468
|
+
/** Events an older build installed that this one no longer serves. */
|
|
469
|
+
removed: string[]
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Adds our handlers without disturbing anyone else's. Re-running replaces only
|
|
474
|
+
* the groups we previously wrote, so an upgrade that changes a timeout does not
|
|
475
|
+
* accumulate duplicate hooks.
|
|
476
|
+
*
|
|
477
|
+
* Handlers are stripped from EVERY event, not just the ones being installed.
|
|
478
|
+
* Only rewriting incoming events left a dropped event's handler in place for
|
|
479
|
+
* ever, and since the binary no longer implements it, it exited 2 with
|
|
480
|
+
* "Unknown hook event" every time the harness fired it — a permanent hook
|
|
481
|
+
* failure that reinstalling could not clear.
|
|
482
|
+
*/
|
|
483
|
+
export function mergeHooks(
|
|
484
|
+
existing: SettingsDocument,
|
|
485
|
+
incoming: HookConfig,
|
|
486
|
+
scriptPath: string,
|
|
487
|
+
): MergeResult {
|
|
488
|
+
const hooks: HookConfig = {}
|
|
489
|
+
const added: string[] = []
|
|
490
|
+
const replaced: string[] = []
|
|
491
|
+
const removed: string[] = []
|
|
492
|
+
|
|
493
|
+
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
494
|
+
const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
|
|
495
|
+
if (hadOurs) {
|
|
496
|
+
if (event in incoming) replaced.push(event)
|
|
497
|
+
else removed.push(event)
|
|
498
|
+
}
|
|
499
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const [event, groups] of Object.entries(incoming)) {
|
|
503
|
+
if (!replaced.includes(event)) added.push(event)
|
|
504
|
+
hooks[event] = [...(hooks[event] ?? []), ...groups]
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { document: { ...existing, hooks }, added, replaced, removed }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function removeHooks(existing: SettingsDocument, scriptPath: string): MergeResult {
|
|
511
|
+
const hooks: HookConfig = {}
|
|
512
|
+
const replaced: string[] = []
|
|
513
|
+
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
514
|
+
const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
|
|
515
|
+
if (removed) replaced.push(event)
|
|
516
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
517
|
+
}
|
|
518
|
+
return { document: { ...existing, hooks }, added: [], replaced, removed: [] }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Writes the settings file the way a tool that does not own it should.
|
|
523
|
+
*
|
|
524
|
+
* Truncating in place lost the user's whole harness configuration if the write
|
|
525
|
+
* failed halfway, and clobbered a concurrent editor's changes; following a
|
|
526
|
+
* symlink wrote through to a target they never named. So: refuse
|
|
527
|
+
* anything that is not a regular file, write a sibling temp file, fsync it, and
|
|
528
|
+
* rename over the original — atomic within a directory on every platform we
|
|
529
|
+
* support.
|
|
530
|
+
*/
|
|
531
|
+
export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
|
|
532
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
533
|
+
const mode = targetMode(file)
|
|
534
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.notifai-${process.pid}.tmp`)
|
|
535
|
+
|
|
536
|
+
const handle = openSync(temp, 'w', mode)
|
|
537
|
+
try {
|
|
538
|
+
writeFileSync(handle, `${JSON.stringify(document, null, 2)}\n`)
|
|
539
|
+
fsyncSync(handle)
|
|
540
|
+
} finally {
|
|
541
|
+
closeSync(handle)
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
renameSync(temp, file)
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Leaving the temp behind would be a second, quieter mess on top of the
|
|
547
|
+
// failure the caller is about to hear about.
|
|
548
|
+
try {
|
|
549
|
+
unlinkSync(temp)
|
|
550
|
+
} catch {
|
|
551
|
+
// Nothing useful to do; the rename error is the one that matters.
|
|
552
|
+
}
|
|
553
|
+
throw err
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
|
|
558
|
+
function targetMode(file: string): number {
|
|
559
|
+
let stat
|
|
560
|
+
try {
|
|
561
|
+
stat = lstatSync(file)
|
|
562
|
+
} catch (err) {
|
|
563
|
+
// A settings file can carry tokens, so a file we create is ours alone.
|
|
564
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0o600
|
|
565
|
+
throw err
|
|
566
|
+
}
|
|
567
|
+
if (stat.isSymbolicLink()) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
|
|
570
|
+
'or install to a different location.',
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
|
|
574
|
+
return stat.mode & 0o777
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function loadSettings(file: string): SettingsDocument {
|
|
578
|
+
return readSettings(file)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function loadCursorSettings(file: string): CursorSettingsDocument {
|
|
582
|
+
return readCursorSettings(file)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Discovery — answering "will my hooks actually run?" without a live test
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
/** One of our handlers as it sits in a settings file, with its position. */
|
|
590
|
+
export interface InstalledHandler {
|
|
591
|
+
event: string
|
|
592
|
+
/** Index of the matcher group within the event, as the harness sees it. */
|
|
593
|
+
groupIndex: number
|
|
594
|
+
/** Index of the handler within its group. */
|
|
595
|
+
handlerIndex: number
|
|
596
|
+
command: string
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export interface Installation {
|
|
600
|
+
harness: Harness
|
|
601
|
+
file: string
|
|
602
|
+
global: boolean
|
|
603
|
+
handlers: InstalledHandler[]
|
|
604
|
+
/** Structural defects that require reinstalling this generated adapter. */
|
|
605
|
+
problems?: string[]
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Any NotifAI handler, whatever checkout wrote it.
|
|
610
|
+
*
|
|
611
|
+
* Deliberately looser than `isOurHandler`: this answers "has NotifAI been set
|
|
612
|
+
* up here at all", and a handler installed from a second checkout is still
|
|
613
|
+
* evidence that it has (and is itself worth reporting).
|
|
614
|
+
*/
|
|
615
|
+
function isNotifaiCommand(command: string): boolean {
|
|
616
|
+
return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isNotifaiHandler(handler: HookHandler): boolean {
|
|
620
|
+
return isNotifaiCommand(handler.command)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Every place either harness would read a NotifAI handler from. */
|
|
624
|
+
export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
|
|
625
|
+
const found: Installation[] = []
|
|
626
|
+
for (const harness of HARNESSES) {
|
|
627
|
+
for (const global of [false, true]) {
|
|
628
|
+
const file = settingsFile(harness, global, cwd, env)
|
|
629
|
+
if (!existsSync(file)) continue
|
|
630
|
+
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
631
|
+
// is reported as one installation covering all three events rather than
|
|
632
|
+
// parsed for handlers.
|
|
633
|
+
if (harness === 'opencode') {
|
|
634
|
+
const target = opencodePluginTarget(readFileSync(file, 'utf8'))
|
|
635
|
+
if (target === null) continue
|
|
636
|
+
found.push({
|
|
637
|
+
harness,
|
|
638
|
+
file,
|
|
639
|
+
global,
|
|
640
|
+
...(!target.current
|
|
641
|
+
? { problems: ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`'] }
|
|
642
|
+
: {}),
|
|
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.exec, target.script, hookEvent),
|
|
651
|
+
})),
|
|
652
|
+
})
|
|
653
|
+
continue
|
|
654
|
+
}
|
|
655
|
+
if (harness === 'cursor') {
|
|
656
|
+
let document: CursorSettingsDocument
|
|
657
|
+
try {
|
|
658
|
+
document = readCursorSettings(file)
|
|
659
|
+
} catch {
|
|
660
|
+
continue
|
|
661
|
+
}
|
|
662
|
+
const handlers = locateCursorHandlers(document)
|
|
663
|
+
if (handlers.length > 0) found.push({ harness, file, global, handlers })
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
let document: SettingsDocument
|
|
667
|
+
try {
|
|
668
|
+
document = readSettings(file)
|
|
669
|
+
} catch {
|
|
670
|
+
continue
|
|
671
|
+
}
|
|
672
|
+
const handlers = locateHandlers(document)
|
|
673
|
+
if (handlers.length > 0) found.push({ harness, file, global, handlers })
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return found
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
|
|
680
|
+
const handlers: InstalledHandler[] = []
|
|
681
|
+
for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
|
|
682
|
+
eventHandlers.forEach((handler, handlerIndex) => {
|
|
683
|
+
if (isNotifaiCommand(handler.command)) {
|
|
684
|
+
handlers.push({ event, groupIndex: 0, handlerIndex, command: handler.command })
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
return handlers
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
692
|
+
const handlers: InstalledHandler[] = []
|
|
693
|
+
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
694
|
+
groups.forEach((group, groupIndex) => {
|
|
695
|
+
group.hooks?.forEach((handler, handlerIndex) => {
|
|
696
|
+
if (isNotifaiHandler(handler)) {
|
|
697
|
+
handlers.push({ event, groupIndex, handlerIndex, command: handler.command })
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
return handlers
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
706
|
+
export function handlerEvent(command: string): string | null {
|
|
707
|
+
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
|
|
708
|
+
}
|