nearly-cli 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -7
- package/bin/nearly.mjs +11 -1
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +69 -41
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +38 -4
- package/scripts/update-check.mjs +4 -1
- package/server/adapters.mjs +595 -0
- package/server/index.mjs +65 -16
- package/server/paths.mjs +10 -0
- package/ui/index.html +26 -6
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// One gate, six harnesses.
|
|
2
|
+
//
|
|
3
|
+
// Nearly's server speaks one dialect: Claude Code's. Everything downstream of a
|
|
4
|
+
// hook — the consent gradient, the recording, the record page, the PR comment —
|
|
5
|
+
// reads that shape and nothing else. So supporting another agent is not a
|
|
6
|
+
// second gate. It is a translation at the edge: turn their payload into Claude
|
|
7
|
+
// Code's on the way in, turn our answer into theirs on the way out. The server
|
|
8
|
+
// never learns that any of this happened.
|
|
9
|
+
//
|
|
10
|
+
// Three things make the translation small enough to be worth trusting.
|
|
11
|
+
//
|
|
12
|
+
// 1. Nearly never asks the harness to ask. Every one of these has some notion of
|
|
13
|
+
// "prompt the user", and we want none of them, because their dialog is not
|
|
14
|
+
// the record. We hold the hook open instead and answer allow or deny once a
|
|
15
|
+
// human has. So the only thing an adapter needs is a pre-tool hook that
|
|
16
|
+
// blocks, which is the one thing all of them have.
|
|
17
|
+
//
|
|
18
|
+
// 2. Tools are matched by shape, not only by name. `run_command`, `shell`,
|
|
19
|
+
// `run_terminal_cmd` and `bash` are all Bash, and anything carrying a command
|
|
20
|
+
// string is treated as Bash even when nobody here has heard of it — because
|
|
21
|
+
// if it is not, the never-rules do not apply to it, and `rm -rf` walks
|
|
22
|
+
// through a gate that reports itself as working. Unknown falls to "ask".
|
|
23
|
+
//
|
|
24
|
+
// 3. The raw name still travels, as tool_label, so the record says what the
|
|
25
|
+
// agent actually called rather than what we translated it to.
|
|
26
|
+
//
|
|
27
|
+
// Honesty about what this is: every format below is taken from the vendor's own
|
|
28
|
+
// hook documentation, and every one is exercised in test/adapters.test.mjs
|
|
29
|
+
// against payloads copied from those docs. Only Claude Code has been run end to
|
|
30
|
+
// end against a live agent. An adapter built to spec is a claim, not a
|
|
31
|
+
// demonstration, and `nearly agents` says so out loud.
|
|
32
|
+
|
|
33
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
34
|
+
import { join, dirname } from 'node:path';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Tool identity
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// Canonical names are Claude Code's, because that is what server/policy.mjs
|
|
41
|
+
// keys on, and because a rule you set in one harness should mean the same thing
|
|
42
|
+
// in the next one.
|
|
43
|
+
const CANON = new Set(['Bash', 'Read', 'Edit', 'Write', 'MultiEdit', 'Glob', 'Grep',
|
|
44
|
+
'LS', 'WebFetch', 'WebSearch', 'Task', 'NotebookEdit', 'TodoWrite']);
|
|
45
|
+
|
|
46
|
+
const TOOL_ALIASES = {
|
|
47
|
+
// shell — the safety-critical row: a miss here disables the never-rules
|
|
48
|
+
run_command: 'Bash', run_terminal_cmd: 'Bash', run_shell_command: 'Bash',
|
|
49
|
+
shell: 'Bash', bash: 'Bash', local_shell: 'Bash', terminal: 'Bash',
|
|
50
|
+
execute_command: 'Bash', runcommand: 'Bash', run_in_terminal: 'Bash',
|
|
51
|
+
// read
|
|
52
|
+
view_file: 'Read', read_file: 'Read', view: 'Read', open_file: 'Read',
|
|
53
|
+
read_many_files: 'Read', readfile: 'Read',
|
|
54
|
+
// write
|
|
55
|
+
write_to_file: 'Write', write_file: 'Write', create_file: 'Write', create: 'Write',
|
|
56
|
+
// edit
|
|
57
|
+
replace_file_content: 'Edit', multi_replace_file_content: 'Edit', replace: 'Edit',
|
|
58
|
+
edit_file: 'Edit', str_replace: 'Edit', str_replace_editor: 'Edit',
|
|
59
|
+
apply_patch: 'Edit', replace_string_in_file: 'Edit', edit_notebook: 'NotebookEdit',
|
|
60
|
+
// search and listing
|
|
61
|
+
grep: 'Grep', grep_search: 'Grep', search_file_content: 'Grep',
|
|
62
|
+
codebase_search: 'Grep', semantic_search: 'Grep', ripgrep: 'Grep',
|
|
63
|
+
glob: 'Glob', file_search: 'Glob', find_files: 'Glob',
|
|
64
|
+
list_directory: 'LS', list_dir: 'LS', ls: 'LS',
|
|
65
|
+
// network
|
|
66
|
+
web_fetch: 'WebFetch', fetch: 'WebFetch', read_url: 'WebFetch', read_url_content: 'WebFetch',
|
|
67
|
+
web_search: 'WebSearch', google_web_search: 'WebSearch', search_web: 'WebSearch',
|
|
68
|
+
// delegation
|
|
69
|
+
task: 'Task', spawn_agent: 'Task', subagent: 'Task',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Different harnesses spell the same argument differently. Copy the ones policy
|
|
73
|
+
// and the record read into the names they expect, and leave everything else
|
|
74
|
+
// alone so nothing is lost from the record.
|
|
75
|
+
export function normalizeInput(input) {
|
|
76
|
+
const i = (input && typeof input === 'object') ? { ...input } : {};
|
|
77
|
+
const cmd = i.command ?? i.CommandLine ?? i.command_line ?? i.commandLine ?? i.cmd ?? i.script;
|
|
78
|
+
if (typeof cmd === 'string') i.command = cmd;
|
|
79
|
+
const fp = i.file_path ?? i.filePath ?? i.TargetFile ?? i.target_file ?? i.path
|
|
80
|
+
?? i.absolute_path ?? i.AbsolutePath;
|
|
81
|
+
if (typeof fp === 'string') i.file_path = fp;
|
|
82
|
+
return i;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Name first, then shape. Shape is the safety net: an unrecognised tool that
|
|
86
|
+
// carries a command string is a shell call whatever its author called it.
|
|
87
|
+
export function canonicalTool(name, input) {
|
|
88
|
+
const raw = String(name ?? '');
|
|
89
|
+
if (CANON.has(raw)) return raw;
|
|
90
|
+
const alias = TOOL_ALIASES[raw.toLowerCase()];
|
|
91
|
+
if (alias) return alias;
|
|
92
|
+
if (typeof input?.command === 'string') return 'Bash';
|
|
93
|
+
if (typeof input?.file_path === 'string') {
|
|
94
|
+
return (input.content !== undefined || input.contents !== undefined) ? 'Write' : 'Edit';
|
|
95
|
+
}
|
|
96
|
+
return raw || 'unknown';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Everything an adapter produces for the server, in Claude Code's own words.
|
|
100
|
+
function toolEvent(p, { name, input, id, session, cwd, model }) {
|
|
101
|
+
const tool_input = normalizeInput(input);
|
|
102
|
+
const tool_name = canonicalTool(name, tool_input);
|
|
103
|
+
const out = { session_id: session, cwd, tool_name, tool_input, tool_use_id: id };
|
|
104
|
+
if (name && name !== tool_name) out.tool_label = String(name);
|
|
105
|
+
if (model) out.model = model;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const first = (v) => (Array.isArray(v) ? v[0] : v);
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Config file helpers
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function readJson(file) {
|
|
116
|
+
if (!existsSync(file)) return null;
|
|
117
|
+
try {
|
|
118
|
+
// Some editors write these files with a byte-order mark. Windsurf's own
|
|
119
|
+
// parser had to be taught to tolerate one; ours should not be worse.
|
|
120
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^/, ''));
|
|
121
|
+
} catch { return undefined; } // present but unreadable: distinct from absent
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeJson(file, obj) {
|
|
125
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
126
|
+
writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Ours is anything that runs nearly. Matching on that rather than on a version
|
|
130
|
+
// or a path is what makes attach safe to re-run, and what stopped a rename from
|
|
131
|
+
// orphaning hooks the last time.
|
|
132
|
+
const isOurs = (h) => /nearly/i.test(JSON.stringify(h ?? ''));
|
|
133
|
+
|
|
134
|
+
// Strip our entries out of an event map shaped { event: [entry, ...] }, and drop
|
|
135
|
+
// events we emptied so the file does not fill with husks.
|
|
136
|
+
function stripEvents(map) {
|
|
137
|
+
for (const ev of Object.keys(map || {})) {
|
|
138
|
+
const kept = (map[ev] || []).filter((e) => !isOurs(e));
|
|
139
|
+
if (kept.length) map[ev] = kept; else delete map[ev];
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// How long each pre-tool hook may hold while a person decides. Everything else
|
|
145
|
+
// should be quick. The unit differs per harness; the intent does not.
|
|
146
|
+
const SECONDS = {
|
|
147
|
+
'pre-tool': 600, // long enough to hold while a human decides
|
|
148
|
+
'session-end': 120, // the record is built off the back of this one
|
|
149
|
+
stop: 30,
|
|
150
|
+
};
|
|
151
|
+
const holdFor = (ev) => SECONDS[ev] ?? 20;
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// The adapters
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// Claude Code's own answer shape, which is what the server returns.
|
|
158
|
+
const decisionOf = (answer) => answer?.hookSpecificOutput?.permissionDecision
|
|
159
|
+
?? answer?.permissionDecision ?? null;
|
|
160
|
+
const reasonOf = (answer) => answer?.hookSpecificOutput?.permissionDecisionReason
|
|
161
|
+
?? answer?.permissionDecisionReason ?? 'nearly';
|
|
162
|
+
|
|
163
|
+
// Passing the answer through untouched, for harnesses that already speak it.
|
|
164
|
+
const passThrough = (ev, answer) => ({ stdout: JSON.stringify(answer ?? {}), exit: 0 });
|
|
165
|
+
|
|
166
|
+
export const ADAPTERS = [
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
{
|
|
170
|
+
id: 'claude-code',
|
|
171
|
+
name: 'Claude Code',
|
|
172
|
+
verified: 'run end to end against a live agent',
|
|
173
|
+
config: '.claude/settings.local.json',
|
|
174
|
+
// Also read by Claude Code inside VS Code and JetBrains, which is why those
|
|
175
|
+
// editors need no adapter of their own.
|
|
176
|
+
events: {
|
|
177
|
+
SessionStart: 'session-start', UserPromptSubmit: 'prompt', PreToolUse: 'pre-tool',
|
|
178
|
+
PostToolUse: 'post-tool', Stop: 'stop', SessionEnd: 'session-end',
|
|
179
|
+
},
|
|
180
|
+
install({ repo, cmdFor }) {
|
|
181
|
+
const file = join(repo, '.claude', 'settings.local.json');
|
|
182
|
+
const s = readJson(file);
|
|
183
|
+
if (s === undefined) return { error: `could not read ${file}` };
|
|
184
|
+
const settings = s || {};
|
|
185
|
+
settings.hooks = stripEvents(settings.hooks || {});
|
|
186
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
187
|
+
settings.hooks[their] = [...(settings.hooks[their] || []),
|
|
188
|
+
{ hooks: [{ type: 'command', command: cmdFor(ours), timeout: holdFor(ours) }] }];
|
|
189
|
+
}
|
|
190
|
+
writeJson(file, settings);
|
|
191
|
+
return { file };
|
|
192
|
+
},
|
|
193
|
+
uninstall({ repo }) {
|
|
194
|
+
const file = join(repo, '.claude', 'settings.local.json');
|
|
195
|
+
const settings = readJson(file);
|
|
196
|
+
if (!settings || settings.hooks === undefined) return { removed: false };
|
|
197
|
+
settings.hooks = stripEvents(settings.hooks);
|
|
198
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
199
|
+
writeJson(file, settings);
|
|
200
|
+
return { removed: true, file };
|
|
201
|
+
},
|
|
202
|
+
normalize: (ev, p) => p, // already canonical
|
|
203
|
+
render: passThrough,
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
{
|
|
208
|
+
id: 'cursor',
|
|
209
|
+
name: 'Cursor',
|
|
210
|
+
verified: null,
|
|
211
|
+
config: '.cursor/hooks.json',
|
|
212
|
+
// preToolUse is the gate rather than beforeShellExecution, because it covers
|
|
213
|
+
// every tool with one entry and cannot double-fire against the shell hook.
|
|
214
|
+
// Its documented output has no "ask", which costs us nothing: we hold the
|
|
215
|
+
// hook and answer allow or deny ourselves.
|
|
216
|
+
events: {
|
|
217
|
+
sessionStart: 'session-start', beforeSubmitPrompt: 'prompt', preToolUse: 'pre-tool',
|
|
218
|
+
postToolUse: 'post-tool', stop: 'stop', sessionEnd: 'session-end',
|
|
219
|
+
},
|
|
220
|
+
install({ repo, cmdFor }) {
|
|
221
|
+
const file = join(repo, '.cursor', 'hooks.json');
|
|
222
|
+
const c = readJson(file);
|
|
223
|
+
if (c === undefined) return { error: `could not read ${file}` };
|
|
224
|
+
const cfg = c || { version: 1, hooks: {} };
|
|
225
|
+
cfg.version = cfg.version || 1;
|
|
226
|
+
cfg.hooks = stripEvents(cfg.hooks || {});
|
|
227
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
228
|
+
cfg.hooks[their] = [...(cfg.hooks[their] || []),
|
|
229
|
+
{ command: cmdFor(ours), timeout: holdFor(ours), failClosed: ours === 'pre-tool' }];
|
|
230
|
+
}
|
|
231
|
+
writeJson(file, cfg);
|
|
232
|
+
return { file };
|
|
233
|
+
},
|
|
234
|
+
uninstall({ repo }) {
|
|
235
|
+
const file = join(repo, '.cursor', 'hooks.json');
|
|
236
|
+
const cfg = readJson(file);
|
|
237
|
+
if (!cfg || cfg.hooks === undefined) return { removed: false };
|
|
238
|
+
cfg.hooks = stripEvents(cfg.hooks);
|
|
239
|
+
if (!Object.keys(cfg.hooks).length) rmSync(file, { force: true });
|
|
240
|
+
else writeJson(file, cfg);
|
|
241
|
+
return { removed: true, file };
|
|
242
|
+
},
|
|
243
|
+
normalize(ev, p) {
|
|
244
|
+
// conversation_id is on every Cursor event; session_id only on sessionStart.
|
|
245
|
+
const session = p.conversation_id || p.session_id;
|
|
246
|
+
const cwd = p.cwd || first(p.workspace_roots);
|
|
247
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
248
|
+
// beforeShellExecution sends a bare command with no tool name; treat it
|
|
249
|
+
// as the shell tool it is, in case someone wires that event up instead.
|
|
250
|
+
const name = p.tool_name ?? (p.command !== undefined ? 'run_terminal_cmd' : undefined);
|
|
251
|
+
const input = p.tool_input ?? (p.command !== undefined ? { command: p.command } : {});
|
|
252
|
+
const base = toolEvent(p, { name, input, id: p.tool_use_id, session, cwd, model: p.model });
|
|
253
|
+
if (ev === 'post-tool') {
|
|
254
|
+
base.tool_response = p.tool_output ?? p.output;
|
|
255
|
+
base.duration_ms = p.duration;
|
|
256
|
+
}
|
|
257
|
+
return base;
|
|
258
|
+
}
|
|
259
|
+
if (ev === 'prompt') return { session_id: session, cwd, prompt: p.prompt };
|
|
260
|
+
if (ev === 'stop') return { session_id: session, cwd, last_assistant_message: p.text };
|
|
261
|
+
if (ev === 'session-end') return { session_id: session, cwd, reason: p.reason || p.final_status };
|
|
262
|
+
return { session_id: session, cwd };
|
|
263
|
+
},
|
|
264
|
+
render(ev, answer) {
|
|
265
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
266
|
+
const d = decisionOf(answer);
|
|
267
|
+
if (!d) return { stdout: '', exit: 0 };
|
|
268
|
+
return {
|
|
269
|
+
stdout: JSON.stringify({
|
|
270
|
+
permission: d === 'deny' ? 'deny' : 'allow',
|
|
271
|
+
user_message: reasonOf(answer),
|
|
272
|
+
agent_message: reasonOf(answer),
|
|
273
|
+
}),
|
|
274
|
+
exit: 0,
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
{
|
|
281
|
+
id: 'antigravity',
|
|
282
|
+
name: 'Antigravity',
|
|
283
|
+
verified: null,
|
|
284
|
+
config: '.agents/hooks.json',
|
|
285
|
+
// Antigravity nests the call under toolCall and spells the shell argument
|
|
286
|
+
// CommandLine. Both are handled by normalizeInput and canonicalTool, so the
|
|
287
|
+
// policy sees `Bash` with a `command` like everywhere else.
|
|
288
|
+
events: {
|
|
289
|
+
PreToolUse: 'pre-tool', PostToolUse: 'post-tool',
|
|
290
|
+
PreInvocation: 'prompt', PostInvocation: 'stop', Stop: 'session-end',
|
|
291
|
+
},
|
|
292
|
+
install({ repo, cmdFor }) {
|
|
293
|
+
const file = join(repo, '.agents', 'hooks.json');
|
|
294
|
+
const c = readJson(file);
|
|
295
|
+
if (c === undefined) return { error: `could not read ${file}` };
|
|
296
|
+
const cfg = c || {};
|
|
297
|
+
// Antigravity's top level is a map of named containers, so ours is one key
|
|
298
|
+
// and anybody else's are untouched.
|
|
299
|
+
cfg.nearly = { enabled: true };
|
|
300
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
301
|
+
cfg.nearly[their] = [{ matcher: '.*', handler: { command: cmdFor(ours), timeout: holdFor(ours) } }];
|
|
302
|
+
}
|
|
303
|
+
writeJson(file, cfg);
|
|
304
|
+
return { file };
|
|
305
|
+
},
|
|
306
|
+
uninstall({ repo }) {
|
|
307
|
+
const file = join(repo, '.agents', 'hooks.json');
|
|
308
|
+
const cfg = readJson(file);
|
|
309
|
+
if (!cfg || cfg.nearly === undefined) return { removed: false };
|
|
310
|
+
delete cfg.nearly;
|
|
311
|
+
if (!Object.keys(cfg).length) rmSync(file, { force: true });
|
|
312
|
+
else writeJson(file, cfg);
|
|
313
|
+
return { removed: true, file };
|
|
314
|
+
},
|
|
315
|
+
normalize(ev, p) {
|
|
316
|
+
const session = p.conversationId || p.conversation_id;
|
|
317
|
+
const cwd = first(p.workspacePaths) || first(p.workspace_paths);
|
|
318
|
+
const model = p.modelName || p.model_name;
|
|
319
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
320
|
+
const call = p.toolCall || p.tool_call || {};
|
|
321
|
+
const base = toolEvent(p, {
|
|
322
|
+
name: call.name ?? p.tool_name, input: call.args ?? p.tool_input,
|
|
323
|
+
id: p.stepIdx != null ? `step-${p.stepIdx}` : p.tool_use_id,
|
|
324
|
+
session, cwd, model,
|
|
325
|
+
});
|
|
326
|
+
if (ev === 'post-tool') base.tool_response = p.toolResult ?? p.result ?? p.tool_response;
|
|
327
|
+
return base;
|
|
328
|
+
}
|
|
329
|
+
if (ev === 'prompt') return { session_id: session, cwd, model, prompt: p.prompt ?? p.userMessage };
|
|
330
|
+
if (ev === 'stop') return { session_id: session, cwd, model, last_assistant_message: p.response ?? p.text };
|
|
331
|
+
if (ev === 'session-end') return { session_id: session, cwd, model, reason: p.reason };
|
|
332
|
+
return { session_id: session, cwd, model };
|
|
333
|
+
},
|
|
334
|
+
render(ev, answer) {
|
|
335
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
336
|
+
const d = decisionOf(answer);
|
|
337
|
+
if (!d) return { stdout: '', exit: 0 };
|
|
338
|
+
return { stdout: JSON.stringify({ decision: d === 'deny' ? 'deny' : 'allow', reason: reasonOf(answer) }), exit: 0 };
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
{
|
|
344
|
+
id: 'copilot',
|
|
345
|
+
name: 'GitHub Copilot CLI',
|
|
346
|
+
verified: null,
|
|
347
|
+
config: '.github/hooks/nearly.json',
|
|
348
|
+
// Copilot accepts PascalCase event names as a Claude Code compatibility
|
|
349
|
+
// mode, and in that mode it sends snake_case fields and Claude's own tool
|
|
350
|
+
// names. So this adapter is mostly a different file path.
|
|
351
|
+
events: {
|
|
352
|
+
SessionStart: 'session-start', UserPromptSubmit: 'prompt', PreToolUse: 'pre-tool',
|
|
353
|
+
PostToolUse: 'post-tool', Stop: 'stop', SessionEnd: 'session-end',
|
|
354
|
+
},
|
|
355
|
+
install({ repo, cmdFor }) {
|
|
356
|
+
const file = join(repo, '.github', 'hooks', 'nearly.json');
|
|
357
|
+
const cfg = { version: 1, hooks: {} };
|
|
358
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
359
|
+
cfg.hooks[their] = [{ type: 'command', command: cmdFor(ours), timeoutSec: holdFor(ours) }];
|
|
360
|
+
}
|
|
361
|
+
writeJson(file, cfg); // our own file; nobody else's entries to keep
|
|
362
|
+
return { file };
|
|
363
|
+
},
|
|
364
|
+
uninstall({ repo }) {
|
|
365
|
+
const file = join(repo, '.github', 'hooks', 'nearly.json');
|
|
366
|
+
if (!existsSync(file)) return { removed: false };
|
|
367
|
+
rmSync(file, { force: true });
|
|
368
|
+
return { removed: true, file };
|
|
369
|
+
},
|
|
370
|
+
normalize(ev, p) {
|
|
371
|
+
// Tolerate both spellings: the payload arrives PascalCase-shaped when the
|
|
372
|
+
// event is registered that way, camelCase when it is not.
|
|
373
|
+
const session = p.session_id || p.sessionId;
|
|
374
|
+
const cwd = p.cwd;
|
|
375
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
376
|
+
const base = toolEvent(p, {
|
|
377
|
+
name: p.tool_name ?? p.toolName, input: p.tool_input ?? p.toolArgs,
|
|
378
|
+
id: p.tool_use_id ?? p.toolUseId, session, cwd,
|
|
379
|
+
});
|
|
380
|
+
if (ev === 'post-tool') {
|
|
381
|
+
base.tool_response = p.tool_response ?? p.toolOutput ?? p.result;
|
|
382
|
+
base.duration_ms = p.duration_ms ?? p.duration;
|
|
383
|
+
}
|
|
384
|
+
return base;
|
|
385
|
+
}
|
|
386
|
+
if (ev === 'prompt') return { session_id: session, cwd, prompt: p.prompt };
|
|
387
|
+
if (ev === 'stop') {
|
|
388
|
+
return { session_id: session, cwd, transcript_path: p.transcriptPath ?? p.transcript_path,
|
|
389
|
+
last_assistant_message: p.lastAssistantMessage ?? p.last_assistant_message };
|
|
390
|
+
}
|
|
391
|
+
if (ev === 'session-end') return { session_id: session, cwd, reason: p.reason };
|
|
392
|
+
return { session_id: session, cwd };
|
|
393
|
+
},
|
|
394
|
+
render(ev, answer) {
|
|
395
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
396
|
+
const d = decisionOf(answer);
|
|
397
|
+
if (!d) return { stdout: '', exit: 0 };
|
|
398
|
+
// Flat is what the reference documents; the nested form is what the
|
|
399
|
+
// Claude-compatible path reads. Sending both costs a few bytes and means
|
|
400
|
+
// a doc that is behind the build cannot turn a deny into an allow.
|
|
401
|
+
return {
|
|
402
|
+
stdout: JSON.stringify({
|
|
403
|
+
permissionDecision: d, permissionDecisionReason: reasonOf(answer),
|
|
404
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: d, permissionDecisionReason: reasonOf(answer) },
|
|
405
|
+
}),
|
|
406
|
+
exit: 0,
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
{
|
|
413
|
+
id: 'codex',
|
|
414
|
+
name: 'Codex CLI',
|
|
415
|
+
verified: null,
|
|
416
|
+
config: '.codex/hooks.json',
|
|
417
|
+
// Codex parses "allow" and "ask" and does nothing with them: deny is the
|
|
418
|
+
// only decision that moves. That suits us exactly, because holding the hook
|
|
419
|
+
// open is how we ask, and an allow is simply the hook returning.
|
|
420
|
+
events: { PreToolUse: 'pre-tool', PostToolUse: 'post-tool', SessionEnd: 'session-end' },
|
|
421
|
+
install({ repo, cmdFor }) {
|
|
422
|
+
const file = join(repo, '.codex', 'hooks.json');
|
|
423
|
+
const c = readJson(file);
|
|
424
|
+
if (c === undefined) return { error: `could not read ${file}` };
|
|
425
|
+
const cfg = c || {};
|
|
426
|
+
cfg.hooks = stripEvents(cfg.hooks || {});
|
|
427
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
428
|
+
cfg.hooks[their] = [...(cfg.hooks[their] || []), {
|
|
429
|
+
matcher: '.*',
|
|
430
|
+
hooks: [{ type: 'command', command: cmdFor(ours), timeout: holdFor(ours) }],
|
|
431
|
+
}];
|
|
432
|
+
}
|
|
433
|
+
writeJson(file, cfg);
|
|
434
|
+
return { file, note: 'Codex has no session-start, prompt or turn hook, so the record has no prompts and one turn.' };
|
|
435
|
+
},
|
|
436
|
+
uninstall({ repo }) {
|
|
437
|
+
const file = join(repo, '.codex', 'hooks.json');
|
|
438
|
+
const cfg = readJson(file);
|
|
439
|
+
if (!cfg || cfg.hooks === undefined) return { removed: false };
|
|
440
|
+
cfg.hooks = stripEvents(cfg.hooks);
|
|
441
|
+
if (!Object.keys(cfg.hooks).length) rmSync(file, { force: true });
|
|
442
|
+
else writeJson(file, cfg);
|
|
443
|
+
return { removed: true, file };
|
|
444
|
+
},
|
|
445
|
+
normalize(ev, p) {
|
|
446
|
+
const session = p.session_id || p.sessionId;
|
|
447
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
448
|
+
const base = toolEvent(p, {
|
|
449
|
+
name: p.tool_name, input: p.tool_input, id: p.tool_use_id, session, cwd: p.cwd,
|
|
450
|
+
});
|
|
451
|
+
if (ev === 'post-tool') base.tool_response = p.tool_response ?? p.tool_output;
|
|
452
|
+
return base;
|
|
453
|
+
}
|
|
454
|
+
if (ev === 'session-end') return { session_id: session, cwd: p.cwd, reason: p.reason };
|
|
455
|
+
return { session_id: session, cwd: p.cwd };
|
|
456
|
+
},
|
|
457
|
+
render(ev, answer) {
|
|
458
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
459
|
+
const d = decisionOf(answer);
|
|
460
|
+
if (d !== 'deny') return { stdout: '', exit: 0 }; // anything else is a pass
|
|
461
|
+
return {
|
|
462
|
+
stdout: JSON.stringify({
|
|
463
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reasonOf(answer) },
|
|
464
|
+
}),
|
|
465
|
+
exit: 0,
|
|
466
|
+
};
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
{
|
|
472
|
+
id: 'gemini',
|
|
473
|
+
name: 'Gemini CLI',
|
|
474
|
+
verified: null,
|
|
475
|
+
config: '.gemini/settings.json',
|
|
476
|
+
events: {
|
|
477
|
+
SessionStart: 'session-start', BeforeAgent: 'prompt', BeforeTool: 'pre-tool',
|
|
478
|
+
AfterTool: 'post-tool', AfterAgent: 'stop', SessionEnd: 'session-end',
|
|
479
|
+
},
|
|
480
|
+
install({ repo, cmdFor }) {
|
|
481
|
+
const file = join(repo, '.gemini', 'settings.json');
|
|
482
|
+
const s = readJson(file);
|
|
483
|
+
if (s === undefined) return { error: `could not read ${file}` };
|
|
484
|
+
// This file is the user's whole Gemini configuration, not ours. Merge into
|
|
485
|
+
// it and never rewrite it wholesale.
|
|
486
|
+
const settings = s || {};
|
|
487
|
+
settings.hooks = stripEvents(settings.hooks || {});
|
|
488
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
489
|
+
settings.hooks[their] = [...(settings.hooks[their] || []), {
|
|
490
|
+
matcher: '.*',
|
|
491
|
+
hooks: [{ type: 'command', name: `nearly ${ours}`, command: cmdFor(ours), timeout: holdFor(ours) * 1000 }],
|
|
492
|
+
}];
|
|
493
|
+
}
|
|
494
|
+
writeJson(file, settings);
|
|
495
|
+
return { file };
|
|
496
|
+
},
|
|
497
|
+
uninstall({ repo }) {
|
|
498
|
+
const file = join(repo, '.gemini', 'settings.json');
|
|
499
|
+
const settings = readJson(file);
|
|
500
|
+
if (!settings || settings.hooks === undefined) return { removed: false };
|
|
501
|
+
settings.hooks = stripEvents(settings.hooks);
|
|
502
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
503
|
+
if (!Object.keys(settings).length) rmSync(file, { force: true });
|
|
504
|
+
else writeJson(file, settings);
|
|
505
|
+
return { removed: true, file };
|
|
506
|
+
},
|
|
507
|
+
normalize(ev, p) {
|
|
508
|
+
const base = { session_id: p.session_id, cwd: p.cwd, transcript_path: p.transcript_path };
|
|
509
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
510
|
+
const t = toolEvent(p, { name: p.tool_name, input: p.tool_input, id: p.tool_use_id, session: p.session_id, cwd: p.cwd });
|
|
511
|
+
t.transcript_path = p.transcript_path;
|
|
512
|
+
if (ev === 'post-tool') {
|
|
513
|
+
// Gemini wraps the result; the record wants the part a person reads.
|
|
514
|
+
t.tool_response = p.tool_response?.returnDisplay ?? p.tool_response?.llmContent ?? p.tool_response;
|
|
515
|
+
}
|
|
516
|
+
return t;
|
|
517
|
+
}
|
|
518
|
+
if (ev === 'prompt') return { ...base, prompt: p.prompt };
|
|
519
|
+
if (ev === 'stop') return { ...base, last_assistant_message: p.prompt_response };
|
|
520
|
+
if (ev === 'session-end') return { ...base, reason: p.reason };
|
|
521
|
+
return { ...base, source: p.source };
|
|
522
|
+
},
|
|
523
|
+
render(ev, answer) {
|
|
524
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
525
|
+
const d = decisionOf(answer);
|
|
526
|
+
if (!d) return { stdout: '', exit: 0 };
|
|
527
|
+
return { stdout: JSON.stringify({ decision: d === 'deny' ? 'deny' : 'allow', reason: reasonOf(answer) }), exit: 0 };
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
// -------------------------------------------------------------------------
|
|
532
|
+
{
|
|
533
|
+
id: 'windsurf',
|
|
534
|
+
name: 'Windsurf',
|
|
535
|
+
verified: null,
|
|
536
|
+
config: '.windsurf/hooks.json',
|
|
537
|
+
// The odd one out twice over. Windsurf has no JSON answer at all — a pre
|
|
538
|
+
// hook blocks by exiting 2 with the reason on stderr — and it has no single
|
|
539
|
+
// pre-tool event, so the gate is spread across three.
|
|
540
|
+
events: {
|
|
541
|
+
pre_run_command: 'pre-tool', pre_write_code: 'pre-tool', pre_read_code: 'pre-tool',
|
|
542
|
+
post_run_command: 'post-tool', pre_user_prompt: 'prompt', post_cascade_response: 'stop',
|
|
543
|
+
},
|
|
544
|
+
install({ repo, cmdFor }) {
|
|
545
|
+
const file = join(repo, '.windsurf', 'hooks.json');
|
|
546
|
+
const c = readJson(file);
|
|
547
|
+
if (c === undefined) return { error: `could not read ${file}` };
|
|
548
|
+
const cfg = c || {};
|
|
549
|
+
cfg.hooks = stripEvents(cfg.hooks || {});
|
|
550
|
+
for (const [their, ours] of Object.entries(this.events)) {
|
|
551
|
+
cfg.hooks[their] = [...(cfg.hooks[their] || []), { command: cmdFor(ours), show_output: false }];
|
|
552
|
+
}
|
|
553
|
+
writeJson(file, cfg);
|
|
554
|
+
return { file, note: 'Windsurf hooks have no configurable timeout, so a request held longer than Cascade waits is denied.' };
|
|
555
|
+
},
|
|
556
|
+
uninstall({ repo }) {
|
|
557
|
+
const file = join(repo, '.windsurf', 'hooks.json');
|
|
558
|
+
const cfg = readJson(file);
|
|
559
|
+
if (!cfg || cfg.hooks === undefined) return { removed: false };
|
|
560
|
+
cfg.hooks = stripEvents(cfg.hooks);
|
|
561
|
+
if (!Object.keys(cfg.hooks).length) rmSync(file, { force: true });
|
|
562
|
+
else writeJson(file, cfg);
|
|
563
|
+
return { removed: true, file };
|
|
564
|
+
},
|
|
565
|
+
normalize(ev, p) {
|
|
566
|
+
const session = p.trajectory_id || p.conversation_id;
|
|
567
|
+
const info = p.tool_info || {};
|
|
568
|
+
const cwd = info.cwd || p.cwd;
|
|
569
|
+
if (ev === 'pre-tool' || ev === 'post-tool') {
|
|
570
|
+
// The event name is the only tool name Windsurf gives, and pre_write_code
|
|
571
|
+
// and pre_read_code carry file fields rather than a command. Shape
|
|
572
|
+
// inference in canonicalTool is what sorts them out.
|
|
573
|
+
const byEvent = { pre_run_command: 'run_command', post_run_command: 'run_command',
|
|
574
|
+
pre_write_code: 'write_to_file', pre_read_code: 'view_file' };
|
|
575
|
+
const base = toolEvent(p, {
|
|
576
|
+
name: byEvent[p.agent_action_name] ?? p.agent_action_name,
|
|
577
|
+
input: info, id: p.execution_id, session, cwd, model: p.model_name,
|
|
578
|
+
});
|
|
579
|
+
if (ev === 'post-tool') base.tool_response = info.output ?? p.output;
|
|
580
|
+
return base;
|
|
581
|
+
}
|
|
582
|
+
if (ev === 'prompt') return { session_id: session, cwd, model: p.model_name, prompt: p.prompt ?? info.prompt };
|
|
583
|
+
if (ev === 'stop') return { session_id: session, cwd, model: p.model_name, last_assistant_message: p.response ?? info.response };
|
|
584
|
+
return { session_id: session, cwd, model: p.model_name };
|
|
585
|
+
},
|
|
586
|
+
render(ev, answer) {
|
|
587
|
+
if (ev !== 'pre-tool') return { stdout: '', exit: 0 };
|
|
588
|
+
if (decisionOf(answer) !== 'deny') return { stdout: '', exit: 0 };
|
|
589
|
+
return { stdout: '', stderr: reasonOf(answer), exit: 2 };
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
export const byId = (id) => ADAPTERS.find((a) => a.id === id) || null;
|
|
595
|
+
export const ids = () => ADAPTERS.map((a) => a.id);
|