@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/main.js CHANGED
@@ -1,18 +1,115 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { runAgent } from "./agent.js";
3
- import { checkKey } from "./api.js";
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
- import { unsafeStartDir } from "./paths.js";
8
+ import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
9
+
10
+
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";
20
+
21
+ // A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
22
+ // refused there, so say why once at start-up instead of leaving the user with refusals.
23
+ export function protectedWorkspaceWarning(cwd , home ) {
24
+ if (!isSensitivePath(join(cwd, "probe.txt"), undefined, home)) return undefined;
25
+ return "this folder is inside a protected area (a dot folder or Library in your home folder, or a credentials folder), where tools keep keys and tokens. The agent cannot read, write or search files here. Copy the project to a normal folder to work on it.";
26
+ }
27
+
28
+ // The start-up notes both the session banner and a one-shot run print.
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) {
31
+ const out = [];
32
+ if (envInUse && env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
33
+ const inProtected = protectedWorkspaceWarning(cwd, home);
34
+ if (inProtected) out.push(inProtected);
35
+ return out;
36
+ }
37
+
38
+
39
+
40
+ // Keys from a terminal always go through a PromptFrame, framed or not, so pasted text can never
41
+ // answer a y/N question; the frame (rules, status, / menu) is drawn only when `framed`. Questions
42
+ // and the bracketed paste switch go to the terminal: stdout when it is one, else stderr, so
43
+ // `synqa "task" | tee log` still asks on the screen and still tells pasted text from typing.
44
+ export function openSessionInput(o
45
+
46
+
47
+ ) {
48
+ const tty = Boolean(o.stdin.isTTY);
49
+ const term = o.stdout.isTTY || !o.stderr.isTTY ? o.stdout : o.stderr;
50
+ const keys = tty ? new PromptFrame(o.stdin, term, o.ui, o.below, o.promptWidth, { paste: Boolean(term.isTTY) }) : undefined;
51
+ const rl = createInterface({ input: keys ? keys.input : (o.stdin ), output: term, terminal: tty });
52
+ keys?.start(rl);
53
+ return { rl, keys, term };
54
+ }
55
+
56
+
57
+
58
+ // Raw mode and bracketed paste are switched off on every way out: a normal exit, an uncaught
59
+ // exception and an unhandled rejection (then the error is printed and the exit code is 1).
60
+ export function installTerminalRestore(
61
+ restore , proc = process,
62
+ exit = (c) => process.exit(c), report = (s) => console.error(s),
63
+ ) {
64
+ proc.on("exit", restore);
65
+ const crash = (err ) => {
66
+ restore();
67
+ report(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
68
+ exit(1);
69
+ };
70
+ proc.on("uncaughtException", crash);
71
+ proc.on("unhandledRejection", crash);
72
+ }
73
+
74
+ // The session's terminal wiring, in one place so it is tested as main uses it: restore stops the
75
+ // spinner and gives the keys back, on every way out. In a one-shot run on a terminal, a Ctrl+C the
76
+ // key router saw (it closes readline) ends the process with 130, the shell's code for an
77
+ // interrupt. Readline closing for any other reason (end of piped input, /dev/null) is not an
78
+ // interrupt: without a terminal there is no key router and no handler at all.
79
+ export function wireSession(o
80
+
81
+
82
+ ) {
83
+ const exit = o.exit ?? ((c ) => process.exit(c));
84
+ const report = o.report ?? ((s ) => console.error(s));
85
+ const restore = () => { o.spinner.stop(); o.keys?.stop(); };
86
+ installTerminalRestore(restore, o.proc ?? process, exit, report);
87
+ let finished = false;
88
+ const keys = o.keys;
89
+ if (o.oneShot && keys) {
90
+ o.rl.on("close", () => {
91
+ if (finished || !keys.interrupted) return;
92
+ restore();
93
+ report("\ninterrupted");
94
+ exit(130);
95
+ });
96
+ }
97
+ return { restore, done: () => { finished = true; } };
98
+ }
99
+
100
+ // --yes in the home folder would let the agent write shell startup files, launch agents and the
101
+ // like without a question, so it is refused there (the tools also ignore it, as a second guard).
102
+ export function homeYesRefusal(cwd , yes , home ) {
103
+ return yes && isHomeDir(cwd, home) ? "--yes is not allowed in your home folder. Run without --yes, or cd into a project folder." : undefined;
104
+ }
9
105
  import { sanitize } from "./sanitize.js";
10
- import { formatStatus, StatusBar } from "./statusbar.js";
11
- import { DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
106
+ import { formatStatus } from "./statusbar.js";
107
+ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
12
108
  import { accent, dim, stone } from "./theme.js";
13
109
 
14
110
  import { fetchAccount } from "./account.js";
15
- import { balanceFrom, formatFooter, makeUi, readVersion, renderBanner, Spinner, styleOf, TurnRenderer } from "./ui.js";
111
+ import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
112
+ import { PromptFrame } from "./prompt.js";
16
113
 
17
114
 
18
115
  const VERSION = readVersion();
@@ -23,50 +120,31 @@ const BALANCE_TIMEOUT_MS = 2000;
23
120
  const HELP = `Usage:
24
121
  synqa interactive session in the current folder
25
122
  synqa "do something" one-shot task
26
- synqa login save your API key
123
+ synqa login save your Synqa API key
27
124
  Options:
28
- -y, --yes do not ask before write, edit and bash
125
+ -y, --yes do not ask before ordinary file writes and edits
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
29
129
  -m, --model <tier> blink | sonata | aeon (default blink)
30
- -e, --effort <level> default | low | medium (default default)
31
- --allow-home allow starting in your home folder or the filesystem root
130
+ -e, --effort <level> default | low | medium | high (default default)
32
131
  -h, --help -v, --version
33
- In a session: /model, /effort, /status, /init, /compact, /undo, /clear, /help, /exit`;
34
-
35
- // Masked single-line input: nothing typed is echoed.
36
- export function readSecret(promptText ) {
37
- return new Promise((resolve, reject) => {
38
- const stdin = process.stdin;
39
- if (!stdin.isTTY) {
40
- reject(new Error("login needs an interactive terminal (or set SYNQA_API_KEY)"));
41
- return;
42
- }
43
- process.stdout.write(promptText);
44
- let value = "";
45
- stdin.setRawMode(true);
46
- stdin.resume();
47
- stdin.setEncoding("utf8");
48
- const onData = (chunk ) => {
49
- for (const ch of chunk) {
50
- if (ch === "\r" || ch === "\n") {
51
- stdin.setRawMode(false);
52
- stdin.pause();
53
- stdin.off("data", onData);
54
- process.stdout.write("\n");
55
- resolve(value.trim());
56
- return;
57
- }
58
- if (ch === "\u0003") {
59
- stdin.setRawMode(false);
60
- process.stdout.write("\n");
61
- reject(new Error("cancelled"));
62
- return;
63
- }
64
- if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
65
- else if (ch >= " ") value += ch;
66
- }
67
- };
68
- stdin.on("data", onData);
69
- });
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).`;
135
+
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.";
139
+
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;
70
148
  }
71
149
 
72
150
  // The error text can come from the network, so it is sanitized before printing.
@@ -91,14 +169,15 @@ export async function login() {
91
169
  return 0;
92
170
  }
93
171
 
94
-
172
+
95
173
 
96
174
  export function parseArgs(argv ) {
97
- 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: [] };
98
176
  for (let i = 0; i < argv.length; i++) {
99
177
  const t = argv[i] ;
100
178
  if (t === "-y" || t === "--yes") a.yes = true;
101
- else if (t === "--allow-home") a.allowHome = true;
179
+ else if (t === "--env-key") a.envKey = true;
180
+ else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
102
181
  else if (t === "-h" || t === "--help") a.help = true;
103
182
  else if (t === "-v" || t === "--version") a.version = true;
104
183
  else if (t === "-m" || t === "--model") a.model = argv[++i] ?? "";
@@ -108,19 +187,39 @@ export function parseArgs(argv ) {
108
187
  return a;
109
188
  }
110
189
 
111
- export async function main(argv ) {
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
+
204
+ // `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
205
+ export async function main(argv , input = process.stdin) {
112
206
  const args = parseArgs(argv);
113
207
  if (args.help) { console.log(HELP); return 0; }
114
208
  if (args.version) { console.log(VERSION); return 0; }
115
209
  if (args.rest[0] === "login") return login();
116
210
 
117
211
  const unsafe = unsafeStartDir(process.cwd());
118
- if (unsafe && !args.allowHome) {
119
- console.error(`Refusing to start in ${unsafe}: the agent could read your keys and settings there. cd into a project folder, or pass --allow-home if you really mean it.`);
212
+ if (unsafe) {
213
+ console.error(`Synqa does not start in ${unsafe} (the whole disk would be its workspace). cd into a project folder first.`);
120
214
  return 1;
121
215
  }
122
- const apiKey = resolveApiKey();
123
- if (!apiKey) {
216
+ const yesRefused = homeYesRefusal(process.cwd(), args.yes);
217
+ if (yesRefused) { console.error(yesRefused); return 1; }
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) {
124
223
  console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
125
224
  return 1;
126
225
  }
@@ -129,10 +228,15 @@ export async function main(argv ) {
129
228
  const tier = findTier(args.model || readConfig().tier || DEFAULT_TIER);
130
229
  if (!tier) { console.error(`Unknown tier "${args.model}". Use blink, sonata or aeon.`); return 1; }
131
230
  const effort = args.effort ? findEffort(args.effort) : "default";
132
- if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or medium.`); return 1; }
231
+ if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low, medium or high.`); return 1; }
133
232
 
134
- const interactive = Boolean(process.stdin.isTTY) && args.rest.length === 0;
135
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
233
+ // On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
234
+ const framed = interactive && ui.tty && ui.unicode;
235
+ let statusLines = () => [];
236
+ const { rl, keys, term } = openSessionInput({
237
+ stdin: input, stdout: process.stdout, stderr: process.stderr, ui, framed, below: () => statusLines(), promptWidth: textWidth(ui.unicode ? "❯ " : "> "),
238
+ });
239
+ const frame = framed ? keys : undefined;
136
240
  const say = (s ) => console.log(s);
137
241
  // Chrome (spinner, cards, footer) is for interactive terminals only. Pipes and one-shot runs get
138
242
  // the plain text output they had before.
@@ -143,44 +247,79 @@ export async function main(argv ) {
143
247
  // `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
144
248
  // (the tools already escaped every file character in it).
145
249
  const confirm = async (q , detail ) => {
146
- if (!process.stdin.isTTY) return false;
250
+ if (!input.isTTY) return false;
147
251
  spinner.stop();
148
- if (detail) say(inCard(detail));
252
+ if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
149
253
  const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
150
254
  return /^y(es)?$/i.test(ans.trim());
151
255
  };
152
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
+
153
265
  const cwd = process.cwd();
266
+ const sp = startProvider(synqaKey0, process.env, args.envKey);
154
267
  const st = {
155
- apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
268
+ apiKey: sp.apiKey, cwd, tier, effort, history: [], journal: new EditJournal(),
156
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",
157
271
  };
158
272
  st.history = freshHistory(st);
159
- const ctx = { cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)), get notesLoaded() { return st.notesChars > 0; } };
160
-
161
- // The pinned bar needs a terminal on both ends; otherwise the status is an ordinary line.
162
- const bar = new StatusBar(
163
- process.stdout,
164
- interactive && Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb",
165
- (s) => dim(style, s),
166
- );
167
- const refresh = () => {
168
- if (bar.enabled) bar.update(formatStatus(statusParts(st), (process.stdout.columns ?? 80) - 1));
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; },
169
277
  };
170
- const restore = () => { spinner.stop(); bar.uninstall(); };
171
- process.on("exit", restore);
172
- process.stdout.on("resize", () => { bar.resize(); refresh(); });
278
+ const external = () => (st.providerId ?? "synqa") !== "synqa";
279
+ const providerInfo = () => (external() ? { name: providerDef(st.providerId )?.name ?? "Custom endpoint", model: st.modelId } : undefined);
173
280
 
174
- // Known balance for the footer; refreshed in the background after each turn, never awaited.
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
+ };
293
+
294
+ // Known balance for the footer and status line; refreshed in the background after each turn.
175
295
  let balance = { kind: "unavailable" };
176
- const loadBalance = async () => balanceFrom(await fetchAccount(apiKey, undefined, BALANCE_TIMEOUT_MS));
296
+ let latestVersion ;
297
+ // The status line under the input: only known values (tier, workspace, context, tokens, credit).
298
+ statusLines = () => renderStatusLines({
299
+ tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
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,
302
+ }, ui);
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"); };
306
+ // Raw mode and bracketed paste are always switched off again, also after a crash.
307
+ const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
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
+ };
177
315
  const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
178
316
 
179
317
  const turn = async (text ) => {
180
318
  st.history.push({ role: "user", content: text });
181
319
  try {
182
320
  const r = await runAgent({
183
- 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,
184
323
  events: chrome
185
324
  ? {
186
325
  onRequest: () => renderer.requestStart(),
@@ -204,50 +343,95 @@ export async function main(argv ) {
204
343
  renderer.turnEnd();
205
344
  const footer = formatFooter({
206
345
  ttftMs: r.ttftMs, tokPerSec: r.tokPerSec, routing: st.routing,
207
- creditLeft: balance.kind === "ok" ? balance.account.creditRemaining : undefined,
346
+ creditLeft: balance.kind === "ok" && !external() ? balance.account.creditRemaining : undefined,
208
347
  }, ui);
209
348
  if (footer) say(stone(style, footer));
210
- refreshBalance();
349
+ if (!external()) refreshBalance();
211
350
  } else {
212
351
  process.stdout.write("\n");
213
352
  }
214
353
  } catch (err) {
215
- spinner.stop();
216
- console.error(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
354
+ spinner.stop(); // the thinking timer ends on an error or an abort too
355
+ const msg = sanitize(err instanceof Error ? err.message : String(err));
356
+ const envNote = err instanceof ApiError && err.status === 401 && !external() && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
357
+ console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
217
358
  }
359
+ await fire("Stop", { tokens: st.total.prompt + st.total.completion });
218
360
  refresh();
219
361
  };
220
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
+
221
384
  try {
222
385
  if (!interactive) {
223
386
  const prompt = args.rest.join(" ");
224
387
  if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
225
388
  announceNotes(st, say);
389
+ for (const w of startWarnings(process.env, cwd, undefined, apiKeyFromEnv())) console.error(`Warning: ${w}`);
390
+ await fire("SessionStart", { source: "one-shot" });
226
391
  await turn(prompt);
227
392
  say(dim(style, formatStatus(statusParts(st))));
228
393
  return 0;
229
394
  }
230
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
+ }
231
399
  // The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
232
- balance = await loadBalance();
233
- for (const line of renderBanner({ version: VERSION, tier: st.tier, cwd, notesChars: st.notesChars, balance }, ui)) say(line);
234
- say(dim(style, "Type /help for commands."));
235
- announceNotes(st, say);
236
- bar.install();
237
- refresh();
400
+ if (!external()) balance = await loadBalance();
401
+ say("");
402
+ if (!needSignIn) for (const line of renderBanner(bannerInfo(false), ui)) say(line);
403
+ say("");
404
+ if (!frame) say(dim(style, "Type /help for commands."));
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
+ };
238
414
  for (;;) {
239
- // Without a pinned bar the status is printed above each prompt instead.
240
- if (!bar.enabled) say(dim(style, formatStatus(statusParts(st))));
415
+ // Without the frame the status is printed above each prompt instead.
416
+ if (!frame) say(dim(style, formatStatus(statusParts(st))));
241
417
  let line ;
242
- try { line = (await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim(); } catch { break; }
418
+ try {
419
+ line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
420
+ } catch { break; }
243
421
  if (!line) continue;
244
- const r = await runCommand(line, st, { print: say, confirm, style });
422
+ const r = await runCommand(line, st, io);
245
423
  refresh();
246
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
+ }
247
430
  if (!r.handled) await turn(line);
248
431
  }
249
432
  return 0;
250
433
  } finally {
434
+ done(); // a normal end: closing readline below is not an interrupt
251
435
  restore();
252
436
  rl.close();
253
437
  }
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"];