@synqa/cli 1.1.1 → 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/README.md +66 -8
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +2 -1
- 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 +144 -66
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +56 -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 +29 -9
- package/dist/update.js +71 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -3,12 +3,20 @@ import { runAgent } from "./agent.js";
|
|
|
3
3
|
import { ApiError, checkKey } from "./api.js";
|
|
4
4
|
import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
|
|
5
5
|
|
|
6
|
-
import { looksLikeKey, readConfig, resolveApiKey, writeConfig, configPath } from "./config.js";
|
|
6
|
+
import { API_BASE, looksLikeKey, readConfig, resolveApiKey, resolveProviderKey, writeConfig, configPath } from "./config.js";
|
|
7
7
|
import { EditJournal } from "./journal.js";
|
|
8
8
|
import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { loadHooks, runHooks, toggleHook } from "./hooks.js";
|
|
13
|
+
|
|
14
|
+
import { MODE_LABEL, nextMode } from "./modes.js";
|
|
15
|
+
import { providerDef, unlockGate } from "./providers.js";
|
|
16
|
+
import { pickOne, readMasked, streamKeys } from "./select.js";
|
|
17
|
+
|
|
18
|
+
import { bashEnv } from "./tools.js";
|
|
19
|
+
import { checkForUpdate, runUpdate } from "./update.js";
|
|
12
20
|
|
|
13
21
|
// A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
|
|
14
22
|
// refused there, so say why once at start-up instead of leaving the user with refusals.
|
|
@@ -18,9 +26,10 @@ export function protectedWorkspaceWarning(cwd , home )
|
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
// The start-up notes both the session banner and a one-shot run print.
|
|
21
|
-
|
|
29
|
+
// envInUse: false when a saved key is used and SYNQA_API_KEY is ignored (no warning then).
|
|
30
|
+
export function startWarnings(env , cwd , home , envInUse = true) {
|
|
22
31
|
const out = [];
|
|
23
|
-
if (env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
|
|
32
|
+
if (envInUse && env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
|
|
24
33
|
const inProtected = protectedWorkspaceWarning(cwd, home);
|
|
25
34
|
if (inProtected) out.push(inProtected);
|
|
26
35
|
return out;
|
|
@@ -111,54 +120,31 @@ const BALANCE_TIMEOUT_MS = 2000;
|
|
|
111
120
|
const HELP = `Usage:
|
|
112
121
|
synqa interactive session in the current folder
|
|
113
122
|
synqa "do something" one-shot task
|
|
114
|
-
synqa login save your API key
|
|
123
|
+
synqa login save your Synqa API key
|
|
115
124
|
Options:
|
|
116
125
|
-y, --yes do not ask before ordinary file writes and edits
|
|
117
126
|
(shell commands always ask; not allowed in your home folder)
|
|
127
|
+
--env-key use SYNQA_API_KEY (and the other provider key variables)
|
|
128
|
+
instead of a key saved by /login
|
|
118
129
|
-m, --model <tier> blink | sonata | aeon (default blink)
|
|
119
|
-
-e, --effort <level> default | low | medium (default default)
|
|
130
|
+
-e, --effort <level> default | low | medium | high (default default)
|
|
120
131
|
-h, --help -v, --version
|
|
121
|
-
In a session, type / for the command menu (/model, /effort, /
|
|
122
|
-
/clear, /help, /exit)
|
|
132
|
+
In a session, type / for the command menu (/model, /effort, /provider, /login, /logout, /status,
|
|
133
|
+
/init, /compact, /undo, /hooks, /update, /clear, /help, /exit). Shift+Tab cycles the permission
|
|
134
|
+
mode (ask before edits, auto-accept edits, plan only).`;
|
|
123
135
|
|
|
124
|
-
// Said when SYNQA_API_KEY is set
|
|
125
|
-
|
|
136
|
+
// Said when SYNQA_API_KEY is set in the shell but a key saved by synqa login or /login exists:
|
|
137
|
+
// the saved key wins (since 1.2.0), so there is nothing to unset.
|
|
138
|
+
export const ENV_KEY_NOTE = "SYNQA_API_KEY is set in this shell and takes effect only because the --env-key flag was passed or no key is saved. A key saved by synqa login or /login wins otherwise.";
|
|
126
139
|
|
|
127
|
-
// Masked single-line input:
|
|
128
|
-
export function readSecret(promptText ) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
process.stdout.write(promptText);
|
|
136
|
-
let value = "";
|
|
137
|
-
stdin.setRawMode(true);
|
|
138
|
-
stdin.resume();
|
|
139
|
-
stdin.setEncoding("utf8");
|
|
140
|
-
const onData = (chunk ) => {
|
|
141
|
-
for (const ch of chunk) {
|
|
142
|
-
if (ch === "\r" || ch === "\n") {
|
|
143
|
-
stdin.setRawMode(false);
|
|
144
|
-
stdin.pause();
|
|
145
|
-
stdin.off("data", onData);
|
|
146
|
-
process.stdout.write("\n");
|
|
147
|
-
resolve(value.trim());
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (ch === "\u0003") {
|
|
151
|
-
stdin.setRawMode(false);
|
|
152
|
-
process.stdout.write("\n");
|
|
153
|
-
reject(new Error("cancelled"));
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
|
|
157
|
-
else if (ch >= " ") value += ch;
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
stdin.on("data", onData);
|
|
161
|
-
});
|
|
140
|
+
// Masked single-line input on the process terminal: each character shows as a bullet.
|
|
141
|
+
export async function readSecret(promptText ) {
|
|
142
|
+
const stdin = process.stdin;
|
|
143
|
+
if (!stdin.isTTY) throw new Error("login needs an interactive terminal (or set SYNQA_API_KEY)");
|
|
144
|
+
const v = await readMasked(promptText, { keys: streamKeys(stdin), out: process.stdout });
|
|
145
|
+
stdin.pause();
|
|
146
|
+
if (v === undefined) throw new Error("cancelled");
|
|
147
|
+
return v;
|
|
162
148
|
}
|
|
163
149
|
|
|
164
150
|
// The error text can come from the network, so it is sanitized before printing.
|
|
@@ -180,18 +166,17 @@ export async function login() {
|
|
|
180
166
|
}
|
|
181
167
|
const file = writeConfig({ ...readConfig(), apiKey: key });
|
|
182
168
|
console.log(`Key verified and saved to ${file}`);
|
|
183
|
-
const fromEnv = process.env.SYNQA_API_KEY?.trim();
|
|
184
|
-
if (fromEnv && fromEnv !== key) console.error(`\nWarning: ${ENV_KEY_NOTE}`);
|
|
185
169
|
return 0;
|
|
186
170
|
}
|
|
187
171
|
|
|
188
|
-
|
|
172
|
+
|
|
189
173
|
|
|
190
174
|
export function parseArgs(argv ) {
|
|
191
|
-
const a = { allowHome: false, yes: false, model: "", effort: "", help: false, version: false, rest: [] };
|
|
175
|
+
const a = { allowHome: false, yes: false, envKey: false, model: "", effort: "", help: false, version: false, rest: [] };
|
|
192
176
|
for (let i = 0; i < argv.length; i++) {
|
|
193
177
|
const t = argv[i] ;
|
|
194
178
|
if (t === "-y" || t === "--yes") a.yes = true;
|
|
179
|
+
else if (t === "--env-key") a.envKey = true;
|
|
195
180
|
else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
|
|
196
181
|
else if (t === "-h" || t === "--help") a.help = true;
|
|
197
182
|
else if (t === "-v" || t === "--version") a.version = true;
|
|
@@ -202,6 +187,20 @@ export function parseArgs(argv ) {
|
|
|
202
187
|
return a;
|
|
203
188
|
}
|
|
204
189
|
|
|
190
|
+
// Where a session starts: the saved active provider if its credentials are still there, else Synqa Cloud.
|
|
191
|
+
export function startProvider(synqaKey , env , envKey ) {
|
|
192
|
+
const cfg = readConfig(env);
|
|
193
|
+
const id = cfg.activeProvider;
|
|
194
|
+
const def = id ? providerDef(id) : undefined;
|
|
195
|
+
if (id && def && id !== "synqa") {
|
|
196
|
+
const entry = cfg.providers?.[id];
|
|
197
|
+
const key = resolveProviderKey(id, env, { envKey });
|
|
198
|
+
const base = id === "custom" ? entry?.baseUrl : def.baseUrl;
|
|
199
|
+
if (key && base) return { apiKey: key, providerId: def.id, baseUrl: base, modelId: entry?.model };
|
|
200
|
+
}
|
|
201
|
+
return { apiKey: synqaKey, providerId: "synqa", baseUrl: API_BASE };
|
|
202
|
+
}
|
|
203
|
+
|
|
205
204
|
// `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
|
|
206
205
|
export async function main(argv , input = process.stdin) {
|
|
207
206
|
const args = parseArgs(argv);
|
|
@@ -216,8 +215,11 @@ export async function main(argv , input = process.stdin)
|
|
|
216
215
|
}
|
|
217
216
|
const yesRefused = homeYesRefusal(process.cwd(), args.yes);
|
|
218
217
|
if (yesRefused) { console.error(yesRefused); return 1; }
|
|
219
|
-
const
|
|
220
|
-
|
|
218
|
+
const interactive = Boolean(input.isTTY) && args.rest.length === 0;
|
|
219
|
+
const synqaKey0 = resolveApiKey(process.env, { envKey: args.envKey }) ?? "";
|
|
220
|
+
// A terminal session with no usable Synqa key shows the welcome screen and signs in first.
|
|
221
|
+
const needSignIn = interactive && !looksLikeKey(synqaKey0);
|
|
222
|
+
if (!synqaKey0 && !interactive) {
|
|
221
223
|
console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
|
|
222
224
|
return 1;
|
|
223
225
|
}
|
|
@@ -226,9 +228,8 @@ export async function main(argv , input = process.stdin)
|
|
|
226
228
|
const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
|
|
227
229
|
if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
|
|
228
230
|
const effort = args.effort ? findEffort(args.effort) : "default";
|
|
229
|
-
if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or
|
|
231
|
+
if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low, medium or high.`); return 1; }
|
|
230
232
|
|
|
231
|
-
const interactive = Boolean(input.isTTY) && args.rest.length === 0;
|
|
232
233
|
// On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
|
|
233
234
|
const framed = interactive && ui.tty && ui.unicode;
|
|
234
235
|
let statusLines = () => [];
|
|
@@ -253,33 +254,72 @@ export async function main(argv , input = process.stdin)
|
|
|
253
254
|
return /^y(es)?$/i.test(ans.trim());
|
|
254
255
|
};
|
|
255
256
|
|
|
257
|
+
// The arrow-key controls: every key goes to the selector or the masked prompt, none to readline.
|
|
258
|
+
const keySource = keys ?? (input.isTTY ? streamKeys(input) : undefined);
|
|
259
|
+
const pick = keySource
|
|
260
|
+
? (items , title ) => pickOne(items, { keys: keySource, out: term, style, unicode: ui.unicode, title })
|
|
261
|
+
: undefined;
|
|
262
|
+
const secret = keySource ? (p ) => readMasked(p, { keys: keySource, out: term, unicode: ui.unicode }) : undefined;
|
|
263
|
+
const ask = interactive ? async (p ) => { try { return (await rl.question(p)).trim(); } catch { return undefined; } } : undefined;
|
|
264
|
+
|
|
256
265
|
const cwd = process.cwd();
|
|
266
|
+
const sp = startProvider(synqaKey0, process.env, args.envKey);
|
|
257
267
|
const st = {
|
|
258
|
-
apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
268
|
+
apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
259
269
|
total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
|
|
270
|
+
providerId: sp.providerId, baseUrl: sp.baseUrl, modelId: sp.modelId, mode: "ask",
|
|
260
271
|
};
|
|
261
272
|
st.history = freshHistory(st);
|
|
262
|
-
const ctx = {
|
|
273
|
+
const ctx = {
|
|
274
|
+
cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)),
|
|
275
|
+
get notesLoaded() { return st.notesChars > 0; },
|
|
276
|
+
get mode() { return st.mode; },
|
|
277
|
+
};
|
|
278
|
+
const external = () => (st.providerId ?? "synqa") !== "synqa";
|
|
279
|
+
const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
|
|
280
|
+
|
|
281
|
+
// Hooks: shell commands at SessionStart, PreToolUse, PostToolUse and Stop. They never print errors.
|
|
282
|
+
const fire = async (event , payload = {}) => {
|
|
283
|
+
if (st.mode === "plan" && (event === "PostToolUse" || event === "Stop")) return []; // plan mode runs nothing after the fact
|
|
284
|
+
try { return await runHooks(loadHooks(cwd), event, { cwd, ...payload }, cwd, { env: bashEnv(process.env) }); } catch { return []; }
|
|
285
|
+
};
|
|
286
|
+
const toolHooks = {
|
|
287
|
+
pre: async (name , a ) => {
|
|
288
|
+
const blocked = (await fire("PreToolUse", { tool: name, input: a.slice(0, 4000) })).find((h) => h.blocked);
|
|
289
|
+
return blocked ? blocked.reason ?? "stopped by a PreToolUse hook" : undefined;
|
|
290
|
+
},
|
|
291
|
+
post: async (name , a , result ) => { await fire("PostToolUse", { tool: name, input: a.slice(0, 4000), result: result.slice(0, 4000) }); },
|
|
292
|
+
};
|
|
263
293
|
|
|
264
294
|
// Known balance for the footer and status line; refreshed in the background after each turn.
|
|
265
295
|
let balance = { kind: "unavailable" };
|
|
296
|
+
let latestVersion ;
|
|
266
297
|
// The status line under the input: only known values (tier, workspace, context, tokens, credit).
|
|
267
298
|
statusLines = () => renderStatusLines({
|
|
268
299
|
tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
|
|
269
300
|
contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
|
|
301
|
+
provider: providerInfo(), mode: MODE_LABEL[st.mode ?? "ask"], updateHint: latestVersion ? `update ${latestVersion}: /update` : undefined,
|
|
270
302
|
}, ui);
|
|
271
303
|
const refresh = () => { frame?.redraw(); };
|
|
304
|
+
// Shift+Tab cycles the permission mode; the footer shows it.
|
|
305
|
+
if (keys) keys.onShiftTab = () => { st.mode = nextMode(st.mode ?? "ask"); };
|
|
272
306
|
// Raw mode and bracketed paste are always switched off again, also after a crash.
|
|
273
307
|
const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
|
|
274
|
-
|
|
275
|
-
|
|
308
|
+
// 'resize' (not SIGWINCH) fires on Windows, macOS and Linux; a changed width redraws the frame.
|
|
309
|
+
process.stdout.on("resize", () => { if (frame) frame.onResize(); else refresh(); });
|
|
310
|
+
// The balance belongs to Synqa Cloud: it is never asked with a secondary provider's key.
|
|
311
|
+
const loadBalance = async () => {
|
|
312
|
+
const key = resolveApiKey(process.env, { envKey: args.envKey });
|
|
313
|
+
return key ? balanceFrom(await fetchAccount(key, undefined, BALANCE_TIMEOUT_MS)) : { kind: "unavailable" };
|
|
314
|
+
};
|
|
276
315
|
const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
|
|
277
316
|
|
|
278
317
|
const turn = async (text ) => {
|
|
279
318
|
st.history.push({ role: "user", content: text });
|
|
280
319
|
try {
|
|
281
320
|
const r = await runAgent({
|
|
282
|
-
apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
|
|
321
|
+
apiKey: st.apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
|
|
322
|
+
baseUrl: external() ? st.baseUrl : undefined, modelId: external() ? st.modelId : undefined, hooks: toolHooks,
|
|
283
323
|
events: chrome
|
|
284
324
|
? {
|
|
285
325
|
onRequest: () => renderer.requestStart(),
|
|
@@ -303,41 +343,74 @@ export async function main(argv , input = process.stdin)
|
|
|
303
343
|
renderer.turnEnd();
|
|
304
344
|
const footer = formatFooter({
|
|
305
345
|
ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
|
|
306
|
-
creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
|
|
346
|
+
creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
|
|
307
347
|
}, ui);
|
|
308
348
|
if (footer) say(stone(style, footer));
|
|
309
|
-
refreshBalance();
|
|
349
|
+
if (!external()) refreshBalance();
|
|
310
350
|
} else {
|
|
311
351
|
process.stdout.write("\n");
|
|
312
352
|
}
|
|
313
353
|
} catch (err) {
|
|
314
|
-
spinner.stop();
|
|
354
|
+
spinner.stop(); // the thinking timer ends on an error or an abort too
|
|
315
355
|
const msg = sanitize(err instanceof Error ? err.message : String(err));
|
|
316
|
-
const envNote = err instanceof ApiError && err.status === 401 && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
|
|
356
|
+
const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
|
|
317
357
|
console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
|
|
318
358
|
}
|
|
359
|
+
await fire("Stop", { tokens: st.total.prompt + st.total.completion });
|
|
319
360
|
refresh();
|
|
320
361
|
};
|
|
321
362
|
|
|
363
|
+
// Welcome screen and the Synqa-first gate: chat and every other provider stay locked until a
|
|
364
|
+
// valid Synqa Cloud key is saved. Returns false when the person cancels.
|
|
365
|
+
const bannerInfo = (locked ) => ({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings: locked ? [] : startWarnings(process.env, cwd, undefined, apiKeyFromEnv()), provider: providerInfo(), locked });
|
|
366
|
+
const apiKeyFromEnv = () => {
|
|
367
|
+
const e = process.env.SYNQA_API_KEY?.trim();
|
|
368
|
+
return Boolean(e) && resolveApiKey(process.env, { envKey: args.envKey }) === e;
|
|
369
|
+
};
|
|
370
|
+
const signIn = async () => {
|
|
371
|
+
if (!secret) return false;
|
|
372
|
+
say("");
|
|
373
|
+
for (const line of renderBanner(bannerInfo(true), ui)) say(line);
|
|
374
|
+
say("");
|
|
375
|
+
const key = await unlockGate({ secret, print: (m) => say(dim(style, m)) });
|
|
376
|
+
if (!key) return false;
|
|
377
|
+
const p = startProvider(key, process.env, args.envKey);
|
|
378
|
+
st.apiKey = p.apiKey; st.providerId = p.providerId; st.baseUrl = p.baseUrl; st.modelId = p.modelId;
|
|
379
|
+
st.history = freshHistory(st);
|
|
380
|
+
say(dim(style, "Signed in to Synqa Cloud."));
|
|
381
|
+
return true;
|
|
382
|
+
};
|
|
383
|
+
|
|
322
384
|
try {
|
|
323
385
|
if (!interactive) {
|
|
324
386
|
const prompt = args.rest.join(" ");
|
|
325
387
|
if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
|
|
326
388
|
announceNotes(st, say);
|
|
327
|
-
for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
|
|
389
|
+
for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
|
|
390
|
+
await fire("SessionStart", { source: "one-shot" });
|
|
328
391
|
await turn(prompt);
|
|
329
392
|
say(dim(style, formatStatus(statusParts(st))));
|
|
330
393
|
return 0;
|
|
331
394
|
}
|
|
332
395
|
|
|
396
|
+
if (needSignIn) {
|
|
397
|
+
if (!(await signIn())) { say(dim(style, "Not signed in. Run synqa again to sign in to Synqa Cloud.")); return 1; }
|
|
398
|
+
}
|
|
333
399
|
// The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
|
|
334
|
-
balance = await loadBalance();
|
|
335
|
-
const warnings = startWarnings(process.env, cwd);
|
|
400
|
+
if (!external()) balance = await loadBalance();
|
|
336
401
|
say("");
|
|
337
|
-
for (const line of renderBanner(
|
|
402
|
+
if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
|
|
338
403
|
say("");
|
|
339
404
|
if (!frame) say(dim(style, "Type /help for commands."));
|
|
340
405
|
announceNotes(st, (t) => say(dim(style, t)));
|
|
406
|
+
await fire("SessionStart", { source: "startup" });
|
|
407
|
+
// A quiet, short check for a newer release; any failure is silent and nothing waits for it.
|
|
408
|
+
if (chrome && !process.env.SYNQA_NO_UPDATE_CHECK) void checkForUpdate(VERSION).then((v) => { latestVersion = v; if (v) refresh(); }, () => {});
|
|
409
|
+
const io = {
|
|
410
|
+
print: say, confirm, style, ui, pick, secret, ask, envKey: args.envKey,
|
|
411
|
+
hooks: { list: () => loadHooks(cwd), toggle: (id ) => toggleHook(cwd, id) },
|
|
412
|
+
update: { current: VERSION, get latest() { return latestVersion; }, run: () => runUpdate() },
|
|
413
|
+
};
|
|
341
414
|
for (;;) {
|
|
342
415
|
// Without the frame the status is printed above each prompt instead.
|
|
343
416
|
if (!frame) say(dim(style, formatStatus(statusParts(st))));
|
|
@@ -346,9 +419,14 @@ export async function main(argv , input = process.stdin)
|
|
|
346
419
|
line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
|
|
347
420
|
} catch { break; }
|
|
348
421
|
if (!line) continue;
|
|
349
|
-
const r = await runCommand(line, st,
|
|
422
|
+
const r = await runCommand(line, st, io);
|
|
350
423
|
refresh();
|
|
351
424
|
if (r.exit) break;
|
|
425
|
+
if (r.relogin) {
|
|
426
|
+
// A full logout locks the session: back to the sign-in prompt, nothing else works until then.
|
|
427
|
+
if (!(await signIn())) { say(dim(style, "Locked. Run synqa again to sign in.")); break; }
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
352
430
|
if (!r.handled) await turn(line);
|
|
353
431
|
}
|
|
354
432
|
return 0;
|
package/dist/modes.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Permission modes of the session, cycled with Shift+Tab at the prompt and shown in the footer.
|
|
2
|
+
// ask every file write and edit asks first (the default)
|
|
3
|
+
// auto file writes and edits inside the workspace are applied without a question, like --yes
|
|
4
|
+
// (shell commands, secret files and files that can run code later still ask)
|
|
5
|
+
// plan no file is written and no command is run: the agent can only read and describe a plan
|
|
6
|
+
export const MODES = ["ask", "auto", "plan"] ;
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const MODE_LABEL = {
|
|
10
|
+
ask: "ask before edits",
|
|
11
|
+
auto: "auto-accept edits",
|
|
12
|
+
plan: "plan only",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function nextMode(m ) {
|
|
16
|
+
return MODES[(MODES.indexOf(m) + 1) % MODES.length] ;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Tools that change something; plan mode refuses them.
|
|
20
|
+
export const MUTATING_TOOLS = ["write_file", "edit_file", "bash"];
|