@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.
@@ -0,0 +1,234 @@
1
+ // Providers: Synqa Cloud is the primary one and unlocks first; the others are the user's own
2
+ // accounts (OpenCode Go and Zen, a bring-your-own router key, any OpenAI-compatible endpoint).
3
+ // Their credentials are stored in the private config file and sent only to that provider's URL.
4
+ import { checkKey } from "./api.js";
5
+
6
+ import { fetchAccount } from "./account.js";
7
+ import { API_BASE, looksLikeKey, readConfig, resolveApiKey, resolveProviderKey, saveProvider, writeConfig } from "./config.js";
8
+ import { sanitize, sanitizeModelId } from "./sanitize.js";
9
+ import { TIERS } from "./tiers.js";
10
+
11
+
12
+
13
+
14
+
15
+
16
+ export const PROVIDERS = [
17
+ { id: "synqa", name: "Synqa Cloud", label: "Synqa Cloud (Blink 5.5 · Sonata 6.0 · Aeon 6.5) [Primary]", baseUrl: API_BASE },
18
+ { id: "opencode-go", name: "OpenCode Go", label: "OpenCode Go (Subscription models via OpenCode)", baseUrl: "https://opencode.ai/zen/v1" },
19
+ { id: "opencode-zen", name: "OpenCode Zen", label: "OpenCode Zen (Pay-as-you-go via OpenCode)", baseUrl: "https://opencode.ai/zen/v1" },
20
+ { id: "openrouter", name: "OpenRouter", label: "OpenRouter (Bring your own sk-or-... key)", baseUrl: "https://openrouter.ai/api/v1" },
21
+ { id: "custom", name: "Custom endpoint", label: "Custom OpenAI-Compatible Endpoint", baseUrl: "" },
22
+ ];
23
+
24
+ export const providerDef = (id ) => PROVIDERS.find((p) => p.id === id);
25
+
26
+ // Shown only when the provider's own /v1/models list cannot be fetched.
27
+ export const CURATED_MODELS = {
28
+ "opencode-go": ["kimi-k2", "grok-code"],
29
+ "opencode-zen": ["grok-code", "kimi-k2"],
30
+ openrouter: ["openrouter/auto", "moonshotai/kimi-k2", "x-ai/grok-code-fast-1"],
31
+ custom: [],
32
+ };
33
+
34
+ // What /model offers. On Synqa Cloud it is the three tiers (the short id after the name); on the
35
+ // others it is the fetched list, or the curated defaults when nothing could be fetched.
36
+ export function modelItems(provider , fetched ) {
37
+ if (provider === "synqa") {
38
+ return TIERS.map((t) => ({ label: `${t.display} (${t.id.replace(/-\d+(\.\d+)*$/, "")})`, value: t.key }));
39
+ }
40
+ const ids = fetched && fetched.length ? fetched : CURATED_MODELS[provider] ?? [];
41
+ return ids.slice(0, 200).map((id) => ({ label: id, value: id }));
42
+ }
43
+
44
+ const trimSlash = (u ) => u.replace(/\/+$/, "");
45
+
46
+ // GET {base}/models. Returns the model ids; throws a short, sanitized message on failure.
47
+ export async function fetchModelIds(baseUrl , secret , fetchFn = fetch, timeoutMs = 8000) {
48
+ const ac = new AbortController();
49
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
50
+ try {
51
+ const res = await fetchFn(`${trimSlash(baseUrl)}/models`, { headers: { authorization: `Bearer ${secret}` }, signal: ac.signal });
52
+ if (res.status === 401 || res.status === 403) throw new Error("the key was rejected");
53
+ if (!res.ok) throw new Error(`the provider answered with status ${res.status}`);
54
+ const body = (await res.json()) ;
55
+ const data = Array.isArray(body.data) ? body.data : [];
56
+ return data
57
+ .map((m) => (m && typeof m === "object" ? (m ).id : undefined))
58
+ .filter((id) => typeof id === "string" && id.length > 0 && id.length <= 200)
59
+ .map((id) => sanitize(id));
60
+ } catch (err) {
61
+ if (ac.signal.aborted) throw new Error("the provider did not answer in time");
62
+ throw new Error(sanitize(err instanceof Error ? err.message : String(err)));
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ // A base URL a person typed: http(s) only, no credentials in it, no query or fragment.
69
+ export function normalizeBaseUrl(raw ) {
70
+ try {
71
+ const u = new URL(raw.trim());
72
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined;
73
+ // The key travels in a header: plain http only to this machine.
74
+ if (u.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) return undefined;
75
+ if (u.username || u.password || u.search || u.hash) return undefined;
76
+ return trimSlash(u.toString());
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // Verifies a Synqa key against GET /v1/account (the models list when the account route is not
83
+ // available to keys yet). Returns undefined when the key is good, else a short reason.
84
+ export async function verifySynqaKey(candidate , fetchFn ) {
85
+ if (!looksLikeKey(candidate)) return "That does not look like a Synqa key (expected synqa_live_...).";
86
+ const r = await fetchAccount(candidate, fetchFn, 8000);
87
+ if (r.ok) return undefined;
88
+ if (r.notFound) {
89
+ try {
90
+ await checkKey(candidate, fetchFn);
91
+ return undefined;
92
+ } catch (err) {
93
+ return `Could not verify the key: ${sanitize(err instanceof Error ? err.message : String(err))}`;
94
+ }
95
+ }
96
+ return `Could not verify the key: ${sanitize(r.message)}`;
97
+ }
98
+
99
+
100
+
101
+ // The synqa-first gate: nothing else unlocks until a valid Synqa Cloud key is saved. `secret`
102
+ // reads masked input (undefined = cancelled). Returns the key, or undefined when cancelled or
103
+ // out of attempts (the TUI stays locked).
104
+ export async function unlockGate(o
105
+
106
+
107
+
108
+
109
+
110
+ ) {
111
+ const env = o.env ?? process.env;
112
+ for (let i = 0; i < (o.attempts ?? 3); i++) {
113
+ const entered = await o.secret("Synqa Cloud API key (synqa_live_...): ");
114
+ if (entered === undefined) return undefined;
115
+ const why = await verifySynqaKey(entered, o.fetchFn);
116
+ if (why) { o.print(why); continue; }
117
+ writeConfig({ ...readConfig(env), apiKey: entered }, env);
118
+ return entered;
119
+ }
120
+ o.print("Too many attempts. Run synqa again to sign in.");
121
+ return undefined;
122
+ }
123
+
124
+
125
+
126
+
127
+
128
+
129
+
130
+
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+ // Makes `id` the active provider. For a secondary one it asks for the key when none is saved,
142
+ // verifies it with GET /models, stores it and picks a first model. Returns false when cancelled.
143
+ export async function activateProvider(st , id , io ) {
144
+ const env = io.env ?? process.env;
145
+ const def = providerDef(id) ;
146
+ if (id === "synqa") {
147
+ const saved = resolveApiKey(env, { envKey: io.envKey });
148
+ if (!saved) { io.print("Not signed in to Synqa Cloud."); return false; }
149
+ st.apiKey = saved; st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined;
150
+ writeConfig({ ...readConfig(env), activeProvider: "synqa" }, env);
151
+ io.print("Using Synqa Cloud.");
152
+ return true;
153
+ }
154
+ const entry = readConfig(env).providers?.[id];
155
+ let baseUrl = def.baseUrl;
156
+ let model = entry?.model;
157
+ if (id === "custom") {
158
+ const raw = entry?.baseUrl ?? (await io.ask("Base URL (for example https://host/v1): "));
159
+ if (raw === undefined) return false;
160
+ const norm = normalizeBaseUrl(raw);
161
+ if (!norm) { io.print("That is not a usable base URL (https, or http for localhost only)."); return false; }
162
+ baseUrl = norm;
163
+ }
164
+ let credential = resolveProviderKey(id, env, { envKey: io.envKey });
165
+ if (!credential) {
166
+ credential = await io.secret(`${def.name} API key: `);
167
+ if (!credential) { io.print("Cancelled."); return false; }
168
+ }
169
+ let ids = [];
170
+ try {
171
+ ids = await fetchModelIds(baseUrl, credential, io.fetchFn);
172
+ } catch (err) {
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ // A custom endpoint may not serve /models at all; a rejected key is always final.
175
+ if (id !== "custom" || /rejected/.test(msg)) { io.print(`Could not verify the key: ${msg}`); return false; }
176
+ io.print(`Could not list models (${msg}); continuing.`);
177
+ }
178
+ if (id === "custom" && !model) {
179
+ const m = await io.ask("Model ID: ");
180
+ if (!m) { io.print("Cancelled."); return false; }
181
+ model = sanitizeModelId(m);
182
+ if (!model) { io.print("That is not a usable model id."); return false; }
183
+ }
184
+ if (!model) model = modelItems(id, ids)[0]?.value;
185
+ saveProvider(id, { apiKey: credential, ...(id === "custom" ? { baseUrl } : {}), ...(model ? { model } : {}) }, env);
186
+ writeConfig({ ...readConfig(env), activeProvider: id }, env);
187
+ st.apiKey = credential; st.providerId = id; st.baseUrl = baseUrl; st.modelId = model;
188
+ io.print(`Using ${def.name}${model ? ` with ${model}` : ""}.`);
189
+ return true;
190
+ }
191
+
192
+ // /provider and /login: the arrow-key menu.
193
+ export async function providerMenu(st , io , arg = "") {
194
+ const wanted = arg.trim().toLowerCase();
195
+ let id = PROVIDERS.find((p) => p.id === wanted)?.id;
196
+ if (!id) {
197
+ const items = PROVIDERS.map((p) => ({ label: p.label, value: p.id, hint: p.id === st.providerId ? "(active)" : undefined }));
198
+ id = await io.pick(items, "Choose a provider");
199
+ }
200
+ if (!id) { io.print("Cancelled."); return false; }
201
+ return activateProvider(st, id, io);
202
+ }
203
+
204
+
205
+
206
+ // /logout. Returns "locked" after a full logout (the caller returns to the sign-in prompt),
207
+ // "secondary" after leaving a secondary provider, or undefined when nothing changed.
208
+ export async function logout(st , io ) {
209
+ const env = io.env ?? process.env;
210
+ const choice = await io.pick ([
211
+ { label: "Log out of active secondary provider", value: "secondary" },
212
+ { label: "Full logout (clear Synqa key and lock TUI)", value: "full" },
213
+ ], "Log out");
214
+ if (!choice) { io.print("Cancelled."); return undefined; }
215
+ const cfg = readConfig(env);
216
+ if (choice === "secondary") {
217
+ if (st.providerId === "synqa") { io.print("No secondary provider is active."); return undefined; }
218
+ const providers = { ...cfg.providers };
219
+ delete providers[st.providerId];
220
+ const synqaKey = cfg.apiKey?.trim() || env.SYNQA_API_KEY?.trim() || "";
221
+ writeConfig({ ...cfg, providers, activeProvider: "synqa" }, env);
222
+ st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined; st.apiKey = synqaKey;
223
+ io.print("Logged out of the secondary provider. Using Synqa Cloud.");
224
+ return "secondary";
225
+ }
226
+ const next = { ...cfg, activeProvider: "synqa" };
227
+ delete next.apiKey;
228
+ writeConfig(next, env);
229
+ // The variable is cleared in this process only, so nobody has to unset it in their shell.
230
+ delete env.SYNQA_API_KEY;
231
+ st.apiKey = ""; st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined;
232
+ io.print("Logged out of Synqa Cloud. The session is locked until you sign in again.");
233
+ return "locked";
234
+ }
package/dist/sanitize.js CHANGED
@@ -51,3 +51,9 @@ export function fenceText(s , max ) {
51
51
  .slice(0, max)
52
52
  .replace(/</g, "\u2039");
53
53
  }
54
+
55
+ // A model id a person typed or a provider listed: letters, digits and . _ : / @ + - only, 200 at most.
56
+ export function sanitizeModelId(s ) {
57
+ const t = s.trim();
58
+ return /^[A-Za-z0-9._:\/@+-]{1,200}$/.test(t) ? t : undefined;
59
+ }
package/dist/select.js ADDED
@@ -0,0 +1,131 @@
1
+ // Inline terminal controls that take over the keyboard for a moment: an Up/Down selector (pickOne)
2
+ // and a masked secret prompt (readMasked). Both read keys from a KeySource and write to a plain
3
+ // { write } sink, so they are tested with a fake source and a string buffer. The only control
4
+ // sequences written are the fixed ones in this file (cursor up, erase to end of screen).
5
+ import { emitKeypressEvents } from "node:readline";
6
+ import { dim, terracottaBold } from "./theme.js";
7
+
8
+
9
+
10
+
11
+ // Something that delivers key presses and can be listened to exclusively: subscribe returns the
12
+ // function that gives the keyboard back.
13
+
14
+
15
+
16
+ const ESC = "\x1b";
17
+
18
+ // A KeySource over a raw stdin stream (used where no PromptFrame owns the keyboard, and in tests
19
+ // that drive a PassThrough).
20
+ export function streamKeys(stdin ) {
21
+ emitKeypressEvents(stdin);
22
+ return {
23
+ subscribe(fn) {
24
+ const was = (stdin ).isRaw;
25
+ stdin.setRawMode?.(true);
26
+ stdin.on("keypress", fn);
27
+ stdin.resume();
28
+ return () => {
29
+ stdin.off("keypress", fn);
30
+ if (!was) { stdin.setRawMode?.(false); stdin.pause(); }
31
+ };
32
+ },
33
+ };
34
+ }
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+ const oneLine = (s ) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
48
+
49
+ // Draws the list and returns the number of rows written. Text comes from our own tables or from
50
+ // a provider's model list, so it is cut to one plain line first.
51
+ export function renderPick (items , active , o ) {
52
+ const s = o.style ?? { color: false, depth: 8 };
53
+ const pointer = o.unicode === false ? ">" : "❯";
54
+ const max = Math.max(1, o.max ?? 10);
55
+ const start = Math.max(0, Math.min(active - max + 1, items.length - max));
56
+ return items.slice(start, start + max).map((it, i) => {
57
+ const on = start + i === active;
58
+ const label = oneLine(it.label);
59
+ const hint = it.hint ? " " + dim(s, oneLine(it.hint)) : "";
60
+ return on ? `${terracottaBold(s, `${pointer} ${label}`)}${hint}` : ` ${label}${hint}`;
61
+ });
62
+ }
63
+
64
+ // Up/Down (or k/j) move, Enter confirms, Esc or Ctrl+C cancels. Resolves to the chosen value, or
65
+ // undefined when cancelled. The list is erased on the way out so the transcript stays clean.
66
+ export function pickOne (items , o ) {
67
+ if (items.length === 0) return Promise.resolve(undefined);
68
+ return new Promise((resolve) => {
69
+ let active = Math.min(Math.max(0, o.initial ?? 0), items.length - 1);
70
+ let rows = 0;
71
+ const s = o.style ?? { color: false, depth: 8 };
72
+ const header = o.title ? [dim(s, oneLine(o.title) + (o.unicode === false ? " (up/down, enter, esc)" : " (↑↓ move, enter select, esc cancel)"))] : [];
73
+ const draw = () => {
74
+ const lines = [...header, ...renderPick(items, active, o)];
75
+ let out = rows > 0 ? `\r${ESC}[${rows}A${ESC}[J` : "";
76
+ out += lines.join("\n") + "\n";
77
+ rows = lines.length;
78
+ o.out.write(out);
79
+ };
80
+ const finish = (v ) => {
81
+ off();
82
+ o.out.write(`\r${ESC}[${rows}A${ESC}[J`);
83
+ resolve(v);
84
+ };
85
+ const off = o.keys.subscribe((str, key) => {
86
+ const name = key?.name;
87
+ const seq = key?.sequence ?? str ?? "";
88
+ if (name === "up" || (name === undefined && seq === "k")) active = (active - 1 + items.length) % items.length;
89
+ else if (name === "down" || (name === undefined && seq === "j") || name === "tab") active = (active + 1) % items.length;
90
+ else if (name === "return" || name === "enter") { finish((items[active] ).value); return; }
91
+ else if (name === "escape" || (key?.ctrl && name === "c") || seq === "\x03") { finish(undefined); return; }
92
+ else return;
93
+ draw();
94
+ });
95
+ draw();
96
+ });
97
+ }
98
+
99
+
100
+
101
+ // A secret read one key at a time; each character shows as a bullet and nothing typed is echoed.
102
+ // Pasted text arrives as characters and is accepted; Enter ends it, Esc or Ctrl+C cancels
103
+ // (undefined). Backspace removes one character and one bullet.
104
+ export function readMasked(prompt , o ) {
105
+ const bullet = o.unicode === false ? "*" : "•";
106
+ return new Promise((resolve) => {
107
+ let value = "";
108
+ const finish = (v ) => {
109
+ off();
110
+ o.out.write("\n");
111
+ resolve(v);
112
+ };
113
+ o.out.write(prompt);
114
+ const off = o.keys.subscribe((str, key) => {
115
+ const name = key?.name;
116
+ const seq = key?.sequence ?? str ?? "";
117
+ if (name === "return" || name === "enter" || seq === "\r" || seq === "\n") { finish(value.trim()); return; }
118
+ if (name === "escape" || (key?.ctrl && name === "c") || seq === "\x03") { finish(undefined); return; }
119
+ if (name === "backspace" || seq === "\x7f" || seq === "\b") {
120
+ if (value.length > 0) { value = Array.from(value).slice(0, -1).join(""); o.out.write("\b \b"); }
121
+ return;
122
+ }
123
+ if (key?.ctrl || key?.meta) return;
124
+ // Printable text only (a paste may carry several characters; control characters are dropped).
125
+ const text = Array.from(seq).filter((ch) => ch >= " " && ch !== "\x7f" && !/[\u0080-\u009f]/.test(ch)).join("");
126
+ if (!text) return;
127
+ value += text;
128
+ o.out.write(bullet.repeat(Array.from(text).length));
129
+ }, { paste: true });
130
+ });
131
+ }
package/dist/statusbar.js CHANGED
@@ -26,22 +26,29 @@ export function formatRate(tps ) {
26
26
  }
27
27
 
28
28
  // Fits `width` columns by dropping segments from the least important end; the tier always stays.
29
+ // Unknown values (routing "?", no TTFT or rate yet) are left out, never shown as "?" or "-".
29
30
  export function formatStatus(p , width ) {
30
31
  const tier = p.tier.display;
31
32
  const effort = p.effort === "default" ? "" : `effort ${p.effort}`;
32
- const full = [tier, effort, p.routing, `TTFT ${formatTtft(p.ttftMs)}`, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tokens`];
33
+ const route = p.routing && p.routing !== "?" ? p.routing : "";
34
+ const ttft = formatTtft(p.ttftMs);
35
+ const t = ttft === "-" ? "" : `TTFT ${ttft}`;
36
+ const r = formatRate(p.tokPerSec);
37
+ const rate = r === "-" ? "" : r;
38
+ const toks = `${groupDigits(p.tokens)} tokens`;
39
+ const short = `${groupDigits(p.tokens)} tok`;
33
40
  const variants = [
34
- full,
35
- [tier, p.routing, `TTFT ${formatTtft(p.ttftMs)}`, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tokens`],
36
- [tier, p.routing, formatRate(p.tokPerSec), `${groupDigits(p.tokens)} tok`],
37
- [tier, p.routing, `${groupDigits(p.tokens)} tok`],
38
- [tier, p.routing],
41
+ [tier, effort, route, t, rate, toks],
42
+ [tier, route, t, rate, toks],
43
+ [tier, route, rate, short],
44
+ [tier, route, short],
45
+ [tier, route],
39
46
  ];
40
47
  const join = (parts ) => parts.filter((x) => x !== "").join(" | ");
41
- if (width === undefined) return join(full);
48
+ if (width === undefined) return join(variants[0] );
42
49
  for (const v of variants) {
43
- const t = join(v);
44
- if (t.length <= width) return t;
50
+ const s = join(v);
51
+ if (s.length <= width) return s;
45
52
  }
46
53
  const last = join(variants[variants.length - 1] );
47
54
  return width > 1 ? last.slice(0, width - 1) + "…" : last.slice(0, width);
package/dist/theme.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The Synqa palette and every colour escape the CLI emits. This file is the only place that builds
2
2
  // an escape sequence for colour; text from the model or from tools never reaches it as a code.
3
- // 24-bit colour when the terminal says it has it (COLORTERM=truecolor|24bit), otherwise the nearest
4
- // xterm-256 index. NO_COLOR, a non-TTY or TERM=dumb turn all of it off.
3
+ // 24-bit colour only when the terminal is known to draw it (COLORTERM=truecolor|24bit, or a
4
+ // TERM_PROGRAM known for it); macOS Terminal.app and anything unknown get xterm-256 colours, which
5
+ // every modern terminal draws. NO_COLOR, a non-TTY or TERM=dumb turn all of it off.
5
6
 
6
7
 
7
8
 
@@ -12,8 +13,11 @@ export const SAGE = [0x4a, 0x7c, 0x59]; // #4a7c59 sage: balance highlights
12
13
 
13
14
 
14
15
 
15
- // depth is optional so a bare { color: true } keeps meaning 24-bit.
16
-
16
+ // depth is optional; absent means 256 colours, the safe choice (Terminal.app has no 24-bit colour).
17
+ // bg is the terminal background when the environment says so (COLORFGBG); bone text is used only
18
+ // on a known dark background, plain bold otherwise, so names read on light and dark themes.
19
+
20
+
17
21
 
18
22
  export function colorEnabled(env , isTTY ) {
19
23
  if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
@@ -21,9 +25,30 @@ export function colorEnabled(env , isTTY
21
25
  return isTTY;
22
26
  }
23
27
 
28
+ // Terminals that draw 24-bit colour, by TERM_PROGRAM (compared lower case).
29
+ const TRUECOLOR_PROGRAMS = new Set(["iterm.app", "wezterm", "vscode", "ghostty", "hyper", "tabby", "warpterminal", "rio", "kitty", "alacritty", "contour", "zed"]);
30
+
24
31
  export function colorDepth(env ) {
32
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
33
+ // Terminal.app before macOS 26 misdraws 24-bit escapes, and a COLORTERM copied from another
34
+ // terminal's profile (or passed through ssh or tmux) must not switch them on there.
35
+ if (program === "apple_terminal") return 8;
25
36
  const c = (env.COLORTERM ?? "").toLowerCase();
26
- return c === "truecolor" || c === "24bit" ? 24 : 8;
37
+ if (c === "truecolor" || c === "24bit") return 24;
38
+ if (TRUECOLOR_PROGRAMS.has(program)) return 24;
39
+ const term = (env.TERM ?? "").toLowerCase();
40
+ if (/(^|-)(kitty|alacritty|direct|truecolor|ghostty|wezterm)($|-)/.test(term) || term.endsWith("-direct")) return 24;
41
+ return 8;
42
+ }
43
+
44
+ // COLORFGBG ("15;0", or "15;default;0") names the background colour index when a terminal sets
45
+ // it (rxvt, Konsole, iTerm2 with the option on). 0-6 and 8 are dark; 7 and 9-15 are light.
46
+ export function detectBackground(env ) {
47
+ const raw = env.COLORFGBG;
48
+ if (!raw) return undefined;
49
+ const bg = Number(raw.split(";").pop());
50
+ if (!Number.isInteger(bg) || bg < 0 || bg > 15) return undefined;
51
+ return bg === 7 || bg >= 9 ? "light" : "dark";
27
52
  }
28
53
 
29
54
  const CUBE = [0, 95, 135, 175, 215, 255] ;
@@ -53,8 +78,17 @@ export function rgbTo256(rgb ) {
53
78
  const RESET = "\x1b[0m";
54
79
 
55
80
  // The SGR parameters that select `rgb` as the foreground.
81
+ // Hand-picked 256-colour stand-ins where the nearest match loses the brand: the nearest index for
82
+ // terracotta (167) is a pink salmon; 166 (#d75f00) keeps the burnt orange and reads on light and
83
+ // dark backgrounds.
84
+ const TUNED_256 = new Map ([[TERRACOTTA.join(","), 166]]);
85
+
86
+ export function to256(rgb ) {
87
+ return TUNED_256.get(rgb.join(",")) ?? rgbTo256(rgb);
88
+ }
89
+
56
90
  export function fgParams(depth , rgb ) {
57
- return depth === 8 ? `38;5;${rgbTo256(rgb)}` : `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
91
+ return depth === 24 ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : `38;5;${to256(rgb)}`;
58
92
  }
59
93
 
60
94
  function paint(s , params , text ) {
@@ -65,8 +99,14 @@ export function terracotta(s , text ) {
65
99
  return paint(s, fgParams(s.depth, TERRACOTTA), text);
66
100
  }
67
101
 
102
+ // Bone on a known dark background; plain bold (the terminal's own text colour) otherwise, because
103
+ // bone is nearly invisible on a light background.
68
104
  export function boneBold(s , text ) {
69
- return paint(s, `1;${fgParams(s.depth, BONE)}`, text);
105
+ return paint(s, s.bg === "dark" ? `1;${fgParams(s.depth, BONE)}` : "1", text);
106
+ }
107
+
108
+ export function terracottaBold(s , text ) {
109
+ return paint(s, `1;${fgParams(s.depth, TERRACOTTA)}`, text);
70
110
  }
71
111
 
72
112
  export function stone(s , text ) {
package/dist/tiers.js CHANGED
@@ -9,7 +9,7 @@ export const TIERS = [
9
9
  export const DEFAULT_TIER = "blink";
10
10
 
11
11
  // Reasoning effort the user can pick. "default" sends no reasoning_effort at all.
12
- export const EFFORTS = ["default", "low", "medium"] ;
12
+ export const EFFORTS = ["default", "low", "medium", "high"] ;
13
13
 
14
14
 
15
15
  export function findEffort(name ) {