@synqa/cli 1.1.0 → 1.1.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 CHANGED
@@ -12,7 +12,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
12
12
  - [Slash commands](#slash-commands)
13
13
  - [Welcome panel and turn footer](#welcome-panel-and-turn-footer)
14
14
  - [Diff preview](#diff-preview)
15
- - [Status bar](#status-bar)
15
+ - [Status line](#status-line)
16
16
  - [AGENTS.md and /init](#agentsmd-and-init)
17
17
  - [Undo](#undo)
18
18
  - [Settings and environment](#settings-and-environment)
@@ -22,7 +22,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
22
22
 
23
23
  ## Install
24
24
 
25
- Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies. **Not published to npm yet:** `npx` and the installer work only after the first release.
25
+ Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies.
26
26
 
27
27
  Try it without installing:
28
28
 
@@ -65,7 +65,7 @@ Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the
65
65
 
66
66
  - `synqa` : interactive session in the current folder.
67
67
  - `synqa "fix the failing test"` : one task, then exit.
68
- - `synqa --yes "..."` : run unattended. **`--yes` runs shell commands without asking, so use it only in repositories you trust**
68
+ - `synqa --yes "..."` : approve ordinary file writes and edits inside the folder without asking. **Shell commands always ask, even with `--yes`** (any file the agent reads could carry an injected command), and `--yes` is not allowed in your home folder
69
69
  (text in a README or a web page can steer the model). Some things still ask, and are denied when nobody can answer:
70
70
  writes that can run code later (git hooks and other `.git` files, `package.json`, Makefiles, `.envrc`, `.vscode`
71
71
  settings, CI workflows, shell startup files, dotfiles, executables), writes to secrets files, every write and
@@ -85,46 +85,62 @@ Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the
85
85
  | `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
86
86
  | `/undo` | Revert the last file the agent wrote. Run it again to go further back. |
87
87
  | `/clear` | Start a fresh conversation (reloads `AGENTS.md`). |
88
- | `/help` | List the commands. |
88
+ | `/help` | List the commands, grouped, with the keys of the command menu. |
89
89
  | `/exit` | Leave (also `/quit`, or Ctrl-D). |
90
90
 
91
91
  `/status` reads your balance from the Synqa account service with your key. If the service does not
92
92
  answer, it says so and shows everything else; your balance is always on the
93
93
  [dashboard](https://synqa.ca/client-dashboard).
94
94
 
95
- ## Welcome panel and turn footer
95
+ ## Start screen, prompt and turn footer
96
96
 
97
- In an interactive terminal the session opens with a welcome panel: the CLI version, the tier id and
98
- name, the workspace folder (your home folder shown as `~`), whether `AGENTS.md` project notes were
99
- loaded, and your credit and weekly pool balance from the Synqa account service. The balance lookup
100
- waits at most 2 seconds; if it fails the panel says `balance unavailable`, and `balance not available yet`
101
- when the service does not offer it to API keys yet. Your key is never shown.
97
+ In an interactive terminal the session opens with the Synqa wordmark (on a window of 60 columns or
98
+ more; a one-line bold `Synqa.` below that), then dim lines with the CLI version, tier and effort, and
99
+ the workspace folder (your home folder shown as `~`), then short `⎿` notes: whether `AGENTS.md`
100
+ project notes were loaded, and your credit and weekly pool balance from the Synqa account service.
101
+ The balance lookup waits at most 2 seconds; if it fails the note says `balance unavailable`, and
102
+ `balance not available yet` when the service does not offer it to API keys yet. Your key is never shown.
102
103
 
103
104
  ```
104
- ╭─ Synqa. CLI v1.1.0 ───────────────────────────────────────────╮
105
- │ ╭────╮ Tier synqa-sonata-6.0 (Sonata 6.0) │
106
- │ │ S. │ Workspace ~/proj │
107
- │ ╰────╯ Notes AGENTS.md loaded (1,234 chars) │
108
- │ Balance $9.98 credit · weekly pool $4.00 of $10.00 │
109
- ╰───────────────────────────────────────────────────────────────╯
105
+ ▄▀▀▀
106
+ ▀▀▄ █ █ █▀▀▄ ▄▀▀█ ▄▀▀█
107
+ ▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
108
+ ▄▄▄▀ █
109
+
110
+ v1.1.1 · Blink 5.5 · default effort
111
+ ~/synqa-test
112
+ ⎿ no AGENTS.md yet: /init writes one
113
+ ⎿ $9.98 credit · weekly pool $4.00 of $10.00
114
+ ───────────────────────────────────────────────────────────────────────────────
115
+ ❯ Try "explain this project", or type / for commands
116
+ ───────────────────────────────────────────────────────────────────────────────
117
+ Blink 5.5 · synqa-test · 0 tokens $9.98 credit
118
+ / commands · ↑↓ history · tab complete · ctrl+c quit
110
119
  ```
111
120
 
112
- On a terminal that is not UTF-8 (or `TERM=dumb`, or a very old Windows console) the panel is plain ASCII:
113
-
114
- ```
115
- [S.] Synqa. CLI v1.1.0
116
- Tier synqa-sonata-6.0 (Sonata 6.0)
117
- Workspace ~/proj
118
- Notes AGENTS.md loaded (1,234 chars)
119
- Balance $9.98 credit | weekly pool $4.00 of $10.00
120
- ```
121
-
122
- Colours: terracotta for the period, spinner, `❯` prompt and monogram; warm bone for the name and the
123
- tier; warm stone for borders, labels and the footer; sage for the balance and added diff lines
124
- (removed lines stay red). 24-bit colour is used when `COLORTERM` is `truecolor` or `24bit`, otherwise
125
- the nearest of the 256 terminal colours. Stone is a mid grey: row labels are bold to stay readable,
126
- but on a light terminal background borders and the footer are faint.
127
-
121
+ The letters are drawn in your terminal's own text colour, so they read as ink on a light theme and
122
+ light on a dark one; the full stop is terracotta. The input sits between two thin rules, with a dim
123
+ hint while it is empty. Under it the status line shows only what is known (see below) and a line of
124
+ key hints.
125
+
126
+ Type `/` and a menu of commands opens under the input, filtered as you type, with a short
127
+ description of each. Up and Down move the highlight, Tab or Enter completes it (Enter runs a command
128
+ that takes no argument), Esc closes the menu. An unknown command gets a one-line hint with the
129
+ nearest name. The menu is drawn with Node's own readline key events, no extra package.
130
+
131
+ On a terminal that is not UTF-8 (or `TERM=dumb`, or a very old Windows console) everything is plain
132
+ ASCII and there is no menu or frame: the status is printed above a plain `>` prompt.
133
+
134
+ Colours: terracotta for the full stop, spinner, `❯` prompt, tier name and the selected menu row;
135
+ the terminal's own text colour (bold) for names, or warm bone when the terminal reports a dark
136
+ background (`COLORFGBG`); warm stone for rules and labels; sage for the balance and added diff lines
137
+ (removed lines stay red). 24-bit colour is used only where it is known to work: `COLORTERM` is
138
+ `truecolor` or `24bit`, or `TERM_PROGRAM` is iTerm2, WezTerm, VS Code, Ghostty and similar. macOS
139
+ Terminal.app (`TERM_PROGRAM=Apple_Terminal`) and unknown terminals get the 256 standard colours, with
140
+ terracotta mapped to colour 166 so it stays burnt orange; Terminal.app gets them even when a
141
+ `COLORTERM` was copied into the shell profile, because it misdraws 24-bit escapes. Every line is
142
+ measured by its visible width (colour codes not counted, wide characters counted as two) and kept
143
+ one column short of the window, so nothing wraps.
128
144
 
129
145
  While the agent waits for the first token a spinner with the elapsed seconds is shown (`⠋ waiting 1.2s`)
130
146
  and is erased when output starts. Each tool call is drawn as a card: a header with the tool and its
@@ -143,10 +159,10 @@ file or command, the diff preview for edits inside it, and a one line result.
143
159
  After every turn a dim footer line stays in the scrollback: time to first token, output speed,
144
160
  routing (`local` or `hybrid`) and your credit left (only when the balance is known).
145
161
 
146
- The panel, spinner, cards and footer degrade on their own: with `NO_COLOR` they have no colour; when
162
+ The start screen, spinner, cards and footer degrade on their own: with `NO_COLOR` they have no colour; when
147
163
  output is not a terminal (a pipe, a file, a one-shot task) there is no spinner, no cursor control and
148
164
  the old plain output; when the terminal is not UTF-8, is `TERM=dumb`, or is the old Windows console
149
- (Windows Terminal is fine), the panel, cards and spinner use plain ASCII (`[S.]`, `+-`, `| / - \`). Long paths are cut with an
165
+ (Windows Terminal is fine), the start screen, cards and spinner use plain ASCII (`+-`, `| / - \`). Long paths are cut with an
150
166
  ellipsis to fit narrow windows. Text from the model or from tools never reaches the terminal as
151
167
  control sequences.
152
168
 
@@ -161,24 +177,26 @@ shell startup files, other dotfiles): those are always shown in full.
161
177
  Colour is off when output is not a terminal or when `NO_COLOR` is set; the diff is then plain `+`/`-`
162
178
  text. Control characters in file content are shown as `\x1b`-style escapes, never sent to your terminal.
163
179
 
164
- ## Status bar
180
+ ## Status line
165
181
 
166
- In an interactive terminal the last row of the screen stays pinned and shows:
182
+ In an interactive terminal the line under the input shows only values that are known:
167
183
 
168
184
  ```
169
- Blink 5.5 | local | TTFT 0.42s | 87.3 tok/s | 1,234 tokens
185
+ Blink 5.5 · low effort · synqa-test · ctx 10% · hybrid · 1,234 tokens $9.98 credit
170
186
  ```
171
187
 
172
- - Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `effort low` or `effort medium` when set.
188
+ - Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `low effort` or `medium effort` when set.
189
+ - The workspace folder name.
190
+ - `ctx`: how much of the context window the last request used (after the first request).
173
191
  - Routing: `local` or `hybrid`, read from the `x-synqa-routing` response header of the last request
174
- (`fallback` for a fallback route, `?` if the server did not say).
175
- - TTFT: seconds from sending the request to the first token. tok/s: output tokens per second of
176
- generation over the last turn. A dash means the number was not reported.
192
+ (`fallback` for a fallback route); left out when the server did not say.
177
193
  - Session tokens: input plus output so far.
194
+ - On the right, your credit when the balance is known.
178
195
 
179
- On a narrow terminal the least important parts drop off first. When the output is not a terminal
180
- (a pipe, a file, a very small window, `TERM=dumb`) there is no pinned bar: the same line is printed
181
- above each prompt, and after a one-shot task.
196
+ On a narrow terminal the least important parts drop off first. Time to first token and output speed
197
+ are in the footer after each turn. When the output is not a terminal (a pipe, a file, `TERM=dumb`)
198
+ a plain line (`Blink 5.5 | local | TTFT 0.42s | 87.3 tok/s | 1,234 tokens`, unknown parts left out)
199
+ is printed above each prompt, and after a one-shot task.
182
200
 
183
201
  ## AGENTS.md and /init
184
202
 
@@ -203,7 +221,7 @@ it. Use git for that.
203
221
 
204
222
  | Name | Meaning |
205
223
  |---|---|
206
- | `SYNQA_API_KEY` | Your key; wins over the saved one. Never passed to commands the agent runs. |
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. |
207
225
  | `NO_COLOR` | Any non-empty value turns colour off. |
208
226
  | `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
209
227
 
@@ -211,7 +229,7 @@ it. Use git for that.
211
229
 
212
230
  - File tools only touch files inside the folder you started in (`..`, absolute paths and symlinks
213
231
  out of it are refused).
214
- - Writes, edits and shell commands ask `y/N` first, also in one-shot mode, unless `--yes`. Without a
232
+ - Writes and edits ask `y/N` first, also in one-shot mode, unless `--yes`; shell commands always ask. Every question shows the whole diff (only the display after a `--yes` approval is cut). Commands run without credential-looking environment variables (tokens, secrets, keys and passwords by name, `AWS_*` except region and profile, git askpass helpers, connection strings with a password in them, the API key, and `SSH_AUTH_SOCK`: run `git push` over SSH in your own terminal). `grep` stops a pattern that runs longer than 10 seconds. Without a
215
233
  terminal the answer is no.
216
234
  - Shell commands are not confined to the folder: read each one before you answer.
217
235
  - Writes to `.git/hooks`, `package.json`, shell startup files and other dotfiles carry a warning, because
@@ -220,21 +238,33 @@ it. Use git for that.
220
238
  delimited block as conventions only, you are told when it is loaded, and while it is loaded shell
221
239
  commands always ask first, even with `--yes`.
222
240
  - Files that hold secrets (`.env` and `.env.*` except examples, `.envrc`, `*.pem`, `*.key`, `*.tfvars`, `id_*`
223
- private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube/config`,
224
- `.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`, this CLI's own config)
225
- are opened (read or edit) only after you say yes, never with `--yes`, and are skipped by `grep` and `glob`.
241
+ private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube/`,
242
+ `.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`, `.config/gh`,
243
+ `.config/gcloud`, `.azure/`, `.password-store/`, macOS keychains (`Library/Keychains`, `*.keychain-db`),
244
+ browser profiles (Chrome, Chromium, Brave, Edge, Arc, Firefox, Safari, cookies and saved logins), this
245
+ CLI's own config) are never read, written or edited by the agent, with or without `--yes`, and are
246
+ skipped by `grep` and `glob`. A shell command that names one always asks first, even with `--yes`.
226
247
  Names match without regard to case or full-width letters, as on a case-insensitive Mac.
227
- - The CLI refuses to start in your home folder or the filesystem root (it could reach your keys and
228
- settings there). Pass `--allow-home` only if you mean it.
248
+ - The CLI starts in any folder, your home folder included, like other coding CLIs. Inside your home
249
+ folder everything under a dot entry at its top (`~/.zsh_history`, `~/.config/...`, `~/.cargo/...`,
250
+ any tool's settings) and everything under `~/Library` is protected as a rule, whether or not it is
251
+ named above; your projects' own dot files (`~/proj/.github`) are not affected. `--yes` is refused
252
+ in the home folder itself. Writes to launchd agents, autostart entries, systemd units, fish and
253
+ other shell startup files and `~/.local/bin` always ask. Only the filesystem root (`/`) is refused
254
+ as a start folder. `--allow-home` is still accepted and does nothing.
229
255
  - Your key is never printed, never put on a command line, and removed from the environment of every
230
256
  command the agent runs.
231
257
 
232
258
  ## Troubleshooting
233
259
 
234
260
  - `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
235
- - `the key was rejected`: the key was revoked or mistyped; make a new one on the dashboard.
261
+ - `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`).
264
+ - `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
265
+ chat access.
236
266
  - `request timed out`: a request is cut off after 10 minutes.
237
- - Status bar garbled after resizing: it repaints on resize.
267
+ - Stray characters or a broken layout in Terminal.app: update to 1.1.1, which uses 256 colours there.
238
268
  - No colour: check `NO_COLOR` and that stdout is a terminal.
239
269
 
240
270
  ## Develop
package/dist/api.js CHANGED
@@ -1,4 +1,5 @@
1
- import { API_BASE } from "./config.js";
1
+ import { API_BASE, DASHBOARD_URL } from "./config.js";
2
+ import { sanitize } from "./sanitize.js";
2
3
  import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
3
4
 
4
5
 
@@ -90,9 +91,48 @@ export class ApiError extends Error {
90
91
  }
91
92
  }
92
93
 
94
+ export const KEY_REJECTED = `Your API key was rejected. Run \`synqa login\` again, or check the key is active in your Synqa dashboard (${DASHBOARD_URL}).`;
95
+
96
+ // The gateway's short reason ({"detail": "..."}), already white-labelled by the server; sanitized
97
+ // and cut here because it is network text.
98
+ async function detailOf(res ) {
99
+ try {
100
+ const text = (await res.text()).slice(0, 4000);
101
+ const v = JSON.parse(text) ;
102
+ const d = typeof v.detail === "string" ? v.detail : typeof v.error?.message === "string" ? v.error.message : "";
103
+ return sanitize(d).replace(/\s+/g, " ").trim().slice(0, 200);
104
+ } catch {
105
+ return "";
106
+ }
107
+ }
108
+
109
+ // A plain sentence for a failed request, by status. Never the raw body.
110
+ export function friendlyStatus(status , detail = "") {
111
+ const why = detail ? ` (${detail})` : "";
112
+ if (status === 401) return KEY_REJECTED;
113
+ if (status === 402) return `Your Synqa balance is used up or your plan is not active${why}. Top up in your Synqa dashboard (${DASHBOARD_URL}).`;
114
+ if (status === 403) return `This key is not allowed to do that${why}. Check the key's access in your Synqa dashboard (${DASHBOARD_URL}).`;
115
+ if (status === 404) return "The Synqa service did not recognise this request (404). Update the CLI: npm i -g @synqa/cli";
116
+ if (status === 413) return `The conversation is too large to send${why}. Run /compact or /clear and try again.`;
117
+ if (status === 429) return `Rate limit reached${why}. Wait a moment and try again.`;
118
+ if (status === 503) return `Synqa is busy or paused right now${why}. Try again in a minute.`;
119
+ if (status >= 500) return `The Synqa service had a problem (status ${status}). Try again in a minute.`;
120
+ return `The request failed with status ${status}${why}.`;
121
+ }
122
+
123
+ // Login check: GET /v1/models goes through the same key check as chat (the gateway's caller_key),
124
+ // and the list is empty for a key without the chat capability, so an empty list is refused too.
93
125
  export async function checkKey(apiKey , fetchFn = fetch) {
94
126
  const res = await fetchFn(`${API_BASE}/models`, { headers: { authorization: `Bearer ${apiKey}` } });
95
- if (!res.ok) throw new ApiError(res.status, res.status === 401 || res.status === 403 ? "the key was rejected" : `unexpected status ${res.status}`);
127
+ if (!res.ok) throw new ApiError(res.status, res.status === 401 ? "the key was rejected" : friendlyStatus(res.status, await detailOf(res)));
128
+ let ids = [];
129
+ try {
130
+ const body = (await res.json()) ;
131
+ ids = Array.isArray(body.data) ? body.data : [];
132
+ } catch {
133
+ throw new ApiError(res.status, "the service answer was not understood");
134
+ }
135
+ if (ids.length === 0) throw new ApiError(403, "this key has no chat access; check it in your Synqa dashboard");
96
136
  }
97
137
 
98
138
 
@@ -128,7 +168,8 @@ export async function chat(o ) {
128
168
  }),
129
169
  signal: ac.signal,
130
170
  });
131
- if (!res.ok || !res.body) throw new ApiError(res.status, `request failed with status ${res.status}`);
171
+ if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
172
+ if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
132
173
  const acc = new StreamAccumulator();
133
174
  let firstAt ;
134
175
  for await (const ev of sseEvents(res.body )) {
package/dist/commands.js CHANGED
@@ -15,6 +15,8 @@ import { groupDigits } from "./statusbar.js";
15
15
  import { CONTEXT_TOKENS, EFFORTS, findEffort, findTier, TIERS } from "./tiers.js";
16
16
 
17
17
 
18
+ import { renderHelp, SLASH_HELP, unknownCommandHint } from "./prompt.js";
19
+
18
20
 
19
21
 
20
22
 
@@ -36,23 +38,14 @@ import { CONTEXT_TOKENS, EFFORTS, findEffort, findTier, TIERS } from "./tiers.js
36
38
 
37
39
 
38
40
 
41
+
39
42
 
40
43
 
41
44
 
42
45
 
43
46
 
44
47
 
45
- export const SLASH_HELP = [
46
- "/model blink|sonata|aeon switch tier",
47
- "/effort default|low|medium set reasoning effort",
48
- "/status tier, routing, session tokens, credit and weekly pool",
49
- "/init write a starter AGENTS.md for this project",
50
- "/compact summarize the conversation to save context",
51
- "/undo revert the last file the agent wrote (not shell commands)",
52
- "/clear start a fresh conversation",
53
- "/help this text",
54
- "/exit leave",
55
- ].join("\n");
48
+ export { SLASH_HELP };
56
49
 
57
50
  export function statusParts(st ) {
58
51
  return {
@@ -141,7 +134,7 @@ export async function runCommand(line , st , io )
141
134
  case "/quit":
142
135
  return { handled: true, exit: true };
143
136
  case "/help":
144
- io.print(SLASH_HELP);
137
+ io.print(io.ui ? renderHelp(io.ui).join("\n") : SLASH_HELP);
145
138
  break;
146
139
  case "/clear":
147
140
  st.history = freshHistory(st);
@@ -184,7 +177,7 @@ export async function runCommand(line , st , io )
184
177
  await cmdUndo(st, io);
185
178
  break;
186
179
  default:
187
- io.print("Unknown command. /help lists them.");
180
+ io.print(unknownCommandHint(first));
188
181
  }
189
182
  return { handled: true };
190
183
  }