@raidiant/notifai 1.0.1 → 1.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/CHANGELOG.md +17 -0
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +31 -39
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.js +2 -1
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +50 -21
- package/dist/install-hooks.js +141 -57
- package/dist/install-hooks.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +3 -3
- package/src/commands.test.ts +100 -26
- package/src/commands.ts +49 -36
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +27 -1
- package/src/hooks.ts +2 -1
- package/src/install-hooks.test.ts +174 -33
- package/src/install-hooks.ts +186 -54
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/ui/config-view.ts +5 -5
package/dist/install-hooks.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HookHostPlatform } from './hook-adapter.js';
|
|
1
2
|
import { type Harness } from './harnesses.js';
|
|
2
3
|
export { HARNESSES, type Harness } from './harnesses.js';
|
|
3
4
|
/**
|
|
@@ -6,10 +7,10 @@ export { HARNESSES, type Harness } from './harnesses.js';
|
|
|
6
7
|
* Claude Code's `settings.json` and Codex's hook files use the same shape —
|
|
7
8
|
* `hooks` maps an event name to matcher groups, each holding command handlers
|
|
8
9
|
* with an optional timeout — so one generator serves both. Only the file
|
|
9
|
-
* location and the event set differ. Codex
|
|
10
|
-
* `hooks.json
|
|
11
|
-
*
|
|
12
|
-
* layer, never both.
|
|
10
|
+
* location and the event set differ. Codex supports either a dedicated
|
|
11
|
+
* `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
|
|
12
|
+
* We default to the dedicated file so installing hooks never rewrites Codex's
|
|
13
|
+
* main configuration and trust store. One representation per layer, never both.
|
|
13
14
|
*
|
|
14
15
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
15
16
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -35,12 +36,21 @@ export interface InstallPlan {
|
|
|
35
36
|
global: boolean;
|
|
36
37
|
config: HookConfig;
|
|
37
38
|
}
|
|
39
|
+
export interface HookCommandOptions {
|
|
40
|
+
platform?: NodeJS.Platform | HookHostPlatform;
|
|
41
|
+
/** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
|
|
42
|
+
nodePath?: string;
|
|
43
|
+
}
|
|
38
44
|
/**
|
|
39
45
|
* The command each hook runs. Harness definitions know only the stable
|
|
40
46
|
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
41
|
-
* paths live behind that seam and never enter a trusted hook identity
|
|
47
|
+
* paths live behind that seam and never enter a trusted hook identity —
|
|
48
|
+
* except on Windows, where CreateProcess cannot run the adapter without an
|
|
49
|
+
* explicit Node executable, so the registered interpreter is named first.
|
|
42
50
|
*/
|
|
43
|
-
export declare function hookCommand(adapterPath: string, event: string, harness?: Harness): string;
|
|
51
|
+
export declare function hookCommand(adapterPath: string, event: string, harness?: Harness, options?: HookCommandOptions): string;
|
|
52
|
+
/** The leading argv that every Notifai handler must start with. */
|
|
53
|
+
export declare function hookCommandPrefix(adapterPath: string, options?: HookCommandOptions): string;
|
|
44
54
|
/**
|
|
45
55
|
* Says "Notifai installed this" without saying which checkout did.
|
|
46
56
|
*
|
|
@@ -56,11 +66,21 @@ export declare function hookCommand(adapterPath: string, event: string, harness?
|
|
|
56
66
|
* harness runs the command through a shell.
|
|
57
67
|
*/
|
|
58
68
|
export declare const OWNER_MARKER = "--owner notifai";
|
|
69
|
+
/**
|
|
70
|
+
* Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
|
|
71
|
+
* it as a single argument. Always used for the Node and adapter paths, which
|
|
72
|
+
* routinely contain spaces (`Program Files`) and must not use POSIX quotes
|
|
73
|
+
* inside JSON or TOML harness documents.
|
|
74
|
+
*/
|
|
75
|
+
export declare function quoteWindowsArg(value: string): string;
|
|
59
76
|
export interface BuildOptions {
|
|
60
77
|
/** Stable user-level executable installed by the hook adapter module. */
|
|
61
78
|
adapterPath: string;
|
|
62
79
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
63
80
|
harness?: Harness;
|
|
81
|
+
platform?: NodeJS.Platform | HookHostPlatform;
|
|
82
|
+
/** Registered Node named first in Windows hook commands. */
|
|
83
|
+
nodePath?: string;
|
|
64
84
|
}
|
|
65
85
|
/**
|
|
66
86
|
* Claude Code's Stop handler returns instantly and the same process lives on
|
|
@@ -106,7 +126,14 @@ export declare function buildCursorHookConfig(options: BuildOptions): CursorHook
|
|
|
106
126
|
* gitignored file where one exists: which devices a person wants their
|
|
107
127
|
* questions pushed to is not a property of the repository.
|
|
108
128
|
*/
|
|
109
|
-
export declare function settingsFile(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv): string;
|
|
129
|
+
export declare function settingsFile(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
|
|
130
|
+
/**
|
|
131
|
+
* The account home harness config roots are resolved against.
|
|
132
|
+
*
|
|
133
|
+
* Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
|
|
134
|
+
* Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
|
|
135
|
+
*/
|
|
136
|
+
export declare function harnessAccountHome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
|
|
110
137
|
/**
|
|
111
138
|
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
112
139
|
* working directory.
|
|
@@ -139,19 +166,21 @@ export declare function codexProjectRoot(cwd: string): string;
|
|
|
139
166
|
*/
|
|
140
167
|
export declare function codexLayerDir(cwd: string): string | null;
|
|
141
168
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* `CODEX_HOME
|
|
145
|
-
*
|
|
169
|
+
* Harness-specific home variables relocate the whole active harness home.
|
|
170
|
+
* This matters under session managers that give each Codex account its own
|
|
171
|
+
* `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
|
|
172
|
+
* different Codex installation than the one running the command.
|
|
146
173
|
*/
|
|
147
|
-
export declare function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string): string;
|
|
174
|
+
export declare function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string, platform?: NodeJS.Platform | HookHostPlatform): string;
|
|
175
|
+
/** User-global Codex config directory for the active Codex installation. */
|
|
176
|
+
export declare function codexGlobalDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string;
|
|
148
177
|
export interface CodexLayerPaths {
|
|
149
178
|
dir: string;
|
|
150
179
|
hooksJson: string;
|
|
151
180
|
configToml: string;
|
|
152
181
|
}
|
|
153
182
|
/** The two files Codex will look at for one config layer. */
|
|
154
|
-
export declare function codexLayerPaths(global: boolean, cwd: string, env?: NodeJS.ProcessEnv): CodexLayerPaths;
|
|
183
|
+
export declare function codexLayerPaths(global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): CodexLayerPaths;
|
|
155
184
|
export interface CodexLayerInspection {
|
|
156
185
|
paths: CodexLayerPaths;
|
|
157
186
|
jsonEvents: string[];
|
|
@@ -164,15 +193,15 @@ export interface CodexLayerInspection {
|
|
|
164
193
|
/**
|
|
165
194
|
* Which representation this Codex layer already uses.
|
|
166
195
|
*
|
|
167
|
-
* `hooks.json`
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
196
|
+
* Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
|
|
197
|
+
* and runs every matching handler. Prefer the dedicated file for a new layer;
|
|
198
|
+
* preserve an existing single representation so installing Notifai never
|
|
199
|
+
* invents a dual layer. `[hooks.state]` is the trust store, not a hook
|
|
171
200
|
* definition, so it does not count as a representation.
|
|
172
201
|
*/
|
|
173
202
|
export declare function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection;
|
|
174
203
|
/** Every file in this layer Codex might already be reading hooks from. */
|
|
175
|
-
export declare function hookDefinitionFiles(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv): string[];
|
|
204
|
+
export declare function hookDefinitionFiles(harness: Harness, global: boolean, cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): string[];
|
|
176
205
|
/**
|
|
177
206
|
* Drop the unused representation after writing the one this layer already had.
|
|
178
207
|
*
|
|
@@ -189,7 +218,7 @@ export declare function codexRepresentationProblems(cwd: string, env?: NodeJS.Pr
|
|
|
189
218
|
*
|
|
190
219
|
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
191
220
|
*/
|
|
192
|
-
export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv): Harness[];
|
|
221
|
+
export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): Harness[];
|
|
193
222
|
/**
|
|
194
223
|
* Which *single* harness to wire when a caller still needs exactly one, or
|
|
195
224
|
* null when it is genuinely unclear.
|
|
@@ -199,7 +228,7 @@ export declare function detectedHarnesses(cwd: string, env?: NodeJS.ProcessEnv):
|
|
|
199
228
|
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
200
229
|
* this helper stays for uninstall and any caller that cannot take a list.
|
|
201
230
|
*/
|
|
202
|
-
export declare function detectHarness(cwd: string, env?: NodeJS.ProcessEnv): Harness | null;
|
|
231
|
+
export declare function detectHarness(cwd: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform | HookHostPlatform): Harness | null;
|
|
203
232
|
export interface SettingsDocument {
|
|
204
233
|
hooks?: HookConfig;
|
|
205
234
|
[key: string]: unknown;
|
|
@@ -288,6 +317,6 @@ export declare function codexTrustKey(installation: Installation, handler: Insta
|
|
|
288
317
|
*/
|
|
289
318
|
export declare function codexTrustProblems(installations: Installation[], env?: NodeJS.ProcessEnv): string[];
|
|
290
319
|
/** Every place either harness would read a Notifai handler from. */
|
|
291
|
-
export declare function findInstallations(cwd: string, env?: NodeJS.ProcessEnv, adapterHome?: string): Installation[];
|
|
320
|
+
export declare function findInstallations(cwd: string, env?: NodeJS.ProcessEnv, adapterHome?: string, platform?: NodeJS.Platform | HookHostPlatform): Installation[];
|
|
292
321
|
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
293
322
|
export declare function handlerEvent(command: string): string | null;
|
package/dist/install-hooks.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
5
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
7
|
-
import { hookAdapterPath } from './hook-adapter.js';
|
|
6
|
+
import { hookAdapterPath, hookHostPlatform, inspectHookAdapter, } from './hook-adapter.js';
|
|
8
7
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js';
|
|
9
8
|
import { HARNESSES } from './harnesses.js';
|
|
9
|
+
import { accountHome } from './platform.js';
|
|
10
10
|
export { HARNESSES } from './harnesses.js';
|
|
11
11
|
/**
|
|
12
12
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
@@ -21,12 +21,23 @@ const OPENCODE_EVENTS = [
|
|
|
21
21
|
/**
|
|
22
22
|
* The command each hook runs. Harness definitions know only the stable
|
|
23
23
|
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
24
|
-
* paths live behind that seam and never enter a trusted hook identity
|
|
24
|
+
* paths live behind that seam and never enter a trusted hook identity —
|
|
25
|
+
* except on Windows, where CreateProcess cannot run the adapter without an
|
|
26
|
+
* explicit Node executable, so the registered interpreter is named first.
|
|
25
27
|
*/
|
|
26
|
-
export function hookCommand(adapterPath, event, harness) {
|
|
27
|
-
return (`${
|
|
28
|
+
export function hookCommand(adapterPath, event, harness, options = {}) {
|
|
29
|
+
return (`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
|
|
28
30
|
(harness === undefined ? '' : ` --harness ${harness}`));
|
|
29
31
|
}
|
|
32
|
+
/** The leading argv that every Notifai handler must start with. */
|
|
33
|
+
export function hookCommandPrefix(adapterPath, options = {}) {
|
|
34
|
+
const host = hookHostPlatform(options.platform);
|
|
35
|
+
if (host === 'win32') {
|
|
36
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
37
|
+
return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `;
|
|
38
|
+
}
|
|
39
|
+
return `${quote(adapterPath)} `;
|
|
40
|
+
}
|
|
30
41
|
/**
|
|
31
42
|
* Says "Notifai installed this" without saying which checkout did.
|
|
32
43
|
*
|
|
@@ -51,6 +62,30 @@ export const OWNER_MARKER = '--owner notifai';
|
|
|
51
62
|
function quote(value) {
|
|
52
63
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
53
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
|
|
67
|
+
* it as a single argument. Always used for the Node and adapter paths, which
|
|
68
|
+
* routinely contain spaces (`Program Files`) and must not use POSIX quotes
|
|
69
|
+
* inside JSON or TOML harness documents.
|
|
70
|
+
*/
|
|
71
|
+
export function quoteWindowsArg(value) {
|
|
72
|
+
let out = '"';
|
|
73
|
+
let slashes = 0;
|
|
74
|
+
for (const ch of value) {
|
|
75
|
+
if (ch === '\\') {
|
|
76
|
+
slashes += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"') {
|
|
80
|
+
out += '\\'.repeat(slashes * 2 + 1) + '"';
|
|
81
|
+
slashes = 0;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
out += '\\'.repeat(slashes) + ch;
|
|
85
|
+
slashes = 0;
|
|
86
|
+
}
|
|
87
|
+
return `${out}${'\\'.repeat(slashes * 2)}"`;
|
|
88
|
+
}
|
|
54
89
|
/**
|
|
55
90
|
* Claude Code's Stop handler returns instantly and the same process lives on
|
|
56
91
|
* out of band as the escalation waiter, so this `timeout` bounds a background
|
|
@@ -86,24 +121,31 @@ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540;
|
|
|
86
121
|
* declared ceiling above the wait — except Codex, which owns that number
|
|
87
122
|
* itself and whose definition must stay byte-stable for its trust store.
|
|
88
123
|
*/
|
|
89
|
-
function stopHandler(adapterPath, harness) {
|
|
90
|
-
const command = hookCommand(adapterPath, 'stop', harness);
|
|
91
|
-
if (harness === 'claude-code') {
|
|
124
|
+
function stopHandler(adapterPath, harness, options) {
|
|
125
|
+
const command = hookCommand(adapterPath, 'stop', harness, options);
|
|
126
|
+
if (harness === 'claude-code' && hookHostPlatform(options.platform) === 'posix') {
|
|
92
127
|
return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true };
|
|
93
128
|
}
|
|
94
129
|
if (harness === 'codex')
|
|
95
130
|
return { type: 'command', command };
|
|
96
131
|
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS };
|
|
97
132
|
}
|
|
133
|
+
function commandOptionsFrom(options) {
|
|
134
|
+
return {
|
|
135
|
+
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
136
|
+
...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
98
139
|
export function buildHookConfig(options) {
|
|
99
140
|
const { adapterPath } = options;
|
|
141
|
+
const commandOptions = commandOptionsFrom(options);
|
|
100
142
|
return {
|
|
101
143
|
UserPromptSubmit: [
|
|
102
144
|
{
|
|
103
145
|
hooks: [
|
|
104
146
|
{
|
|
105
147
|
type: 'command',
|
|
106
|
-
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
|
|
148
|
+
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
|
|
107
149
|
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
108
150
|
// a slow network can never delay the user's own prompt.
|
|
109
151
|
timeout: 15,
|
|
@@ -111,13 +153,13 @@ export function buildHookConfig(options) {
|
|
|
111
153
|
],
|
|
112
154
|
},
|
|
113
155
|
],
|
|
114
|
-
Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
|
|
156
|
+
Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
|
|
115
157
|
SessionEnd: [
|
|
116
158
|
{
|
|
117
159
|
hooks: [
|
|
118
160
|
{
|
|
119
161
|
type: 'command',
|
|
120
|
-
command: hookCommand(adapterPath, 'session-end', options.harness),
|
|
162
|
+
command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
|
|
121
163
|
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
122
164
|
// only touches local state.
|
|
123
165
|
timeout: 3,
|
|
@@ -129,16 +171,17 @@ export function buildHookConfig(options) {
|
|
|
129
171
|
}
|
|
130
172
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
131
173
|
export function buildCursorHookConfig(options) {
|
|
174
|
+
const commandOptions = commandOptionsFrom(options);
|
|
132
175
|
return {
|
|
133
176
|
beforeSubmitPrompt: [
|
|
134
177
|
{
|
|
135
|
-
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
|
|
178
|
+
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
|
|
136
179
|
timeout: 15,
|
|
137
180
|
},
|
|
138
181
|
],
|
|
139
182
|
stop: [
|
|
140
183
|
{
|
|
141
|
-
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
184
|
+
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
142
185
|
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
143
186
|
// A continuation may register a real follow-up question. Match the
|
|
144
187
|
// session-state cap so those chains are useful but never unbounded.
|
|
@@ -147,7 +190,7 @@ export function buildCursorHookConfig(options) {
|
|
|
147
190
|
],
|
|
148
191
|
sessionEnd: [
|
|
149
192
|
{
|
|
150
|
-
command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
|
|
193
|
+
command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
|
|
151
194
|
timeout: 3,
|
|
152
195
|
},
|
|
153
196
|
],
|
|
@@ -158,28 +201,37 @@ export function buildCursorHookConfig(options) {
|
|
|
158
201
|
* gitignored file where one exists: which devices a person wants their
|
|
159
202
|
* questions pushed to is not a property of the repository.
|
|
160
203
|
*/
|
|
161
|
-
export function settingsFile(harness, global, cwd, env = process.env) {
|
|
204
|
+
export function settingsFile(harness, global, cwd, env = process.env, platform = process.platform) {
|
|
162
205
|
switch (harness) {
|
|
163
206
|
// OpenCode has no settings document to merge into — its adapter is a
|
|
164
207
|
// generated plugin module, so it owns a whole file instead.
|
|
165
208
|
case 'opencode':
|
|
166
|
-
return opencodePluginPath(global, cwd, env);
|
|
209
|
+
return opencodePluginPath(global, cwd, env, platform);
|
|
167
210
|
case 'cursor': {
|
|
168
|
-
const home = env
|
|
211
|
+
const home = harnessAccountHome(env, platform);
|
|
169
212
|
return global
|
|
170
213
|
? path.join(home, '.cursor', 'hooks.json')
|
|
171
214
|
: path.join(cwd, '.cursor', 'hooks.json');
|
|
172
215
|
}
|
|
173
216
|
case 'claude-code':
|
|
174
217
|
return global
|
|
175
|
-
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
218
|
+
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
|
|
176
219
|
: path.join(cwd, '.claude', 'settings.local.json');
|
|
177
220
|
case 'codex':
|
|
178
|
-
return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget;
|
|
221
|
+
return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget;
|
|
179
222
|
default:
|
|
180
223
|
return assertNeverHarness(harness);
|
|
181
224
|
}
|
|
182
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* The account home harness config roots are resolved against.
|
|
228
|
+
*
|
|
229
|
+
* Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
|
|
230
|
+
* Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
|
|
231
|
+
*/
|
|
232
|
+
export function harnessAccountHome(env = process.env, platform = process.platform) {
|
|
233
|
+
return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux');
|
|
234
|
+
}
|
|
183
235
|
function assertNeverHarness(harness) {
|
|
184
236
|
throw new Error(`Unsupported harness: ${String(harness)}`);
|
|
185
237
|
}
|
|
@@ -268,19 +320,26 @@ function commonGitDir(gitDir) {
|
|
|
268
320
|
return path.resolve(gitDir, readFileSync(marker, 'utf8').trim());
|
|
269
321
|
}
|
|
270
322
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* `CODEX_HOME
|
|
274
|
-
*
|
|
323
|
+
* Harness-specific home variables relocate the whole active harness home.
|
|
324
|
+
* This matters under session managers that give each Codex account its own
|
|
325
|
+
* `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
|
|
326
|
+
* different Codex installation than the one running the command.
|
|
275
327
|
*/
|
|
276
|
-
export function configHome(env, variable, fallback) {
|
|
328
|
+
export function configHome(env, variable, fallback, platform = process.platform) {
|
|
277
329
|
const override = env[variable];
|
|
278
|
-
|
|
330
|
+
if (override !== undefined && override !== '')
|
|
331
|
+
return override;
|
|
332
|
+
const home = harnessAccountHome(env, platform);
|
|
333
|
+
return path.join(home, fallback);
|
|
334
|
+
}
|
|
335
|
+
/** User-global Codex config directory for the active Codex installation. */
|
|
336
|
+
export function codexGlobalDir(env = process.env, platform = process.platform) {
|
|
337
|
+
return configHome(env, 'CODEX_HOME', '.codex', platform);
|
|
279
338
|
}
|
|
280
339
|
/** The two files Codex will look at for one config layer. */
|
|
281
|
-
export function codexLayerPaths(global, cwd, env = process.env) {
|
|
340
|
+
export function codexLayerPaths(global, cwd, env = process.env, platform = process.platform) {
|
|
282
341
|
const dir = global
|
|
283
|
-
?
|
|
342
|
+
? codexGlobalDir(env, platform)
|
|
284
343
|
: path.join(codexProjectRoot(cwd), '.codex');
|
|
285
344
|
return {
|
|
286
345
|
dir,
|
|
@@ -291,10 +350,10 @@ export function codexLayerPaths(global, cwd, env = process.env) {
|
|
|
291
350
|
/**
|
|
292
351
|
* Which representation this Codex layer already uses.
|
|
293
352
|
*
|
|
294
|
-
* `hooks.json`
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
353
|
+
* Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
|
|
354
|
+
* and runs every matching handler. Prefer the dedicated file for a new layer;
|
|
355
|
+
* preserve an existing single representation so installing Notifai never
|
|
356
|
+
* invents a dual layer. `[hooks.state]` is the trust store, not a hook
|
|
298
357
|
* definition, so it does not count as a representation.
|
|
299
358
|
*/
|
|
300
359
|
export function inspectCodexLayer(paths) {
|
|
@@ -304,15 +363,24 @@ export function inspectCodexLayer(paths) {
|
|
|
304
363
|
const tomlEvents = hookEventNames(tomlDocument?.hooks);
|
|
305
364
|
const jsonForeign = documentHasForeignHandlers(jsonDocument);
|
|
306
365
|
const tomlForeign = documentHasForeignHandlers(tomlDocument);
|
|
366
|
+
const jsonOurs = documentHasOurHandlers(jsonDocument);
|
|
367
|
+
const tomlOurs = documentHasOurHandlers(tomlDocument);
|
|
307
368
|
const dual = jsonEvents.length > 0 && tomlEvents.length > 0;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
369
|
+
let writeTarget = paths.hooksJson;
|
|
370
|
+
if (tomlEvents.length > 0 && jsonEvents.length === 0) {
|
|
371
|
+
writeTarget = paths.configToml;
|
|
372
|
+
}
|
|
373
|
+
else if (dual) {
|
|
374
|
+
if (tomlForeign && !jsonForeign)
|
|
375
|
+
writeTarget = paths.configToml;
|
|
376
|
+
else if (jsonForeign && !tomlForeign)
|
|
377
|
+
writeTarget = paths.hooksJson;
|
|
378
|
+
else if (!jsonForeign && !tomlForeign)
|
|
379
|
+
writeTarget = paths.hooksJson;
|
|
380
|
+
else if (tomlOurs && !jsonOurs)
|
|
381
|
+
writeTarget = paths.configToml;
|
|
382
|
+
else
|
|
383
|
+
writeTarget = paths.hooksJson;
|
|
316
384
|
}
|
|
317
385
|
return {
|
|
318
386
|
paths,
|
|
@@ -325,10 +393,10 @@ export function inspectCodexLayer(paths) {
|
|
|
325
393
|
};
|
|
326
394
|
}
|
|
327
395
|
/** Every file in this layer Codex might already be reading hooks from. */
|
|
328
|
-
export function hookDefinitionFiles(harness, global, cwd, env = process.env) {
|
|
396
|
+
export function hookDefinitionFiles(harness, global, cwd, env = process.env, platform = process.platform) {
|
|
329
397
|
if (harness !== 'codex')
|
|
330
|
-
return [settingsFile(harness, global, cwd, env)];
|
|
331
|
-
const paths = codexLayerPaths(global, cwd, env);
|
|
398
|
+
return [settingsFile(harness, global, cwd, env, platform)];
|
|
399
|
+
const paths = codexLayerPaths(global, cwd, env, platform);
|
|
332
400
|
return [paths.hooksJson, paths.configToml];
|
|
333
401
|
}
|
|
334
402
|
/**
|
|
@@ -389,6 +457,11 @@ function documentHasForeignHandlers(document) {
|
|
|
389
457
|
return false;
|
|
390
458
|
return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command));
|
|
391
459
|
}
|
|
460
|
+
function documentHasOurHandlers(document) {
|
|
461
|
+
if (document === null)
|
|
462
|
+
return false;
|
|
463
|
+
return locateHandlers(document).length > 0;
|
|
464
|
+
}
|
|
392
465
|
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
393
466
|
/**
|
|
394
467
|
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
@@ -417,12 +490,12 @@ function localHarnessEvidence(cwd) {
|
|
|
417
490
|
return found;
|
|
418
491
|
}
|
|
419
492
|
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
420
|
-
function globalHarnessEvidence(env = process.env) {
|
|
421
|
-
const home = env
|
|
493
|
+
function globalHarnessEvidence(env = process.env, platform = process.platform) {
|
|
494
|
+
const home = harnessAccountHome(env, platform);
|
|
422
495
|
const found = [];
|
|
423
496
|
if (existsSync(path.join(home, '.claude')))
|
|
424
497
|
found.push('claude-code');
|
|
425
|
-
if (existsSync(
|
|
498
|
+
if (existsSync(codexGlobalDir(env, platform)))
|
|
426
499
|
found.push('codex');
|
|
427
500
|
if (existsSync(path.join(home, '.cursor')))
|
|
428
501
|
found.push('cursor');
|
|
@@ -437,8 +510,11 @@ function globalHarnessEvidence(env = process.env) {
|
|
|
437
510
|
*
|
|
438
511
|
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
439
512
|
*/
|
|
440
|
-
export function detectedHarnesses(cwd, env = process.env) {
|
|
441
|
-
const seen = new Set([
|
|
513
|
+
export function detectedHarnesses(cwd, env = process.env, platform = process.platform) {
|
|
514
|
+
const seen = new Set([
|
|
515
|
+
...localHarnessEvidence(cwd),
|
|
516
|
+
...globalHarnessEvidence(env, platform),
|
|
517
|
+
]);
|
|
442
518
|
return HARNESSES.filter((harness) => seen.has(harness));
|
|
443
519
|
}
|
|
444
520
|
/**
|
|
@@ -450,11 +526,11 @@ export function detectedHarnesses(cwd, env = process.env) {
|
|
|
450
526
|
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
451
527
|
* this helper stays for uninstall and any caller that cannot take a list.
|
|
452
528
|
*/
|
|
453
|
-
export function detectHarness(cwd, env = process.env) {
|
|
529
|
+
export function detectHarness(cwd, env = process.env, platform = process.platform) {
|
|
454
530
|
const local = localHarnessEvidence(cwd);
|
|
455
531
|
if (local.length > 0)
|
|
456
532
|
return local.length === 1 ? local[0] : null;
|
|
457
|
-
const global = globalHarnessEvidence(env);
|
|
533
|
+
const global = globalHarnessEvidence(env, platform);
|
|
458
534
|
return global.length === 1 ? global[0] : null;
|
|
459
535
|
}
|
|
460
536
|
function readCursorSettings(file) {
|
|
@@ -688,7 +764,7 @@ export function codexHookIdentityHash(handler) {
|
|
|
688
764
|
return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`;
|
|
689
765
|
}
|
|
690
766
|
function codexTrustState(env) {
|
|
691
|
-
const file = path.join(
|
|
767
|
+
const file = path.join(codexGlobalDir(env), 'config.toml');
|
|
692
768
|
if (!existsSync(file))
|
|
693
769
|
return {};
|
|
694
770
|
try {
|
|
@@ -758,11 +834,16 @@ function isNotifaiCommand(command) {
|
|
|
758
834
|
return / hook (user-prompt-submit|stop|session-end)\b/.test(command);
|
|
759
835
|
}
|
|
760
836
|
/** Every place either harness would read a Notifai handler from. */
|
|
761
|
-
export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
837
|
+
export function findInstallations(cwd, env = process.env, adapterHome, platform = process.platform) {
|
|
838
|
+
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath;
|
|
839
|
+
const commandOptions = {
|
|
840
|
+
platform,
|
|
841
|
+
...(nodePath === undefined ? {} : { nodePath }),
|
|
842
|
+
};
|
|
762
843
|
const found = [];
|
|
763
844
|
for (const harness of HARNESSES) {
|
|
764
845
|
for (const global of [false, true]) {
|
|
765
|
-
const files = hookDefinitionFiles(harness, global, cwd, env);
|
|
846
|
+
const files = hookDefinitionFiles(harness, global, cwd, env, platform);
|
|
766
847
|
for (const file of files) {
|
|
767
848
|
if (!existsSync(file))
|
|
768
849
|
continue;
|
|
@@ -802,7 +883,10 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
|
802
883
|
event,
|
|
803
884
|
groupIndex: 0,
|
|
804
885
|
handlerIndex: 0,
|
|
805
|
-
command: hookCommand(target.adapter, hookEvent, 'opencode'
|
|
886
|
+
command: hookCommand(target.adapter, hookEvent, 'opencode', {
|
|
887
|
+
...commandOptions,
|
|
888
|
+
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
889
|
+
}),
|
|
806
890
|
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
807
891
|
})),
|
|
808
892
|
});
|
|
@@ -818,7 +902,7 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
|
818
902
|
}
|
|
819
903
|
const handlers = locateCursorHandlers(document);
|
|
820
904
|
if (handlers.length > 0) {
|
|
821
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome);
|
|
905
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
822
906
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
823
907
|
}
|
|
824
908
|
continue;
|
|
@@ -832,7 +916,7 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
|
832
916
|
}
|
|
833
917
|
const handlers = locateHandlers(document);
|
|
834
918
|
if (handlers.length > 0) {
|
|
835
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome);
|
|
919
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions);
|
|
836
920
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) });
|
|
837
921
|
}
|
|
838
922
|
}
|
|
@@ -840,12 +924,12 @@ export function findInstallations(cwd, env = process.env, adapterHome) {
|
|
|
840
924
|
}
|
|
841
925
|
return found;
|
|
842
926
|
}
|
|
843
|
-
function harnessMarkerProblems(harness, handlers, adapterHome) {
|
|
927
|
+
function harnessMarkerProblems(harness, handlers, adapterHome, options = {}) {
|
|
844
928
|
const problems = [];
|
|
845
929
|
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
846
930
|
problems.push(`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``);
|
|
847
931
|
}
|
|
848
|
-
const expected = `${
|
|
932
|
+
const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `;
|
|
849
933
|
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
850
934
|
problems.push('installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter');
|
|
851
935
|
}
|