@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 +66 -8
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +2 -1
- package/dist/commands.js +144 -17
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +144 -66
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +56 -5
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/tiers.js +1 -1
- package/dist/tools.js +9 -1
- package/dist/ui.js +29 -9
- package/dist/update.js +71 -0
- package/package.json +1 -1
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
|
|
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` |
|
|
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.
|
|
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
|
|
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.
|
|
263
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
st
|
|
149
|
-
|
|
150
|
-
|
|
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 "/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
st.
|
|
158
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 ) {
|