@synqa/cli 1.1.0 → 1.2.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/dist/commands.js CHANGED
@@ -13,8 +13,19 @@ 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
 
27
+ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
28
+
18
29
 
19
30
 
20
31
 
@@ -30,29 +41,35 @@ import { CONTEXT_TOKENS, EFFORTS, findEffort, findTier, TIERS } from "./tiers.js
30
41
 
31
42
 
32
43
 
44
+
45
+
46
+
47
+
33
48
 
34
49
 
35
50
 
36
51
 
37
52
 
38
53
 
54
+
39
55
 
40
56
 
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
41
65
 
42
66
 
43
-
67
+ // relogin: a full logout locked the session; the caller shows the sign-in prompt again.
68
+
69
+
70
+ export { SLASH_HELP };
44
71
 
45
- export const SLASH_HELP = [
46
- "/model blink|sonata|aeon switch tier",
47
- "/effort default|low|medium set reasoning effort",
48
- "/status tier, routing, session tokens, credit and weekly pool",
49
- "/init write a starter AGENTS.md for this project",
50
- "/compact summarize the conversation to save context",
51
- "/undo revert the last file the agent wrote (not shell commands)",
52
- "/clear start a fresh conversation",
53
- "/help this text",
54
- "/exit leave",
55
- ].join("\n");
72
+ export const isExternal = (st ) => (st.providerId ?? "synqa") !== "synqa";
56
73
 
57
74
  export function statusParts(st ) {
58
75
  return {
@@ -69,7 +86,7 @@ export function statusParts(st ) {
69
86
  export function systemMessage(st , tier = st.tier) {
70
87
  const notes = loadNotes(st.cwd);
71
88
  st.notesChars = notes.length;
72
- return { role: "system", content: systemPrompt(tier, st.cwd, notes) };
89
+ return { role: "system", content: systemPrompt(tier, st.cwd, notes, isExternal(st)) };
73
90
  }
74
91
 
75
92
  export function freshHistory(st ) {
@@ -88,12 +105,15 @@ const errText = (err ) => sanitize(err instanceof Error ? err.message :
88
105
  async function cmdStatus(st , io ) {
89
106
  const u = st.total;
90
107
  io.print([
91
- `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"]}`,
92
110
  `Routing ${st.routing}`,
93
111
  `Effort ${st.effort}`,
94
112
  `Session ${groupDigits(u.prompt + u.completion)} tokens (${groupDigits(u.prompt)} in, ${groupDigits(u.completion)} out)`,
95
113
  st.last.prompt > 0 ? `Context ${groupDigits(st.last.prompt)} of ${groupDigits(CONTEXT_TOKENS)} used by the last request` : "Context no request yet",
96
114
  ].join("\n"));
115
+ // The balance belongs to Synqa Cloud: a secondary provider's key is never sent there.
116
+ if (isExternal(st)) return;
97
117
  const r = await fetchAccount(st.apiKey, io.fetchFn);
98
118
  io.print(r.ok ? formatAccount(r.account).join("\n") : `Balance unavailable: ${sanitize(r.message)}`);
99
119
  }
@@ -120,7 +140,7 @@ async function cmdCompact(st , io ) {
120
140
  if (st.history.length <= 1) { io.print("Nothing to compact yet."); return; }
121
141
  io.print("Compacting the conversation...");
122
142
  try {
123
- 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 });
124
144
  st.history = r.history;
125
145
  if (r.usage) {
126
146
  st.total = { prompt: st.total.prompt + r.usage.prompt, completion: st.total.completion + r.usage.completion };
@@ -131,6 +151,93 @@ async function cmdCompact(st , io ) {
131
151
  }
132
152
  }
133
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
+
134
241
  export async function runCommand(line , st , io ) {
135
242
  const [first = "", ...args] = line.trim().split(/\s+/);
136
243
  // A first word like /usr/bin/foo is a path in a question, not a command.
@@ -141,30 +248,43 @@ export async function runCommand(line , st , io )
141
248
  case "/quit":
142
249
  return { handled: true, exit: true };
143
250
  case "/help":
144
- io.print(SLASH_HELP);
251
+ io.print(io.ui ? renderHelp(io.ui).join("\n") : SLASH_HELP);
145
252
  break;
146
253
  case "/clear":
147
254
  st.history = freshHistory(st);
148
255
  io.print("Conversation cleared.");
149
256
  announceNotes(st, io.print);
150
257
  break;
151
- case "/model": {
152
- if (!arg) { io.print(`Tier is ${st.tier.display}. Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
153
- const t = findTier(arg);
154
- if (!t) { io.print(`Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
155
- st.tier = t;
156
- st.history[0] = systemMessage(st, t);
157
- 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
+ }
158
272
  break;
159
273
  }
160
- case "/effort": {
161
- if (!arg) { io.print(`Effort is ${st.effort}. Choose one of: ${EFFORTS.join(", ")}`); break; }
162
- const e = findEffort(arg);
163
- if (!e) { io.print(`Choose one of: ${EFFORTS.join(", ")}`); break; }
164
- st.effort = e;
165
- 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 };
166
280
  break;
167
281
  }
282
+ case "/hooks":
283
+ await cmdHooks(io);
284
+ break;
285
+ case "/update":
286
+ await cmdUpdate(io);
287
+ break;
168
288
  case "/status":
169
289
  await cmdStatus(st, io);
170
290
  break;
@@ -184,7 +304,7 @@ export async function runCommand(line , st , io )
184
304
  await cmdUndo(st, io);
185
305
  break;
186
306
  default:
187
- io.print("Unknown command. /help lists them.");
307
+ io.print(unknownCommandHint(first));
188
308
  }
189
309
  return { handled: true };
190
310
  }
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
+ }