@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/README.md +74 -9
- package/bin/synqa.js +0 -0
- package/dist/agent.js +84 -14
- package/dist/api.js +68 -7
- package/dist/commands.js +144 -17
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +180 -75
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +84 -5
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/tiers.js +1 -1
- package/dist/tools.js +9 -1
- package/dist/ui.js +137 -10
- package/dist/update.js +71 -0
- package/package.json +1 -1
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
st
|
|
149
|
-
|
|
150
|
-
|
|
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 "/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
st.
|
|
158
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|