@synqa/cli 1.1.1 → 1.2.1

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/dist/commands.js CHANGED
@@ -13,6 +13,15 @@ import { sanitize } from "./sanitize.js";
13
13
  import { groupDigits } from "./statusbar.js";
14
14
 
15
15
  import { CONTEXT_TOKENS, EFFORTS, findEffort, findTier, TIERS } from "./tiers.js";
16
+ import { MODE_LABEL } from "./modes.js";
17
+
18
+ import { describeHook } from "./hooks.js";
19
+
20
+ import { fetchModelIds, logout, modelItems, providerDef, providerMenu } from "./providers.js";
21
+
22
+ import { API_BASE, saveProvider } from "./config.js";
23
+ import { sanitizeModelId } from "./sanitize.js";
24
+
16
25
 
17
26
 
18
27
  import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
@@ -32,6 +41,10 @@ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
32
41
 
33
42
 
34
43
 
44
+
45
+
46
+
47
+
35
48
 
36
49
 
37
50
 
@@ -41,12 +54,23 @@ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
41
54
 
42
55
 
43
56
 
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
44
65
 
45
66
 
46
-
67
+ // relogin: a full logout locked the session; the caller shows the sign-in prompt again.
68
+
47
69
 
48
70
  export { SLASH_HELP };
49
71
 
72
+ export const isExternal = (st ) => (st.providerId ?? "synqa") !== "synqa";
73
+
50
74
  export function statusParts(st ) {
51
75
  return {
52
76
  tier: st.tier,
@@ -62,7 +86,7 @@ export function statusParts(st ) {
62
86
  export function systemMessage(st , tier = st.tier) {
63
87
  const notes = loadNotes(st.cwd);
64
88
  st.notesChars = notes.length;
65
- return { role: "system", content: systemPrompt(tier, st.cwd, notes) };
89
+ return { role: "system", content: systemPrompt(tier, st.cwd, notes, isExternal(st)) };
66
90
  }
67
91
 
68
92
  export function freshHistory(st ) {
@@ -81,12 +105,15 @@ const errText = (err ) => sanitize(err instanceof Error ? err.message :
81
105
  async function cmdStatus(st , io ) {
82
106
  const u = st.total;
83
107
  io.print([
84
- `Tier ${st.tier.display} (${st.tier.id})`,
108
+ isExternal(st) ? `Provider ${providerDef(st.providerId )?.name ?? "custom"}, model ${sanitize(st.modelId ?? "?")}` : `Tier ${st.tier.display} (${st.tier.id})`,
109
+ `Mode ${MODE_LABEL[st.mode ?? "ask"]}`,
85
110
  `Routing ${st.routing}`,
86
111
  `Effort ${st.effort}`,
87
112
  `Session ${groupDigits(u.prompt + u.completion)} tokens (${groupDigits(u.prompt)} in, ${groupDigits(u.completion)} out)`,
88
113
  st.last.prompt > 0 ? `Context ${groupDigits(st.last.prompt)} of ${groupDigits(CONTEXT_TOKENS)} used by the last request` : "Context no request yet",
89
114
  ].join("\n"));
115
+ // The balance belongs to Synqa Cloud: a secondary provider's key is never sent there.
116
+ if (isExternal(st)) return;
90
117
  const r = await fetchAccount(st.apiKey, io.fetchFn);
91
118
  io.print(r.ok ? formatAccount(r.account).join("\n") : `Balance unavailable: ${sanitize(r.message)}`);
92
119
  }
@@ -113,7 +140,7 @@ async function cmdCompact(st , io ) {
113
140
  if (st.history.length <= 1) { io.print("Nothing to compact yet."); return; }
114
141
  io.print("Compacting the conversation...");
115
142
  try {
116
- const r = await compactHistory({ apiKey: st.apiKey, tier: st.tier, history: st.history, effort: st.effort, fetchFn: io.fetchFn, now: io.now });
143
+ const r = await compactHistory({ apiKey: st.apiKey, tier: st.tier, baseUrl: st.baseUrl, modelId: st.modelId, history: st.history, effort: st.effort, fetchFn: io.fetchFn, now: io.now });
117
144
  st.history = r.history;
118
145
  if (r.usage) {
119
146
  st.total = { prompt: st.total.prompt + r.usage.prompt, completion: st.total.completion + r.usage.completion };
@@ -124,6 +151,93 @@ async function cmdCompact(st , io ) {
124
151
  }
125
152
  }
126
153
 
154
+ // The provider helpers need the arrow-key controls; without a terminal they are unavailable.
155
+ function providerIO(st , io ) {
156
+ const { pick, secret, ask } = io;
157
+ if (!pick || !secret || !ask) return undefined;
158
+ return { print: io.print, pick, secret, ask, fetchFn: io.fetchFn, env: io.env, envKey: io.envKey };
159
+ }
160
+
161
+ const session = (st ) => {
162
+ st.providerId ??= "synqa";
163
+ st.baseUrl ??= API_BASE;
164
+ return st ;
165
+ };
166
+
167
+ async function cmdModel(st , io , arg ) {
168
+ if (!isExternal(st)) {
169
+ const t = arg ? findTier(arg) : undefined;
170
+ if (arg && !t) { io.print(`Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); return; }
171
+ let chosen = t;
172
+ if (!arg) {
173
+ if (!io.pick) { io.print(`Tier is ${st.tier.display}. Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); return; }
174
+ const key = await io.pick(modelItems("synqa"), "Choose a model");
175
+ if (key === undefined) { io.print("Cancelled."); return; }
176
+ chosen = findTier(key);
177
+ }
178
+ if (!chosen) return;
179
+ st.tier = chosen;
180
+ st.history[0] = systemMessage(st, chosen);
181
+ io.print(`Switched to ${chosen.display}.`);
182
+ return;
183
+ }
184
+ const id = st.providerId ;
185
+ let model = arg ? sanitizeModelId(arg) : undefined;
186
+ if (!arg) {
187
+ if (!io.pick) { io.print(`Model is ${sanitize(st.modelId ?? "?")}. Pass a model id: /model <id>`); return; }
188
+ let fetched ;
189
+ try {
190
+ fetched = await fetchModelIds(st.baseUrl , st.apiKey, io.fetchFn);
191
+ } catch {
192
+ fetched = undefined; // the curated defaults are offered instead
193
+ }
194
+ const items = modelItems(id, fetched);
195
+ if (items.length === 0) { io.print("No models to choose from. Pass a model id: /model <id>"); return; }
196
+ const initial = Math.max(0, items.findIndex((i) => i.value === st.modelId));
197
+ model = await io.pick(items.map((i, n) => ({ ...i, hint: n === initial ? "(current)" : undefined })), "Choose a model");
198
+ if (model === undefined) { io.print("Cancelled."); return; }
199
+ }
200
+ if (!model) { io.print("That is not a usable model id."); return; }
201
+ st.modelId = model;
202
+ try { saveProvider(id, { model }, io.env); } catch { /* the choice still applies to this session */ }
203
+ io.print(`Model set to ${model}.`);
204
+ }
205
+
206
+ async function cmdEffort(st , io , arg ) {
207
+ if (!arg) {
208
+ if (!io.pick) { io.print(`Effort is ${st.effort}. Choose one of: ${EFFORTS.join(", ")}`); return; }
209
+ const e = await io.pick(EFFORTS.map((x) => ({ label: x, value: x, hint: x === st.effort ? "(current)" : undefined })), "Reasoning effort");
210
+ if (e === undefined) { io.print("Cancelled."); return; }
211
+ st.effort = e;
212
+ io.print(`Reasoning effort set to ${e}.`);
213
+ return;
214
+ }
215
+ const e = findEffort(arg);
216
+ if (!e) { io.print(`Choose one of: ${EFFORTS.join(", ")}`); return; }
217
+ st.effort = e;
218
+ io.print(`Reasoning effort set to ${e}.`);
219
+ }
220
+
221
+ async function cmdHooks(io ) {
222
+ const hooks = io.hooks?.list() ?? [];
223
+ if (hooks.length === 0) {
224
+ io.print("No hooks. Add .synqa/hooks.json in the project (off until you switch it on here) or hooks.json in your Synqa config folder. Events: SessionStart, PreToolUse, PostToolUse, Stop.");
225
+ return;
226
+ }
227
+ if (!io.pick) { io.print(hooks.map(describeHook).join("\n")); return; }
228
+ const id = await io.pick(hooks.map((h) => ({ label: describeHook(h), value: h.id })), "Hooks: Enter toggles one");
229
+ if (id === undefined) return;
230
+ const now = io.hooks?.toggle(id);
231
+ io.print(now === undefined ? "No such hook." : `Hook is now ${now ? "on" : "off"}.`);
232
+ }
233
+
234
+ async function cmdUpdate(io ) {
235
+ if (!io.update) { io.print("Update from a terminal: npm install -g @synqa/cli@latest"); return; }
236
+ io.print(io.update.latest ? `Updating ${io.update.current} to ${sanitize(io.update.latest)} ...` : "Running npm install -g @synqa/cli@latest ...");
237
+ const code = await io.update.run();
238
+ io.print(code === 0 ? "Updated. Restart synqa to use the new version." : `The update failed (exit ${code}). Run it yourself: npm install -g @synqa/cli@latest`);
239
+ }
240
+
127
241
  export async function runCommand(line , st , io ) {
128
242
  const [first = "", ...args] = line.trim().split(/\s+/);
129
243
  // A first word like /usr/bin/foo is a path in a question, not a command.
@@ -141,23 +255,36 @@ export async function runCommand(line , st , io )
141
255
  io.print("Conversation cleared.");
142
256
  announceNotes(st, io.print);
143
257
  break;
144
- case "/model": {
145
- if (!arg) { io.print(`Tier is ${st.tier.display}. Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
146
- const t = findTier(arg);
147
- if (!t) { io.print(`Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
148
- st.tier = t;
149
- st.history[0] = systemMessage(st, t);
150
- io.print(`Switched to ${t.display}.`);
258
+ case "/model":
259
+ await cmdModel(st, io, arg);
260
+ break;
261
+ case "/effort":
262
+ await cmdEffort(st, io, arg);
263
+ break;
264
+ case "/provider":
265
+ case "/login": {
266
+ const pio = providerIO(st, io);
267
+ if (!pio) { io.print("This needs an interactive terminal."); break; }
268
+ if (await providerMenu(session(st), pio, arg)) {
269
+ st.history[0] = systemMessage(st);
270
+ io.print(isExternal(st) ? `Active: ${providerDef(st.providerId )?.name} ${st.modelId ?? ""}`.trim() : `Active: Synqa Cloud ${st.tier.display}`);
271
+ }
151
272
  break;
152
273
  }
153
- case "/effort": {
154
- if (!arg) { io.print(`Effort is ${st.effort}. Choose one of: ${EFFORTS.join(", ")}`); break; }
155
- const e = findEffort(arg);
156
- if (!e) { io.print(`Choose one of: ${EFFORTS.join(", ")}`); break; }
157
- st.effort = e;
158
- io.print(`Reasoning effort set to ${e}.`);
274
+ case "/logout": {
275
+ const pio = providerIO(st, io);
276
+ if (!pio) { io.print("This needs an interactive terminal."); break; }
277
+ const out = await logout(session(st), pio);
278
+ if (out) st.history[0] = systemMessage(st);
279
+ if (out === "locked") return { handled: true, relogin: true };
159
280
  break;
160
281
  }
282
+ case "/hooks":
283
+ await cmdHooks(io);
284
+ break;
285
+ case "/update":
286
+ await cmdUpdate(io);
287
+ break;
161
288
  case "/status":
162
289
  await cmdStatus(st, io);
163
290
  break;
package/dist/compact.js CHANGED
@@ -51,6 +51,8 @@ export const approxTokens = (history ) => Math.ceil(JSON.strin
51
51
 
52
52
 
53
53
 
54
+
55
+
54
56
 
55
57
 
56
58
 
@@ -64,7 +66,8 @@ export async function compactHistory(o )
64
66
  if (!text.trim()) throw new Error("nothing to compact");
65
67
  const r = await chat({
66
68
  apiKey: o.apiKey,
67
- model: o.tier.id,
69
+ model: o.modelId ?? o.tier.id,
70
+ baseUrl: o.baseUrl,
68
71
  messages: [
69
72
  { role: "system", content: COMPACT_PROMPT },
70
73
  { role: "user", content: text },
package/dist/config.js CHANGED
@@ -7,7 +7,17 @@ export const API_BASE = "https://api.synqa.ca/v1";
7
7
  export const ACCOUNT_URL = `${API_BASE}/account`;
8
8
  export const DASHBOARD_URL = "https://synqa.ca/client-dashboard";
9
9
 
10
-
10
+ // A secondary provider the user chose themselves (OpenCode, a bring-your-own router key, a custom
11
+ // endpoint). Keys live in the same private file as the Synqa key and never leave this machine
12
+ // except to that provider's own base URL.
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
11
21
 
12
22
  export function configDir(env = process.env) {
13
23
  // SYNQA_CONFIG_DIR is honoured only under the node test runner (NODE_TEST_CONTEXT is set);
@@ -29,11 +39,21 @@ export function readConfig(env = process.env
29
39
  }
30
40
  }
31
41
 
32
- // Directory 0700, file 0600, enforced even when they already existed with looser modes.
42
+ // chmod does nothing useful on Windows file systems and may fail there: the mode is best effort
43
+ // on win32 and enforced everywhere else.
44
+ function tryChmod(path , mode , platform = process.platform) {
45
+ try {
46
+ chmodSync(path, mode);
47
+ } catch (err) {
48
+ if (platform !== "win32") throw err;
49
+ }
50
+ }
51
+
52
+ // Directory 0700, file 0600, enforced even when they already existed with looser modes (POSIX).
33
53
  export function writeConfig(config , env = process.env) {
34
54
  const dir = configDir(env);
35
55
  mkdirSync(dir, { recursive: true, mode: 0o700 });
36
- chmodSync(dir, 0o700);
56
+ tryChmod(dir, 0o700);
37
57
  const file = configPath(env);
38
58
  // The key is never written into a pre-existing file: create a fresh 0600 temp file ("wx"
39
59
  // fails if it exists) in the 0700 directory, then rename it over the target.
@@ -46,7 +66,7 @@ export function writeConfig(config , env
46
66
  } finally {
47
67
  closeSync(fd);
48
68
  }
49
- chmodSync(tmp, 0o600);
69
+ tryChmod(tmp, 0o600);
50
70
  renameSync(tmp, file);
51
71
  renamed = true;
52
72
  } finally {
@@ -56,11 +76,34 @@ export function writeConfig(config , env
56
76
  return file;
57
77
  }
58
78
 
59
- // The env var wins over the saved key.
60
- export function resolveApiKey(env = process.env) {
61
- const fromEnv = env.SYNQA_API_KEY?.trim();
62
- if (fromEnv) return fromEnv;
63
- return readConfig(env).apiKey;
79
+ // Since 1.2.0 a key saved by /login or synqa login wins over SYNQA_API_KEY, so nobody has to
80
+ // unset a shell variable to use the key they just saved. The environment is the fallback when
81
+ // nothing is saved, and wins only when the caller passes the explicit --env-key flag.
82
+ export function resolveApiKey(env = process.env, opts = {}) {
83
+ const fromEnv = env.SYNQA_API_KEY?.trim() || undefined;
84
+ const saved = readConfig(env).apiKey?.trim() || undefined;
85
+ return opts.envKey ? fromEnv ?? saved : saved ?? fromEnv;
86
+ }
87
+
88
+ // The environment variable a secondary provider's key may come from.
89
+ export const PROVIDER_ENV = {
90
+ "opencode-go": "OPENCODE_API_KEY",
91
+ "opencode-zen": "OPENCODE_API_KEY",
92
+ openrouter: "OPENROUTER_API_KEY",
93
+ };
94
+
95
+ // The same priority for a secondary provider.
96
+ export function resolveProviderKey(id , env = process.env, opts = {}) {
97
+ const name = PROVIDER_ENV[id];
98
+ const fromEnv = name ? env[name]?.trim() || undefined : undefined;
99
+ const saved = readConfig(env).providers?.[id]?.apiKey?.trim() || undefined;
100
+ return opts.envKey ? fromEnv ?? saved : saved ?? fromEnv;
101
+ }
102
+
103
+ // Merge-writes one secondary provider into the file.
104
+ export function saveProvider(id , entry , env = process.env) {
105
+ const cfg = readConfig(env);
106
+ return writeConfig({ ...cfg, providers: { ...cfg.providers, [id]: { ...cfg.providers?.[id], ...entry } } }, env);
64
107
  }
65
108
 
66
109
  export function looksLikeKey(key ) {
package/dist/hooks.js ADDED
@@ -0,0 +1,219 @@
1
+ // Hooks: shell commands that run at SessionStart, PreToolUse, PostToolUse and Stop.
2
+ //
3
+ // Two files, merged: .synqa/hooks.json in the project and hooks.json in the user config folder.
4
+ // { "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ],
5
+ // "Stop": [ "echo done" ] } }
6
+ // A hook gets a JSON description of the event on stdin. A PreToolUse hook blocks the tool by
7
+ // exiting with code 2 or by printing {"decision":"block","reason":"..."}; any other output, JSON
8
+ // or not, is ignored without a message. A hook that fails to start, fails or times out never
9
+ // stops the session and never prints an error at start-up.
10
+ //
11
+ // A project file is repository content, so project hooks start switched OFF until the person
12
+ // turns them on with /hooks; user hooks start on.
13
+ import { spawn } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
16
+ import { delimiter, join, resolve } from "node:path";
17
+ import { configDir, readConfig, writeConfig } from "./config.js";
18
+ import { sanitize } from "./sanitize.js";
19
+
20
+ export const HOOK_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse", "Stop"] ;
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ const DEFAULT_TIMEOUT_MS = 10_000;
36
+ const MAX_TIMEOUT_MS = 60_000;
37
+ const MAX_OUTPUT = 64 * 1024;
38
+
39
+ export const projectHooksPath = (cwd ) => join(cwd, ".synqa", "hooks.json");
40
+ export const userHooksPath = (env = process.env) => join(configDir(env), "hooks.json");
41
+
42
+ function readJson(path ) {
43
+ try {
44
+ return JSON.parse(readFileSync(path, "utf8"));
45
+ } catch {
46
+ return undefined; // missing or malformed: no hooks, no noise
47
+ }
48
+ }
49
+
50
+ // The project root a project hook is approved for: the real path, else the resolved one.
51
+ function canonicalRoot(cwd ) {
52
+ const abs = resolve(cwd);
53
+ try { return realpathSync(abs); } catch { return abs; }
54
+ }
55
+
56
+ function parseFile(raw , scope , state , projectRoot = "") {
57
+ if (!raw || typeof raw !== "object") return [];
58
+ const root = raw ;
59
+ const table = (root.hooks && typeof root.hooks === "object" ? root.hooks : root) ;
60
+ const out = [];
61
+ for (const event of HOOK_EVENTS) {
62
+ const list = table[event];
63
+ if (!Array.isArray(list)) continue;
64
+ list.forEach((entry, index) => {
65
+ const e = typeof entry === "string" ? { command: entry } : (entry );
66
+ if (!e || typeof e.command !== "string" || e.command.trim() === "") return;
67
+ const command = e.command.trim();
68
+ const t = typeof e.timeout === "number" && e.timeout > 0 ? Math.min(e.timeout, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
69
+ const matcher = typeof e.matcher === "string" && e.matcher !== "" ? e.matcher : undefined;
70
+ // A project hook id binds the approval to the project root and the exact entry (command,
71
+ // matcher, timeout): another project or any edit gives a new id, which is off until approved.
72
+ const id = scope === "project"
73
+ ? `project:${event}:${index}:${createHash("sha256").update(JSON.stringify([projectRoot, command, matcher ?? null, t])).digest("hex")}`
74
+ : `${scope}:${event}:${index}:${createHash("sha1").update(command).digest("hex").slice(0, 6)}`;
75
+ out.push({
76
+ id, scope, event, command, timeoutMs: t,
77
+ matcher,
78
+ enabled: state[id] ?? scope === "user",
79
+ });
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function loadHooks(cwd , env = process.env) {
86
+ const state = readConfig(env).hooksState ?? {};
87
+ return [
88
+ ...parseFile(readJson(userHooksPath(env)), "user", state),
89
+ ...parseFile(readJson(projectHooksPath(cwd)), "project", state, canonicalRoot(cwd)),
90
+ ];
91
+ }
92
+
93
+ // Flips one hook on or off and saves the choice. Returns the new state, or undefined for an unknown id.
94
+ export function toggleHook(cwd , id , env = process.env) {
95
+ const hook = loadHooks(cwd, env).find((h) => h.id === id);
96
+ if (!hook) return undefined;
97
+ const cfg = readConfig(env);
98
+ const enabled = !hook.enabled;
99
+ writeConfig({ ...cfg, hooksState: { ...cfg.hooksState, [id]: enabled } }, env);
100
+ return enabled;
101
+ }
102
+
103
+ export function describeHook(h ) {
104
+ const cmd = sanitize(h.command).slice(0, 60);
105
+ return `${h.event}${h.matcher ? ` [${sanitize(h.matcher).slice(0, 30)}]` : ""} ${cmd} (${h.scope}, ${h.enabled ? "on" : "off"})`;
106
+ }
107
+
108
+ // ---------------------------------------------------------------- python fallback
109
+
110
+ // Is `name` an executable on PATH? Windows also tries the PATHEXT suffixes.
111
+ export function onPath(name , env = process.env, platform = process.platform) {
112
+ const path = env.PATH ?? env.Path ?? "";
113
+ const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean)] : [""];
114
+ for (const dir of path.split(platform === "win32" ? ";" : delimiter).filter(Boolean)) {
115
+ for (const ext of exts) {
116
+ const p = join(dir, name + ext);
117
+ try {
118
+ if (existsSync(p) && statSync(p).isFile()) return true;
119
+ } catch {
120
+ // unreadable entry: keep looking
121
+ }
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ // A command that starts with `python` runs with `python3` (or `py` on Windows) when plain python
128
+ // is not on PATH. Only the first word is rewritten.
129
+ export function resolveHookCommand(
130
+ command , platform = process.platform, has = (n) => onPath(n, process.env, platform),
131
+ ) {
132
+ const m = /^(\s*)(python(?:\.exe)?)(?=\s|$)/i.exec(command);
133
+ if (!m || has("python")) return command;
134
+ const candidates = platform === "win32" ? ["python3", "py"] : ["python3"];
135
+ const found = candidates.find((c) => has(c));
136
+ return found ? (m[1] ?? "") + found + command.slice(m[0].length) : command;
137
+ }
138
+
139
+ // ---------------------------------------------------------------- running
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+ // Reads {"decision":"block"} / {"block":true} out of free-form output; anything else is not a decision.
151
+ export function parseDecision(output ) {
152
+ const text = output.trim();
153
+ if (!text.startsWith("{")) return { blocked: false };
154
+ try {
155
+ const j = JSON.parse(text) ;
156
+ const d = typeof j.decision === "string" ? j.decision.toLowerCase() : "";
157
+ if (d === "block" || d === "deny" || j.block === true) {
158
+ return { blocked: true, reason: typeof j.reason === "string" ? sanitize(j.reason).slice(0, 300) : undefined };
159
+ }
160
+ } catch {
161
+ // not JSON after all: ignored
162
+ }
163
+ return { blocked: false };
164
+ }
165
+
166
+ function hookEnv(env , platform ) {
167
+ return platform === "win32" ? { ...env, NoDefaultCurrentDirectoryInExePath: "1" } : env;
168
+ }
169
+
170
+ export function runHook(hook , payload , cwd , d = {}) {
171
+ const platform = d.platform ?? process.platform;
172
+ const command = resolveHookCommand(hook.command, platform, d.has);
173
+ const done = (r ) => ({ hook, code: null, output: "", blocked: false, ...r });
174
+ return new Promise((resolve) => {
175
+ let out = "";
176
+ let settled = false;
177
+ const finish = (r ) => { if (!settled) { settled = true; clearTimeout(timer); resolve(r); } };
178
+ let child;
179
+ try {
180
+ child = (d.spawnFn ?? spawn)(command, { cwd, shell: true, env: hookEnv(d.env ?? process.env, platform), stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
181
+ } catch (err) {
182
+ resolve(done({ error: sanitize(err instanceof Error ? err.message : String(err)) }));
183
+ return;
184
+ }
185
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* already gone */ } finish(done({ error: "timed out", output: out })); }, hook.timeoutMs);
186
+ (timer ).unref?.();
187
+ child.stdout?.on("data", (c ) => { if (out.length < MAX_OUTPUT) out += c.toString("utf8"); });
188
+ child.on("error", (err ) => finish(done({ error: sanitize(err.message) })));
189
+ child.on("close", (code ) => {
190
+ const dec = parseDecision(out);
191
+ const exit2 = code === 2;
192
+ finish(done({ code, output: out, blocked: hook.event === "PreToolUse" && (exit2 || dec.blocked), reason: dec.reason }));
193
+ });
194
+ child.stdin?.on("error", () => {});
195
+ try {
196
+ child.stdin?.end(JSON.stringify(payload) + "\n");
197
+ } catch {
198
+ // a hook that does not read stdin is fine
199
+ }
200
+ });
201
+ }
202
+
203
+ // Runs every enabled hook of `event` (matching the tool name for the tool events), one after the
204
+ // other. Never throws.
205
+ export async function runHooks(hooks , event , payload , cwd , d = {}) {
206
+ const out = [];
207
+ for (const h of hooks) {
208
+ if (!h.enabled || h.event !== event) continue;
209
+ if (h.matcher && payload.tool !== undefined) {
210
+ try {
211
+ if (!new RegExp(`^(?:${h.matcher})$`).test(payload.tool)) continue;
212
+ } catch {
213
+ continue; // a bad pattern matches nothing
214
+ }
215
+ }
216
+ out.push(await runHook(h, { event, ...payload }, cwd, d));
217
+ }
218
+ return out;
219
+ }