@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 CHANGED
@@ -59,7 +59,61 @@ From a checkout of this repo (for development): `npm link ./cli`.
59
59
  2. Paste your `synqa_live_...` key (typing is hidden). It is checked against the Synqa API and
60
60
  saved to `~/.config/synqa/config.json` (file mode 0600).
61
61
 
62
- Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the saved key.
62
+ Or set `SYNQA_API_KEY` in your shell. A key saved by `synqa login` or `/login` wins over the variable,
63
+ so you never need to unset it; pass `--env-key` to make the variable win for one run.
64
+
65
+ Starting `synqa` in a terminal with no valid Synqa key shows the welcome screen and asks for the key
66
+ first (typing shows `••••`, the key is checked at `https://api.synqa.ca/v1/account`). Chat and every
67
+ other provider stay locked until you are signed in to Synqa Cloud.
68
+
69
+ ## Providers
70
+
71
+ After you sign in, `/provider` (or `/login`) opens an arrow-key menu: Synqa Cloud (primary),
72
+ OpenCode Go, OpenCode Zen, OpenRouter (bring your own `sk-or-...` key) and a custom
73
+ OpenAI-compatible endpoint. Keys for the others are typed masked, checked with `GET /v1/models` and
74
+ saved in the same private config file (mode 0600 on macOS and Linux; Windows ignores the mode). The
75
+ active provider and model show in the header and the footer. `/logout` offers to leave the secondary
76
+ provider or to log out fully, which clears the Synqa key (and `SYNQA_API_KEY` in this process) and
77
+ returns to the sign-in prompt. Use `OPENCODE_API_KEY` or the router's key variable as a fallback when
78
+ nothing is saved.
79
+
80
+ ## Keys and modes
81
+
82
+ - `/` opens the command list under the prompt: Up/Down move, Tab fills, Enter runs, Esc closes.
83
+ - `/model`, `/effort`, `/provider`, `/login`, `/logout` and `/hooks` with no argument open an inline
84
+ Up/Down list (Enter selects, Esc cancels).
85
+ - Shift+Tab cycles the permission mode: `ask before edits`, `auto-accept edits` (file edits in the
86
+ workspace apply without a question, like `--yes`; shell commands still ask) and `plan only` (no
87
+ file is written and no command is run). The mode shows in the footer.
88
+
89
+ ## Hooks
90
+
91
+ `.synqa/hooks.json` in a project and `hooks.json` in the user config folder can run shell commands at
92
+ `SessionStart`, `PreToolUse`, `PostToolUse` and `Stop`:
93
+
94
+ ```json
95
+ { "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ] } }
96
+ ```
97
+
98
+ The hook gets the event as JSON on stdin. A `PreToolUse` hook blocks the tool by exiting with code 2
99
+ or printing `{"decision":"block","reason":"..."}`; other output is ignored. A command that starts
100
+ with `python` runs with `python3` (or `py` on Windows) when `python` is not on PATH. Project hooks
101
+ are repository content, so they start off: `/hooks` lists them and switches them on or off.
102
+
103
+ ### Hooks security
104
+
105
+ - Project hooks start disabled. An approval is tied to the project path and the exact entry
106
+ (command, matcher and timeout): a different folder or any edit to the entry needs a new approval.
107
+ - The hook payload includes the tool input and result (up to 4000 characters each), so a hook sees
108
+ file contents and command output.
109
+ - Never point a hook at a file the agent can write: the agent could rewrite the script that runs next.
110
+ - In plan only mode the `PostToolUse` and `Stop` hooks do not run.
111
+
112
+ ## Updates
113
+
114
+ On start `synqa` asks the npm registry once, with a 1.5 second limit and no error message, whether a
115
+ newer `@synqa/cli` exists, and shows a quiet hint in the footer. `/update` runs
116
+ `npm install -g @synqa/cli@latest`. Set `SYNQA_NO_UPDATE_CHECK=1` to skip the check.
63
117
 
64
118
  ## Use
65
119
 
@@ -72,14 +126,18 @@ Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the
72
126
  shell command while an `AGENTS.md` is loaded, and shell commands that name a secrets file or a glob that could.
73
127
  Diffs are still shown.
74
128
  - `synqa --model sonata` : start on another tier (`blink`, `sonata`, `aeon`).
75
- - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`).
129
+ - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`, `high`).
76
130
 
77
131
  ## Slash commands
78
132
 
79
133
  | Command | What it does |
80
134
  |---|---|
81
- | `/model blink\|sonata\|aeon` | Switch tier. With no argument it shows the current one. |
82
- | `/effort default\|low\|medium` | Set the reasoning effort. `default` sends no effort setting. |
135
+ | `/model blink\|sonata\|aeon` | Choose the model. With no argument an arrow-key list opens: Blink 5.5, Sonata 6.0, Aeon 6.5 on Synqa Cloud, the provider's own model list otherwise. |
136
+ | `/effort default\|low\|medium\|high` | Set the reasoning effort (arrow-key list with no argument). `default` sends no effort setting. |
137
+ | `/provider`, `/login` | Pick Synqa Cloud, OpenCode Go, OpenCode Zen, OpenRouter or a custom endpoint. |
138
+ | `/logout` | Leave the secondary provider, or log out fully and lock the session. |
139
+ | `/hooks` | List hooks and switch them on or off. |
140
+ | `/update` | Install the latest `@synqa/cli`. |
83
141
  | `/status` | Tier, routing, session tokens, context use of the last request, and your live credit and weekly pool balance. |
84
142
  | `/init` | Scan the project and write a starter `AGENTS.md`. |
85
143
  | `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
@@ -107,7 +165,7 @@ The balance lookup waits at most 2 seconds; if it fails the note says `balance u
107
165
  ▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
108
166
  ▄▄▄▀ █
109
167
 
110
- v1.1.1 · Blink 5.5 · default effort
168
+ v1.2.0 · Blink 5.5 · default effort
111
169
  ~/synqa-test
112
170
  ⎿ no AGENTS.md yet: /init writes one
113
171
  ⎿ $9.98 credit · weekly pool $4.00 of $10.00
@@ -221,7 +279,7 @@ it. Use git for that.
221
279
 
222
280
  | Name | Meaning |
223
281
  |---|---|
224
- | `SYNQA_API_KEY` | Your key; wins over the saved one (the start screen says so, and `synqa login` warns when it differs from the key you just saved). Never passed to commands the agent runs. |
282
+ | `SYNQA_API_KEY` | Your key. A key saved by `synqa login` or `/login` wins over it unless you pass `--env-key`. Never passed to commands the agent runs. |
225
283
  | `NO_COLOR` | Any non-empty value turns colour off. |
226
284
  | `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
227
285
 
@@ -259,8 +317,8 @@ it. Use git for that.
259
317
 
260
318
  - `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
261
319
  - `Your API key was rejected`: run `synqa login` again, or check on the dashboard that the key is
262
- active. If `SYNQA_API_KEY` is set in your shell, that key is used instead of the saved one: remove it
263
- (`unset SYNQA_API_KEY`, and the line in `~/.zshrc`).
320
+ active. A key saved with `/login` is used before `SYNQA_API_KEY`; `/logout` then `Full logout` clears
321
+ both in the running session.
264
322
  - `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
265
323
  chat access.
266
324
  - `request timed out`: a request is cut off after 10 minutes.
package/bin/synqa.js CHANGED
File without changes
package/dist/agent.js CHANGED
@@ -13,7 +13,7 @@ export const MAX_NOTES_CHARS = 8000;
13
13
  // from a third party (a cloned repo), so it goes in a delimited block as data: conventions only,
14
14
  // never an instruction source. fenceText removes invisible characters and neutralizes every "<", so
15
15
  // nothing inside can open or close the block.
16
- export function systemPrompt(tier , cwd , notes ) {
16
+ export function systemPrompt(tier , cwd , notes , external = false) {
17
17
  const body = fenceText(notes ?? "", MAX_NOTES_CHARS);
18
18
  const extra = body
19
19
  ? [
@@ -26,8 +26,10 @@ export function systemPrompt(tier , cwd , notes ) {
26
26
  ]
27
27
  : [];
28
28
  return [
29
- `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
30
- "If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside.",
29
+ external
30
+ ? "You are the Synqa terminal coding agent, running on a model provider the user chose. If asked who created this tool, say Synqa."
31
+ : `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
32
+ ...(external ? [] : ["If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside."]),
31
33
  `Working directory: ${cwd}. File tools cannot leave it.`,
32
34
  "Use the tools to read, search and change files and to run commands. Make small, correct changes and say briefly what you did.",
33
35
  ...extra,
@@ -52,6 +54,15 @@ export function systemPrompt(tier , cwd , notes ) {
52
54
 
53
55
 
54
56
 
57
+
58
+
59
+
60
+
61
+
62
+ // PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
63
+
64
+
65
+
55
66
 
56
67
 
57
68
 
@@ -78,7 +89,8 @@ export async function runAgent(o ) {
78
89
  o.events.onRequest?.();
79
90
  const r = await chat({
80
91
  apiKey: o.apiKey,
81
- model: o.tier.id,
92
+ model: o.modelId ?? o.tier.id,
93
+ baseUrl: o.baseUrl,
82
94
  messages: o.history,
83
95
  tools: TOOL_DEFS,
84
96
  fetchFn: o.fetchFn,
@@ -109,7 +121,9 @@ export async function runAgent(o ) {
109
121
  o.events.onTool(c.name, c.arguments);
110
122
  let result ;
111
123
  try {
112
- result = await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
124
+ const blocked = await o.hooks?.pre(c.name, c.arguments);
125
+ result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
126
+ if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
113
127
  } catch (err) {
114
128
  result = `error: ${err instanceof Error ? err.message : String(err)}`;
115
129
  }
package/dist/api.js CHANGED
@@ -141,6 +141,7 @@ export async function checkKey(apiKey , fetchFn = fetch)
141
141
 
142
142
 
143
143
 
144
+
144
145
 
145
146
 
146
147
 
@@ -154,7 +155,7 @@ export async function chat(o ) {
154
155
  const ac = new AbortController();
155
156
  const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
156
157
  try {
157
- const res = await fetchFn(`${API_BASE}/chat/completions`, {
158
+ const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
158
159
  method: "POST",
159
160
  headers: { authorization: `Bearer ${o.apiKey}`, "content-type": "application/json" },
160
161
  body: JSON.stringify({
package/dist/commands.js CHANGED
@@ -13,6 +13,15 @@ import { sanitize } from "./sanitize.js";
13
13
  import { groupDigits } from "./statusbar.js";
14
14
 
15
15
  import { CONTEXT_TOKENS, EFFORTS, findEffort, findTier, TIERS } from "./tiers.js";
16
+ import { MODE_LABEL } from "./modes.js";
17
+
18
+ import { describeHook } from "./hooks.js";
19
+
20
+ import { fetchModelIds, logout, modelItems, providerDef, providerMenu } from "./providers.js";
21
+
22
+ import { API_BASE, saveProvider } from "./config.js";
23
+ import { sanitizeModelId } from "./sanitize.js";
24
+
16
25
 
17
26
 
18
27
  import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
@@ -32,6 +41,10 @@ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
32
41
 
33
42
 
34
43
 
44
+
45
+
46
+
47
+
35
48
 
36
49
 
37
50
 
@@ -41,12 +54,23 @@ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
41
54
 
42
55
 
43
56
 
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
44
65
 
45
66
 
46
-
67
+ // relogin: a full logout locked the session; the caller shows the sign-in prompt again.
68
+
47
69
 
48
70
  export { SLASH_HELP };
49
71
 
72
+ export const isExternal = (st ) => (st.providerId ?? "synqa") !== "synqa";
73
+
50
74
  export function statusParts(st ) {
51
75
  return {
52
76
  tier: st.tier,
@@ -62,7 +86,7 @@ export function statusParts(st ) {
62
86
  export function systemMessage(st , tier = st.tier) {
63
87
  const notes = loadNotes(st.cwd);
64
88
  st.notesChars = notes.length;
65
- return { role: "system", content: systemPrompt(tier, st.cwd, notes) };
89
+ return { role: "system", content: systemPrompt(tier, st.cwd, notes, isExternal(st)) };
66
90
  }
67
91
 
68
92
  export function freshHistory(st ) {
@@ -81,12 +105,15 @@ const errText = (err ) => sanitize(err instanceof Error ? err.message :
81
105
  async function cmdStatus(st , io ) {
82
106
  const u = st.total;
83
107
  io.print([
84
- `Tier ${st.tier.display} (${st.tier.id})`,
108
+ isExternal(st) ? `Provider ${providerDef(st.providerId )?.name ?? "custom"}, model ${sanitize(st.modelId ?? "?")}` : `Tier ${st.tier.display} (${st.tier.id})`,
109
+ `Mode ${MODE_LABEL[st.mode ?? "ask"]}`,
85
110
  `Routing ${st.routing}`,
86
111
  `Effort ${st.effort}`,
87
112
  `Session ${groupDigits(u.prompt + u.completion)} tokens (${groupDigits(u.prompt)} in, ${groupDigits(u.completion)} out)`,
88
113
  st.last.prompt > 0 ? `Context ${groupDigits(st.last.prompt)} of ${groupDigits(CONTEXT_TOKENS)} used by the last request` : "Context no request yet",
89
114
  ].join("\n"));
115
+ // The balance belongs to Synqa Cloud: a secondary provider's key is never sent there.
116
+ if (isExternal(st)) return;
90
117
  const r = await fetchAccount(st.apiKey, io.fetchFn);
91
118
  io.print(r.ok ? formatAccount(r.account).join("\n") : `Balance unavailable: ${sanitize(r.message)}`);
92
119
  }
@@ -113,7 +140,7 @@ async function cmdCompact(st , io ) {
113
140
  if (st.history.length <= 1) { io.print("Nothing to compact yet."); return; }
114
141
  io.print("Compacting the conversation...");
115
142
  try {
116
- const r = await compactHistory({ apiKey: st.apiKey, tier: st.tier, history: st.history, effort: st.effort, fetchFn: io.fetchFn, now: io.now });
143
+ const r = await compactHistory({ apiKey: st.apiKey, tier: st.tier, baseUrl: st.baseUrl, modelId: st.modelId, history: st.history, effort: st.effort, fetchFn: io.fetchFn, now: io.now });
117
144
  st.history = r.history;
118
145
  if (r.usage) {
119
146
  st.total = { prompt: st.total.prompt + r.usage.prompt, completion: st.total.completion + r.usage.completion };
@@ -124,6 +151,93 @@ async function cmdCompact(st , io ) {
124
151
  }
125
152
  }
126
153
 
154
+ // The provider helpers need the arrow-key controls; without a terminal they are unavailable.
155
+ function providerIO(st , io ) {
156
+ const { pick, secret, ask } = io;
157
+ if (!pick || !secret || !ask) return undefined;
158
+ return { print: io.print, pick, secret, ask, fetchFn: io.fetchFn, env: io.env, envKey: io.envKey };
159
+ }
160
+
161
+ const session = (st ) => {
162
+ st.providerId ??= "synqa";
163
+ st.baseUrl ??= API_BASE;
164
+ return st ;
165
+ };
166
+
167
+ async function cmdModel(st , io , arg ) {
168
+ if (!isExternal(st)) {
169
+ const t = arg ? findTier(arg) : undefined;
170
+ if (arg && !t) { io.print(`Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); return; }
171
+ let chosen = t;
172
+ if (!arg) {
173
+ if (!io.pick) { io.print(`Tier is ${st.tier.display}. Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); return; }
174
+ const key = await io.pick(modelItems("synqa"), "Choose a model");
175
+ if (key === undefined) { io.print("Cancelled."); return; }
176
+ chosen = findTier(key);
177
+ }
178
+ if (!chosen) return;
179
+ st.tier = chosen;
180
+ st.history[0] = systemMessage(st, chosen);
181
+ io.print(`Switched to ${chosen.display}.`);
182
+ return;
183
+ }
184
+ const id = st.providerId ;
185
+ let model = arg ? sanitizeModelId(arg) : undefined;
186
+ if (!arg) {
187
+ if (!io.pick) { io.print(`Model is ${sanitize(st.modelId ?? "?")}. Pass a model id: /model <id>`); return; }
188
+ let fetched ;
189
+ try {
190
+ fetched = await fetchModelIds(st.baseUrl , st.apiKey, io.fetchFn);
191
+ } catch {
192
+ fetched = undefined; // the curated defaults are offered instead
193
+ }
194
+ const items = modelItems(id, fetched);
195
+ if (items.length === 0) { io.print("No models to choose from. Pass a model id: /model <id>"); return; }
196
+ const initial = Math.max(0, items.findIndex((i) => i.value === st.modelId));
197
+ model = await io.pick(items.map((i, n) => ({ ...i, hint: n === initial ? "(current)" : undefined })), "Choose a model");
198
+ if (model === undefined) { io.print("Cancelled."); return; }
199
+ }
200
+ if (!model) { io.print("That is not a usable model id."); return; }
201
+ st.modelId = model;
202
+ try { saveProvider(id, { model }, io.env); } catch { /* the choice still applies to this session */ }
203
+ io.print(`Model set to ${model}.`);
204
+ }
205
+
206
+ async function cmdEffort(st , io , arg ) {
207
+ if (!arg) {
208
+ if (!io.pick) { io.print(`Effort is ${st.effort}. Choose one of: ${EFFORTS.join(", ")}`); return; }
209
+ const e = await io.pick(EFFORTS.map((x) => ({ label: x, value: x, hint: x === st.effort ? "(current)" : undefined })), "Reasoning effort");
210
+ if (e === undefined) { io.print("Cancelled."); return; }
211
+ st.effort = e;
212
+ io.print(`Reasoning effort set to ${e}.`);
213
+ return;
214
+ }
215
+ const e = findEffort(arg);
216
+ if (!e) { io.print(`Choose one of: ${EFFORTS.join(", ")}`); return; }
217
+ st.effort = e;
218
+ io.print(`Reasoning effort set to ${e}.`);
219
+ }
220
+
221
+ async function cmdHooks(io ) {
222
+ const hooks = io.hooks?.list() ?? [];
223
+ if (hooks.length === 0) {
224
+ io.print("No hooks. Add .synqa/hooks.json in the project (off until you switch it on here) or hooks.json in your Synqa config folder. Events: SessionStart, PreToolUse, PostToolUse, Stop.");
225
+ return;
226
+ }
227
+ if (!io.pick) { io.print(hooks.map(describeHook).join("\n")); return; }
228
+ const id = await io.pick(hooks.map((h) => ({ label: describeHook(h), value: h.id })), "Hooks: Enter toggles one");
229
+ if (id === undefined) return;
230
+ const now = io.hooks?.toggle(id);
231
+ io.print(now === undefined ? "No such hook." : `Hook is now ${now ? "on" : "off"}.`);
232
+ }
233
+
234
+ async function cmdUpdate(io ) {
235
+ if (!io.update) { io.print("Update from a terminal: npm install -g @synqa/cli@latest"); return; }
236
+ io.print(io.update.latest ? `Updating ${io.update.current} to ${sanitize(io.update.latest)} ...` : "Running npm install -g @synqa/cli@latest ...");
237
+ const code = await io.update.run();
238
+ io.print(code === 0 ? "Updated. Restart synqa to use the new version." : `The update failed (exit ${code}). Run it yourself: npm install -g @synqa/cli@latest`);
239
+ }
240
+
127
241
  export async function runCommand(line , st , io ) {
128
242
  const [first = "", ...args] = line.trim().split(/\s+/);
129
243
  // A first word like /usr/bin/foo is a path in a question, not a command.
@@ -141,23 +255,36 @@ export async function runCommand(line , st , io )
141
255
  io.print("Conversation cleared.");
142
256
  announceNotes(st, io.print);
143
257
  break;
144
- case "/model": {
145
- if (!arg) { io.print(`Tier is ${st.tier.display}. Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
146
- const t = findTier(arg);
147
- if (!t) { io.print(`Choose one of: ${TIERS.map((x) => x.key).join(", ")}`); break; }
148
- st.tier = t;
149
- st.history[0] = systemMessage(st, t);
150
- io.print(`Switched to ${t.display}.`);
258
+ case "/model":
259
+ await cmdModel(st, io, arg);
260
+ break;
261
+ case "/effort":
262
+ await cmdEffort(st, io, arg);
263
+ break;
264
+ case "/provider":
265
+ case "/login": {
266
+ const pio = providerIO(st, io);
267
+ if (!pio) { io.print("This needs an interactive terminal."); break; }
268
+ if (await providerMenu(session(st), pio, arg)) {
269
+ st.history[0] = systemMessage(st);
270
+ io.print(isExternal(st) ? `Active: ${providerDef(st.providerId )?.name} ${st.modelId ?? ""}`.trim() : `Active: Synqa Cloud ${st.tier.display}`);
271
+ }
151
272
  break;
152
273
  }
153
- case "/effort": {
154
- if (!arg) { io.print(`Effort is ${st.effort}. Choose one of: ${EFFORTS.join(", ")}`); break; }
155
- const e = findEffort(arg);
156
- if (!e) { io.print(`Choose one of: ${EFFORTS.join(", ")}`); break; }
157
- st.effort = e;
158
- io.print(`Reasoning effort set to ${e}.`);
274
+ case "/logout": {
275
+ const pio = providerIO(st, io);
276
+ if (!pio) { io.print("This needs an interactive terminal."); break; }
277
+ const out = await logout(session(st), pio);
278
+ if (out) st.history[0] = systemMessage(st);
279
+ if (out === "locked") return { handled: true, relogin: true };
159
280
  break;
160
281
  }
282
+ case "/hooks":
283
+ await cmdHooks(io);
284
+ break;
285
+ case "/update":
286
+ await cmdUpdate(io);
287
+ break;
161
288
  case "/status":
162
289
  await cmdStatus(st, io);
163
290
  break;
package/dist/compact.js CHANGED
@@ -51,6 +51,8 @@ export const approxTokens = (history ) => Math.ceil(JSON.strin
51
51
 
52
52
 
53
53
 
54
+
55
+
54
56
 
55
57
 
56
58
 
@@ -64,7 +66,8 @@ export async function compactHistory(o )
64
66
  if (!text.trim()) throw new Error("nothing to compact");
65
67
  const r = await chat({
66
68
  apiKey: o.apiKey,
67
- model: o.tier.id,
69
+ model: o.modelId ?? o.tier.id,
70
+ baseUrl: o.baseUrl,
68
71
  messages: [
69
72
  { role: "system", content: COMPACT_PROMPT },
70
73
  { role: "user", content: text },
package/dist/config.js CHANGED
@@ -7,7 +7,17 @@ export const API_BASE = "https://api.synqa.ca/v1";
7
7
  export const ACCOUNT_URL = `${API_BASE}/account`;
8
8
  export const DASHBOARD_URL = "https://synqa.ca/client-dashboard";
9
9
 
10
-
10
+ // A secondary provider the user chose themselves (OpenCode, a bring-your-own router key, a custom
11
+ // endpoint). Keys live in the same private file as the Synqa key and never leave this machine
12
+ // except to that provider's own base URL.
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
11
21
 
12
22
  export function configDir(env = process.env) {
13
23
  // SYNQA_CONFIG_DIR is honoured only under the node test runner (NODE_TEST_CONTEXT is set);
@@ -29,11 +39,21 @@ export function readConfig(env = process.env
29
39
  }
30
40
  }
31
41
 
32
- // Directory 0700, file 0600, enforced even when they already existed with looser modes.
42
+ // chmod does nothing useful on Windows file systems and may fail there: the mode is best effort
43
+ // on win32 and enforced everywhere else.
44
+ function tryChmod(path , mode , platform = process.platform) {
45
+ try {
46
+ chmodSync(path, mode);
47
+ } catch (err) {
48
+ if (platform !== "win32") throw err;
49
+ }
50
+ }
51
+
52
+ // Directory 0700, file 0600, enforced even when they already existed with looser modes (POSIX).
33
53
  export function writeConfig(config , env = process.env) {
34
54
  const dir = configDir(env);
35
55
  mkdirSync(dir, { recursive: true, mode: 0o700 });
36
- chmodSync(dir, 0o700);
56
+ tryChmod(dir, 0o700);
37
57
  const file = configPath(env);
38
58
  // The key is never written into a pre-existing file: create a fresh 0600 temp file ("wx"
39
59
  // fails if it exists) in the 0700 directory, then rename it over the target.
@@ -46,7 +66,7 @@ export function writeConfig(config , env
46
66
  } finally {
47
67
  closeSync(fd);
48
68
  }
49
- chmodSync(tmp, 0o600);
69
+ tryChmod(tmp, 0o600);
50
70
  renameSync(tmp, file);
51
71
  renamed = true;
52
72
  } finally {
@@ -56,11 +76,34 @@ export function writeConfig(config , env
56
76
  return file;
57
77
  }
58
78
 
59
- // The env var wins over the saved key.
60
- export function resolveApiKey(env = process.env) {
61
- const fromEnv = env.SYNQA_API_KEY?.trim();
62
- if (fromEnv) return fromEnv;
63
- return readConfig(env).apiKey;
79
+ // Since 1.2.0 a key saved by /login or synqa login wins over SYNQA_API_KEY, so nobody has to
80
+ // unset a shell variable to use the key they just saved. The environment is the fallback when
81
+ // nothing is saved, and wins only when the caller passes the explicit --env-key flag.
82
+ export function resolveApiKey(env = process.env, opts = {}) {
83
+ const fromEnv = env.SYNQA_API_KEY?.trim() || undefined;
84
+ const saved = readConfig(env).apiKey?.trim() || undefined;
85
+ return opts.envKey ? fromEnv ?? saved : saved ?? fromEnv;
86
+ }
87
+
88
+ // The environment variable a secondary provider's key may come from.
89
+ export const PROVIDER_ENV = {
90
+ "opencode-go": "OPENCODE_API_KEY",
91
+ "opencode-zen": "OPENCODE_API_KEY",
92
+ openrouter: "OPENROUTER_API_KEY",
93
+ };
94
+
95
+ // The same priority for a secondary provider.
96
+ export function resolveProviderKey(id , env = process.env, opts = {}) {
97
+ const name = PROVIDER_ENV[id];
98
+ const fromEnv = name ? env[name]?.trim() || undefined : undefined;
99
+ const saved = readConfig(env).providers?.[id]?.apiKey?.trim() || undefined;
100
+ return opts.envKey ? fromEnv ?? saved : saved ?? fromEnv;
101
+ }
102
+
103
+ // Merge-writes one secondary provider into the file.
104
+ export function saveProvider(id , entry , env = process.env) {
105
+ const cfg = readConfig(env);
106
+ return writeConfig({ ...cfg, providers: { ...cfg.providers, [id]: { ...cfg.providers?.[id], ...entry } } }, env);
64
107
  }
65
108
 
66
109
  export function looksLikeKey(key ) {