@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/main.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { runAgent } from "./agent.js";
|
|
3
|
-
import { ApiError, checkKey } from "./api.js";
|
|
3
|
+
import { AbortedError, 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;
|
|
@@ -99,7 +108,7 @@ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from
|
|
|
99
108
|
import { accent, dim, stone } from "./theme.js";
|
|
100
109
|
|
|
101
110
|
import { fetchAccount } from "./account.js";
|
|
102
|
-
import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
|
|
111
|
+
import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, ToolProgressLine, TurnRenderer } from "./ui.js";
|
|
103
112
|
import { PromptFrame } from "./prompt.js";
|
|
104
113
|
|
|
105
114
|
|
|
@@ -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 = () => [];
|
|
@@ -241,45 +242,99 @@ export async function main(argv , input = process.stdin)
|
|
|
241
242
|
// the plain text output they had before.
|
|
242
243
|
const chrome = interactive && ui.tty;
|
|
243
244
|
const spinner = new Spinner(process.stdout, { enabled: chrome, ui });
|
|
244
|
-
const
|
|
245
|
+
const progress = new ToolProgressLine(process.stdout, { enabled: chrome, ui });
|
|
246
|
+
const renderer = new TurnRenderer(process.stdout, ui, spinner, progress);
|
|
245
247
|
const inCard = (t ) => (chrome ? renderer.body(t) : t);
|
|
246
248
|
// `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
|
|
247
249
|
// (the tools already escaped every file character in it).
|
|
248
250
|
const confirm = async (q , detail ) => {
|
|
249
251
|
if (!input.isTTY) return false;
|
|
250
252
|
spinner.stop();
|
|
253
|
+
progress.stop();
|
|
251
254
|
if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
|
|
252
|
-
|
|
255
|
+
// The turn lock lets keys through for the length of the question.
|
|
256
|
+
const ask = () => rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
|
|
257
|
+
const ans = keys ? await keys.withKeys(ask) : await ask();
|
|
253
258
|
return /^y(es)?$/i.test(ans.trim());
|
|
254
259
|
};
|
|
255
260
|
|
|
261
|
+
// The arrow-key controls: every key goes to the selector or the masked prompt, none to readline.
|
|
262
|
+
const keySource = keys ?? (input.isTTY ? streamKeys(input) : undefined);
|
|
263
|
+
const pick = keySource
|
|
264
|
+
? (items , title ) => pickOne(items, { keys: keySource, out: term, style, unicode: ui.unicode, title })
|
|
265
|
+
: undefined;
|
|
266
|
+
const secret = keySource ? (p ) => readMasked(p, { keys: keySource, out: term, unicode: ui.unicode }) : undefined;
|
|
267
|
+
const ask = interactive ? async (p ) => { try { return (await rl.question(p)).trim(); } catch { return undefined; } } : undefined;
|
|
268
|
+
|
|
256
269
|
const cwd = process.cwd();
|
|
270
|
+
const sp = startProvider(synqaKey0, process.env, args.envKey);
|
|
257
271
|
const st = {
|
|
258
|
-
apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
272
|
+
apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
|
|
259
273
|
total: { prompt: 0, completion: 0 }, last: { prompt: 0, completion: 0 }, routing: "?", notesChars: 0, notesShown: 0,
|
|
274
|
+
providerId: sp.providerId, baseUrl: sp.baseUrl, modelId: sp.modelId, mode: "ask",
|
|
260
275
|
};
|
|
261
276
|
st.history = freshHistory(st);
|
|
262
|
-
const ctx = {
|
|
277
|
+
const ctx = {
|
|
278
|
+
cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)),
|
|
279
|
+
get notesLoaded() { return st.notesChars > 0; },
|
|
280
|
+
get mode() { return st.mode; },
|
|
281
|
+
};
|
|
282
|
+
const external = () => (st.providerId ?? "synqa") !== "synqa";
|
|
283
|
+
const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
|
|
284
|
+
|
|
285
|
+
// Hooks: shell commands at SessionStart, PreToolUse, PostToolUse and Stop. They never print errors.
|
|
286
|
+
const fire = async (event , payload = {}) => {
|
|
287
|
+
if (st.mode === "plan" && (event === "PostToolUse" || event === "Stop")) return []; // plan mode runs nothing after the fact
|
|
288
|
+
try { return await runHooks(loadHooks(cwd), event, { cwd, ...payload }, cwd, { env: bashEnv(process.env) }); } catch { return []; }
|
|
289
|
+
};
|
|
290
|
+
const toolHooks = {
|
|
291
|
+
pre: async (name , a ) => {
|
|
292
|
+
const blocked = (await fire("PreToolUse", { tool: name, input: a.slice(0, 4000) })).find((h) => h.blocked);
|
|
293
|
+
return blocked ? blocked.reason ?? "stopped by a PreToolUse hook" : undefined;
|
|
294
|
+
},
|
|
295
|
+
post: async (name , a , result ) => { await fire("PostToolUse", { tool: name, input: a.slice(0, 4000), result: result.slice(0, 4000) }); },
|
|
296
|
+
};
|
|
263
297
|
|
|
264
298
|
// Known balance for the footer and status line; refreshed in the background after each turn.
|
|
265
299
|
let balance = { kind: "unavailable" };
|
|
300
|
+
let latestVersion ;
|
|
266
301
|
// The status line under the input: only known values (tier, workspace, context, tokens, credit).
|
|
267
302
|
statusLines = () => renderStatusLines({
|
|
268
303
|
tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
|
|
269
304
|
contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
|
|
305
|
+
provider: providerInfo(), mode: MODE_LABEL[st.mode ?? "ask"], updateHint: latestVersion ? `update ${latestVersion}: /update` : undefined,
|
|
270
306
|
}, ui);
|
|
271
307
|
const refresh = () => { frame?.redraw(); };
|
|
308
|
+
// Shift+Tab cycles the permission mode; the footer shows it.
|
|
309
|
+
if (keys) keys.onShiftTab = () => { st.mode = nextMode(st.mode ?? "ask"); };
|
|
272
310
|
// Raw mode and bracketed paste are always switched off again, also after a crash.
|
|
273
311
|
const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
|
|
274
|
-
|
|
275
|
-
|
|
312
|
+
// 'resize' (not SIGWINCH) fires on Windows, macOS and Linux; a changed width redraws the frame.
|
|
313
|
+
process.stdout.on("resize", () => { if (frame) frame.onResize(); else refresh(); });
|
|
314
|
+
// The balance belongs to Synqa Cloud: it is never asked with a secondary provider's key.
|
|
315
|
+
const loadBalance = async () => {
|
|
316
|
+
const key = resolveApiKey(process.env, { envKey: args.envKey });
|
|
317
|
+
return key ? balanceFrom(await fetchAccount(key, undefined, BALANCE_TIMEOUT_MS)) : { kind: "unavailable" };
|
|
318
|
+
};
|
|
276
319
|
const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
|
|
277
320
|
|
|
278
|
-
|
|
321
|
+
// Returns true when the person stopped the turn with Esc or Ctrl+C.
|
|
322
|
+
const turn = async (text ) => {
|
|
279
323
|
st.history.push({ role: "user", content: text });
|
|
324
|
+
const ac = new AbortController();
|
|
325
|
+
let stopped = false;
|
|
326
|
+
// While the turn runs the keyboard is locked: nothing typed is echoed; Esc or Ctrl+C stops the
|
|
327
|
+
// request. A second press, with the turn still not ended (a long shell command), leaves.
|
|
328
|
+
keys?.beginTurn(() => {
|
|
329
|
+
if (ac.signal.aborted) { restore(); process.exit(130); }
|
|
330
|
+
stopped = true;
|
|
331
|
+
ac.abort();
|
|
332
|
+
});
|
|
280
333
|
try {
|
|
281
334
|
const r = await runAgent({
|
|
282
|
-
apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
|
|
335
|
+
apiKey: st.apiKey, tier: st.tier, effort: st.effort, history: st.history, ctx,
|
|
336
|
+
baseUrl: external() ? st.baseUrl : undefined, modelId: external() ? st.modelId : undefined, hooks: toolHooks,
|
|
337
|
+
signal: ac.signal,
|
|
283
338
|
events: chrome
|
|
284
339
|
? {
|
|
285
340
|
onRequest: () => renderer.requestStart(),
|
|
@@ -287,11 +342,14 @@ export async function main(argv , input = process.stdin)
|
|
|
287
342
|
onTool: (name, a) => renderer.toolStart(name, a),
|
|
288
343
|
onToolResult: (name, _a, result) => renderer.toolResult(name, result),
|
|
289
344
|
onTurn: () => renderer.requestEnd(),
|
|
345
|
+
onToolProgress: (p) => renderer.toolProgress(p),
|
|
346
|
+
onNotice: (t) => { renderer.requestEnd(); say(dim(style, `[${t}]`)); },
|
|
290
347
|
}
|
|
291
348
|
: {
|
|
292
349
|
onText: (t) => process.stdout.write(sanitize(t)),
|
|
293
350
|
onTool: (name, a) => process.stdout.write(`\n${dim(style, `> ${sanitize(name)} ${sanitize(a.length > 120 ? a.slice(0, 117) + "..." : a)}`)}\n`),
|
|
294
351
|
onTurn: () => {},
|
|
352
|
+
onNotice: (t) => console.error(`[${t}]`),
|
|
295
353
|
},
|
|
296
354
|
});
|
|
297
355
|
st.total = { prompt: st.total.prompt + r.total.prompt, completion: st.total.completion + r.total.completion };
|
|
@@ -303,20 +361,51 @@ export async function main(argv , input = process.stdin)
|
|
|
303
361
|
renderer.turnEnd();
|
|
304
362
|
const footer = formatFooter({
|
|
305
363
|
ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
|
|
306
|
-
creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
|
|
364
|
+
creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
|
|
307
365
|
}, ui);
|
|
308
366
|
if (footer) say(stone(style, footer));
|
|
309
|
-
refreshBalance();
|
|
367
|
+
if (!external()) refreshBalance();
|
|
310
368
|
} else {
|
|
311
369
|
process.stdout.write("\n");
|
|
312
370
|
}
|
|
313
371
|
} catch (err) {
|
|
314
|
-
spinner.stop();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
372
|
+
spinner.stop(); // the thinking timer ends on an error or an abort too
|
|
373
|
+
if (chrome) renderer.turnEnd(); // progress line off, cursor at the start of a line
|
|
374
|
+
if (err instanceof AbortedError) {
|
|
375
|
+
console.error(`\n${dim(style, "interrupted")}`);
|
|
376
|
+
} else {
|
|
377
|
+
const msg = sanitize(err instanceof Error ? err.message : String(err));
|
|
378
|
+
const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
|
|
379
|
+
console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
keys?.endTurn(); // the keyboard is unlocked on every way out
|
|
383
|
+
progress.stop();
|
|
318
384
|
}
|
|
385
|
+
await fire("Stop", { tokens: st.total.prompt + st.total.completion });
|
|
319
386
|
refresh();
|
|
387
|
+
return stopped;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Welcome screen and the Synqa-first gate: chat and every other provider stay locked until a
|
|
391
|
+
// valid Synqa Cloud key is saved. Returns false when the person cancels.
|
|
392
|
+
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 });
|
|
393
|
+
const apiKeyFromEnv = () => {
|
|
394
|
+
const e = process.env.SYNQA_API_KEY?.trim();
|
|
395
|
+
return Boolean(e) && resolveApiKey(process.env, { envKey: args.envKey }) === e;
|
|
396
|
+
};
|
|
397
|
+
const signIn = async () => {
|
|
398
|
+
if (!secret) return false;
|
|
399
|
+
say("");
|
|
400
|
+
for (const line of renderBanner(bannerInfo(true), ui)) say(line);
|
|
401
|
+
say("");
|
|
402
|
+
const key = await unlockGate({ secret, print: (m) => say(dim(style, m)) });
|
|
403
|
+
if (!key) return false;
|
|
404
|
+
const p = startProvider(key, process.env, args.envKey);
|
|
405
|
+
st.apiKey = p.apiKey; st.providerId = p.providerId; st.baseUrl = p.baseUrl; st.modelId = p.modelId;
|
|
406
|
+
st.history = freshHistory(st);
|
|
407
|
+
say(dim(style, "Signed in to Synqa Cloud."));
|
|
408
|
+
return true;
|
|
320
409
|
};
|
|
321
410
|
|
|
322
411
|
try {
|
|
@@ -324,20 +413,31 @@ export async function main(argv , input = process.stdin)
|
|
|
324
413
|
const prompt = args.rest.join(" ");
|
|
325
414
|
if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
|
|
326
415
|
announceNotes(st, say);
|
|
327
|
-
for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
|
|
328
|
-
await
|
|
416
|
+
for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
|
|
417
|
+
await fire("SessionStart", { source: "one-shot" });
|
|
418
|
+
const stopped = await turn(prompt);
|
|
329
419
|
say(dim(style, formatStatus(statusParts(st))));
|
|
330
|
-
return 0;
|
|
420
|
+
return stopped ? 130 : 0;
|
|
331
421
|
}
|
|
332
422
|
|
|
423
|
+
if (needSignIn) {
|
|
424
|
+
if (!(await signIn())) { say(dim(style, "Not signed in. Run synqa again to sign in to Synqa Cloud.")); return 1; }
|
|
425
|
+
}
|
|
333
426
|
// 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);
|
|
427
|
+
if (!external()) balance = await loadBalance();
|
|
336
428
|
say("");
|
|
337
|
-
for (const line of renderBanner(
|
|
429
|
+
if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
|
|
338
430
|
say("");
|
|
339
431
|
if (!frame) say(dim(style, "Type /help for commands."));
|
|
340
432
|
announceNotes(st, (t) => say(dim(style, t)));
|
|
433
|
+
await fire("SessionStart", { source: "startup" });
|
|
434
|
+
// A quiet, short check for a newer release; any failure is silent and nothing waits for it.
|
|
435
|
+
if (chrome && !process.env.SYNQA_NO_UPDATE_CHECK) void checkForUpdate(VERSION).then((v) => { latestVersion = v; if (v) refresh(); }, () => {});
|
|
436
|
+
const io = {
|
|
437
|
+
print: say, confirm, style, ui, pick, secret, ask, envKey: args.envKey,
|
|
438
|
+
hooks: { list: () => loadHooks(cwd), toggle: (id ) => toggleHook(cwd, id) },
|
|
439
|
+
update: { current: VERSION, get latest() { return latestVersion; }, run: () => runUpdate() },
|
|
440
|
+
};
|
|
341
441
|
for (;;) {
|
|
342
442
|
// Without the frame the status is printed above each prompt instead.
|
|
343
443
|
if (!frame) say(dim(style, formatStatus(statusParts(st))));
|
|
@@ -346,9 +446,14 @@ export async function main(argv , input = process.stdin)
|
|
|
346
446
|
line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
|
|
347
447
|
} catch { break; }
|
|
348
448
|
if (!line) continue;
|
|
349
|
-
const r = await runCommand(line, st,
|
|
449
|
+
const r = await runCommand(line, st, io);
|
|
350
450
|
refresh();
|
|
351
451
|
if (r.exit) break;
|
|
452
|
+
if (r.relogin) {
|
|
453
|
+
// A full logout locks the session: back to the sign-in prompt, nothing else works until then.
|
|
454
|
+
if (!(await signIn())) { say(dim(style, "Locked. Run synqa again to sign in.")); break; }
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
352
457
|
if (!r.handled) await turn(line);
|
|
353
458
|
}
|
|
354
459
|
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"];
|
package/dist/paths.js
CHANGED
|
@@ -135,13 +135,17 @@ function hasRun(parts , run ) {
|
|
|
135
135
|
return false;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// One slash form for every comparison. On win32 both separators count and the drive letter and
|
|
139
|
+
// every name are folded, so a Windows AppData folder in any letter case matches. The platform is
|
|
140
|
+
// a parameter so the Windows rules are tested on any machine.
|
|
141
|
+
const slashed = (p , platform = process.platform) =>
|
|
142
|
+
fold(platform === "win32" ? p.replace(/\\/g, "/") : p.split(sep).join("/")).replace(/\/+$/, "");
|
|
139
143
|
|
|
140
144
|
// The home folder, as given and with symlinks resolved, folded.
|
|
141
|
-
function homeForms(home ) {
|
|
142
|
-
const out = [slashed(home)];
|
|
145
|
+
function homeForms(home , platform = process.platform) {
|
|
146
|
+
const out = [slashed(home, platform)];
|
|
143
147
|
try {
|
|
144
|
-
out.push(slashed(realpathSync(home)));
|
|
148
|
+
out.push(slashed(realpathSync(home), platform));
|
|
145
149
|
} catch {
|
|
146
150
|
// a home that does not exist is compared as given
|
|
147
151
|
}
|
|
@@ -152,25 +156,28 @@ function homeForms(home ) {
|
|
|
152
156
|
// ~/.cargo/**, any tool's credential store, known or not) and everything under
|
|
153
157
|
// ~/Library is protected. This is the structural rule: it does not depend on naming each store.
|
|
154
158
|
// A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
// On Windows the same rule covers AppData (browser profiles, tokens, credential stores) and the
|
|
160
|
+
// per-user registry hive NTUSER.DAT with its log and backup files.
|
|
161
|
+
export function homeHidden(abs , home = homedir(), platform = process.platform) {
|
|
162
|
+
const norm = slashed(abs, platform);
|
|
163
|
+
for (const h of homeForms(home, platform)) {
|
|
158
164
|
if (!norm.startsWith(h + "/")) continue;
|
|
159
165
|
const first = norm.slice(h.length + 1).split("/")[0] ?? "";
|
|
160
166
|
if (first.startsWith(".") || first === "library") return true;
|
|
167
|
+
if (platform === "win32" && (first === "appdata" || first.startsWith("ntuser.dat") || first === "ntuser.ini")) return true;
|
|
161
168
|
}
|
|
162
169
|
return false;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
// True when `cwd` is the home folder itself.
|
|
166
|
-
export function isHomeDir(cwd , home = homedir()) {
|
|
167
|
-
let real = slashed(cwd);
|
|
173
|
+
export function isHomeDir(cwd , home = homedir(), platform = process.platform) {
|
|
174
|
+
let real = slashed(cwd, platform);
|
|
168
175
|
try {
|
|
169
|
-
real = slashed(realpathSync(cwd));
|
|
176
|
+
real = slashed(realpathSync(cwd), platform);
|
|
170
177
|
} catch {
|
|
171
178
|
// compared as given
|
|
172
179
|
}
|
|
173
|
-
return homeForms(home).includes(real) || homeForms(home).includes(slashed(cwd));
|
|
180
|
+
return homeForms(home, platform).includes(real) || homeForms(home, platform).includes(slashed(cwd, platform));
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
// Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
|
|
@@ -178,13 +185,13 @@ export function isHomeDir(cwd , home = homedir()) {
|
|
|
178
185
|
// profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
|
|
179
186
|
// opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
|
|
180
187
|
// these outright; grep and glob skip them.
|
|
181
|
-
export function isSensitivePath(abs , cfg = configDir(), home = homedir()) {
|
|
182
|
-
if (homeHidden(abs, home)) return true;
|
|
183
|
-
const norm = slashed(abs);
|
|
188
|
+
export function isSensitivePath(abs , cfg = configDir(), home = homedir(), platform = process.platform) {
|
|
189
|
+
if (homeHidden(abs, home, platform)) return true;
|
|
190
|
+
const norm = slashed(abs, platform);
|
|
184
191
|
const parts = norm.split("/").filter(Boolean);
|
|
185
192
|
if (parts.some((d) => DIR_SET.has(d))) return true;
|
|
186
193
|
if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
|
|
187
|
-
const c = slashed(cfg);
|
|
194
|
+
const c = slashed(cfg, platform);
|
|
188
195
|
if (c && (norm === c || norm.startsWith(c + "/"))) return true;
|
|
189
196
|
const name = parts[parts.length - 1] ?? "";
|
|
190
197
|
// Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
|