@tiens.nguyen/gu-cli 1.0.707 → 1.0.708

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/gu-repl.mjs CHANGED
@@ -24,13 +24,13 @@
24
24
  * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { existsSync, statSync, readFileSync } from "node:fs";
27
+ import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
28
28
  import { spawn } from "node:child_process";
29
29
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
30
30
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
31
31
  import { requireSupportedNode } from "./node-version.mjs";
32
32
  requireSupportedNode();
33
- import { homedir } from "node:os";
33
+ import { homedir, hostname } from "node:os";
34
34
  import { dirname, join, resolve, basename } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { PassThrough } from "node:stream";
@@ -795,6 +795,8 @@ const COMMANDS = [
795
795
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
796
796
  { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
797
797
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
798
+ { name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's coding providers, models and key NAMES to a file (no secrets)" },
799
+ { name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
798
800
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
799
801
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
800
802
  { name: "/help", desc: "list these commands" },
@@ -6093,6 +6095,138 @@ async function chooseModel() {
6093
6095
  // The settings row as the WORKER sees it (X-Worker-Key, no Firebase). This is the only route a
6094
6096
  // Client Expert machine has to its own settings — there is no web app pointed at that API — so
6095
6097
  // anything a command needs to show or change has to come through here.
6098
+ /**
6099
+ * `/save-settings [path]` and `/load-settings [path]` — move a setup between machines (#244).
6100
+ *
6101
+ * WHAT TRAVELS: the coding providers and their model lists, the agent model, and the NAMES of the
6102
+ * API keys. What does not travel is the keys themselves — the API will not hand them over (see
6103
+ * settings-transfer.mjs for why, and why that is right), so loading asks for the ones the target
6104
+ * account is missing. The file is therefore safe to copy about, which is the whole point of it.
6105
+ *
6106
+ * NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
6107
+ * loaded into: a file that moves your models should not quietly move your coder too.
6108
+ */
6109
+ const SETTINGS_FILE_DEFAULT = join(homedir(), "gu-settings.json");
6110
+
6111
+ /** The path a command was given, or the default. Quotes stripped: people paste paths with them. */
6112
+ function settingsPathFrom(line, word) {
6113
+ const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
6114
+ if (!typed) return SETTINGS_FILE_DEFAULT;
6115
+ const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
6116
+ return resolve(expanded);
6117
+ }
6118
+
6119
+ async function runSaveSettingsCommand(line) {
6120
+ const st = await import("./settings-transfer.mjs");
6121
+ const file = settingsPathFrom(line, "/save-settings");
6122
+ console.log("");
6123
+ const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6124
+ if (!settings) {
6125
+ console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
6126
+ return;
6127
+ }
6128
+ const keys = (await fetchCodingKeys()) ?? [];
6129
+ const doc = st.buildTransfer({ settings, keys, host: hostname() });
6130
+ if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
6131
+ console.log(GUTTER + dim(" this account has no coding providers and no agent model set — nothing to save.\n"));
6132
+ return;
6133
+ }
6134
+ // ASK BEFORE OVERWRITING. The default path is one everybody shares, so the second machine to
6135
+ // save would otherwise eat the first machine's file without a word.
6136
+ if (existsSync(file)) {
6137
+ const ok = (await ask(`${file} exists — overwrite it? [y/N] `)).trim().toLowerCase();
6138
+ if (ok !== "y" && ok !== "yes") {
6139
+ console.log(GUTTER + dim(" left it alone.\n"));
6140
+ return;
6141
+ }
6142
+ }
6143
+ try {
6144
+ // 0600: it names your providers and your key names. Not secret, but nobody else's business.
6145
+ writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
6146
+ } catch (e) {
6147
+ console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
6148
+ return;
6149
+ }
6150
+ const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
6151
+ console.log(GUTTER + accent(` saved to ${file}`));
6152
+ console.log(
6153
+ GUTTER +
6154
+ dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
6155
+ );
6156
+ console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
6157
+ }
6158
+
6159
+ async function runLoadSettingsCommand(line) {
6160
+ const st = await import("./settings-transfer.mjs");
6161
+ const file = settingsPathFrom(line, "/load-settings");
6162
+ console.log("");
6163
+ let text;
6164
+ try {
6165
+ text = readFileSync(file, "utf8");
6166
+ } catch {
6167
+ console.log(GUTTER + red(` no file at ${file}`));
6168
+ console.log(GUTTER + dim(" /load-settings <path>, or save one first with /save-settings.\n"));
6169
+ return;
6170
+ }
6171
+ // VALIDATED BEFORE ANYTHING IS TOUCHED: a settings file applied halfway is worse than one
6172
+ // refused, because the half that landed is the half nobody re-reads.
6173
+ const parsed = st.validateTransfer(text);
6174
+ if (!parsed.ok) {
6175
+ console.log(GUTTER + red(` ${parsed.problem}`));
6176
+ console.log(GUTTER + dim(` file: ${file}\n`));
6177
+ return;
6178
+ }
6179
+ const current = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6180
+ if (!current) {
6181
+ console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was changed.\n`));
6182
+ return;
6183
+ }
6184
+ const keys = (await fetchCodingKeys()) ?? [];
6185
+ const plan = st.planLoad(parsed.value, { ...current, keys });
6186
+ console.log(GUTTER + dim(` from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`));
6187
+ for (const l of plan.lines) console.log(GUTTER + dim(" " + l));
6188
+ if (parsed.carriesSecrets) {
6189
+ console.log(GUTTER + yellow(" this file contains API keys in plain text — delete it when you are done."));
6190
+ }
6191
+ console.log("");
6192
+ const go = (await ask(" apply this? [y/N] ")).trim().toLowerCase();
6193
+ if (go !== "y" && go !== "yes") {
6194
+ console.log(GUTTER + dim(" nothing changed.\n"));
6195
+ return;
6196
+ }
6197
+ const md = await import("./model-doctor.mjs");
6198
+ // `current` so a provider this account already has keeps its row id — see toSettingsPatch.
6199
+ const patch = st.toSettingsPatch(parsed.value, current);
6200
+ const saved = Object.keys(patch).length
6201
+ ? await md.saveWorkerSettings(apiBase, workerKey, patch)
6202
+ : true;
6203
+ if (!saved) {
6204
+ console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
6205
+ return;
6206
+ }
6207
+ console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
6208
+
6209
+ // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
6210
+ // once the API has minted their ids.
6211
+ const fresh = (await fetchWorkerSettings()) ?? {};
6212
+ const rows = Array.isArray(fresh.agentCodingBackends) ? fresh.agentCodingBackends : [];
6213
+ const rowIdForUrl = (url) => {
6214
+ const hit = rows.find((r) => String(r?.url ?? "").trim().toLowerCase() === String(url).trim().toLowerCase());
6215
+ return hit?.id ?? "";
6216
+ };
6217
+ for (const k of plan.missingKeys) {
6218
+ const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
6219
+ const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
6220
+ if (!typed) {
6221
+ console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
6222
+ continue;
6223
+ }
6224
+ const id = await addCodingKey(k.name, typed, k.backendUrl ? rowIdForUrl(k.backendUrl) : "");
6225
+ console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
6226
+ }
6227
+ console.log(GUTTER + dim(" pick a coding model with /model.\n"));
6228
+ }
6229
+
6096
6230
  async function fetchWorkerSettings() {
6097
6231
  try {
6098
6232
  const r = await fetch(`${apiBase}/api/worker/settings`, {
@@ -10536,6 +10670,14 @@ async function main() {
10536
10670
  await runS3Command(line);
10537
10671
  continue;
10538
10672
  }
10673
+ if (line === "/save-settings" || line.startsWith("/save-settings ")) {
10674
+ await runSaveSettingsCommand(line);
10675
+ continue;
10676
+ }
10677
+ if (line === "/load-settings" || line.startsWith("/load-settings ")) {
10678
+ await runLoadSettingsCommand(line);
10679
+ continue;
10680
+ }
10539
10681
  if (line === "/test-auto") {
10540
10682
  sessionTestAuto = !sessionTestAuto;
10541
10683
  await saveSession(cwd, history); // persist the choice for this folder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.707",
3
+ "version": "1.0.708",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  "rag-selftest.mjs",
54
54
  "cli-token.mjs",
55
55
  "credential-recovery.mjs",
56
+ "settings-transfer.mjs",
56
57
  "device-login.mjs",
57
58
  "daemon-control.mjs",
58
59
  "poll-errors.mjs",
@@ -0,0 +1,253 @@
1
+ /**
2
+ * MOVING A SETUP BETWEEN MACHINES — the file /save-settings writes and /load-settings reads (#244).
3
+ *
4
+ * WHAT IT CARRIES: the coding backends (the list /model picks from), the agent model, and the
5
+ * IDENTITY of each API key. What it deliberately does not carry is the keys themselves.
6
+ *
7
+ * WHY NO SECRETS, since this is the first question anyone asks of a "save my settings" file: the API
8
+ * will not give them to us. GET /api/worker/coding-keys returns maskCodingKeys() — id, name, last
9
+ * four characters — and says why on the route: it is the one place that lists credentials, so it is
10
+ * the one place that could turn a leaked worker key into "dump every key I own". The only route that
11
+ * returns a real secret returns exactly one, the key a given job or provider would be handed. A save
12
+ * could walk that route once per provider and harvest the lot, which is precisely the thing the
13
+ * masking exists to prevent — and it would put them in a file that then sits in Downloads.
14
+ *
15
+ * So the file names the keys and loading asks for the ones the target account is missing. The
16
+ * side-effect is the good kind: this file is safe to copy, email, or commit.
17
+ *
18
+ * ROW IDS NEVER TRAVEL. They identify a row WITHIN one account — a folder's saved pick, a job's
19
+ * record of which key it used, the account default all name one — so another account's ids would
20
+ * either collide with a row already there or silently re-point a folder at a different provider.
21
+ * The file therefore names providers by URL, which means the same thing on every machine.
22
+ *
23
+ * ON LOADING, A PROVIDER THIS ACCOUNT ALREADY HAS KEEPS ITS OWN ID (see toSettingsPatch). Minting a
24
+ * fresh one would strand every key bound to the old row: a key whose row is gone is ineligible
25
+ * everywhere and filtered out of every picker, so it can never be selected, used or deleted again.
26
+ *
27
+ * THE CURRENT CHOICE IS NOT SAVED, by instruction and by safety: agentCodingDefaultKind and
28
+ * agentCodingDefaultBackendId stay as they are on the machine being loaded into. A file that moves
29
+ * your models should not also decide which one you are coding with.
30
+ */
31
+
32
+ /** Bumped only when an older gu could not read a newer file correctly. */
33
+ export const FILE_VERSION = 1;
34
+
35
+ /** The kinds the API's own type allows (CodingBackendKind). */
36
+ const KINDS = ["", "ollama", "openai", "local"];
37
+
38
+ const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
39
+ const isStr = (v) => typeof v === "string";
40
+ const clean = (v) => (typeof v === "string" ? v.trim() : "");
41
+
42
+ /**
43
+ * The object /save-settings writes.
44
+ *
45
+ * `settings` is the account's settings as the API returns them; `keys` is the masked list. Neither
46
+ * is trusted to be well-formed — a machine that has never set a coder has no backends at all.
47
+ */
48
+ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host = "" } = {}) {
49
+ const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
50
+ const backends = rows
51
+ .filter((r) => r && typeof r === "object")
52
+ .map((r) => ({
53
+ kind: KINDS.includes(r.kind) ? r.kind : "",
54
+ url: clean(r.url),
55
+ defaultModel: clean(r.defaultModel),
56
+ models: Array.isArray(r.models) ? r.models.filter(isStr).map((m) => m.trim()).filter(Boolean) : [],
57
+ enabled: r.enabled !== false,
58
+ }));
59
+ // A key's row id means nothing on another account; the provider's URL means the same everywhere.
60
+ const urlForRowId = new Map(rows.filter((r) => r?.id).map((r) => [r.id, clean(r.url)]));
61
+ const keyList = (Array.isArray(keys) ? keys : [])
62
+ .filter((k) => k && typeof k === "object" && clean(k.name))
63
+ .map((k) => ({
64
+ name: clean(k.name),
65
+ tail: clean(k.tail),
66
+ ...(urlForRowId.get(k.backendId) ? { backendUrl: urlForRowId.get(k.backendId) } : {}),
67
+ }));
68
+ return {
69
+ gu: "settings",
70
+ version: FILE_VERSION,
71
+ // Said in the file, because someone will open it and wonder.
72
+ note: "Models and providers only. This file contains NO API keys — gu asks for those when you load it.",
73
+ savedAt: new Date(now).toISOString(),
74
+ ...(host ? { savedOn: host } : {}),
75
+ codingBackends: backends,
76
+ agentModel: { url: clean(settings.agentModelUrl), name: clean(settings.agentModelName) },
77
+ keys: keyList,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Read a file's text and say either what it means or what is wrong with it.
83
+ *
84
+ * ONE PROBLEM, NAMED. Everything is checked before anything is applied, and the first real problem
85
+ * refuses the whole file: a settings file applied halfway is worse than one rejected, because the
86
+ * half that landed is the half nobody reads back.
87
+ */
88
+ export function validateTransfer(text) {
89
+ let doc;
90
+ try {
91
+ doc = JSON.parse(String(text));
92
+ } catch {
93
+ return { ok: false, problem: "that file is not JSON — is it the file gu saved?" };
94
+ }
95
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
96
+ return { ok: false, problem: "that file is not a gu settings file (expected a JSON object)." };
97
+ }
98
+ if (doc.gu !== "settings") {
99
+ return { ok: false, problem: 'that file is not a gu settings file (no "gu": "settings" marker).' };
100
+ }
101
+ if (doc.version !== FILE_VERSION) {
102
+ return {
103
+ ok: false,
104
+ problem: `that file is version ${JSON.stringify(doc.version)}; this gu writes and reads version ${FILE_VERSION}. Update gu, or save it again from the machine that wrote it.`,
105
+ };
106
+ }
107
+ const rows = doc.codingBackends;
108
+ if (rows !== undefined && !Array.isArray(rows)) {
109
+ return { ok: false, problem: "codingBackends must be a list." };
110
+ }
111
+ const backends = [];
112
+ const seen = new Set();
113
+ for (const [i, r] of (rows ?? []).entries()) {
114
+ const at = `codingBackends[${i}]`;
115
+ if (!r || typeof r !== "object" || Array.isArray(r)) return { ok: false, problem: `${at} is not an object.` };
116
+ if (!KINDS.includes(r.kind)) {
117
+ return { ok: false, problem: `${at} has kind ${JSON.stringify(r.kind)} — expected one of ${KINDS.filter(Boolean).join(", ")}.` };
118
+ }
119
+ if (r.url !== undefined && r.url !== "" && !isHttpUrl(r.url)) {
120
+ return { ok: false, problem: `${at} has a url that is not http(s): ${JSON.stringify(r.url)}.` };
121
+ }
122
+ if (r.models !== undefined && (!Array.isArray(r.models) || !r.models.every(isStr))) {
123
+ return { ok: false, problem: `${at}.models must be a list of model names.` };
124
+ }
125
+ if (r.defaultModel !== undefined && !isStr(r.defaultModel)) {
126
+ return { ok: false, problem: `${at}.defaultModel must be text.` };
127
+ }
128
+ // "false" as a STRING is truthy — the exact trap the API's own settings writer documents.
129
+ if (r.enabled !== undefined && typeof r.enabled !== "boolean") {
130
+ return { ok: false, problem: `${at}.enabled must be true or false, not ${JSON.stringify(r.enabled)}.` };
131
+ }
132
+ const dup = `${r.kind}|${clean(r.url).toLowerCase()}`;
133
+ if (seen.has(dup)) return { ok: false, problem: `${at} repeats a provider already in the file (${r.kind} ${clean(r.url) || "no url"}).` };
134
+ seen.add(dup);
135
+ backends.push({
136
+ kind: r.kind,
137
+ url: clean(r.url),
138
+ defaultModel: clean(r.defaultModel),
139
+ models: (r.models ?? []).map((m) => m.trim()).filter(Boolean),
140
+ enabled: r.enabled !== false,
141
+ });
142
+ }
143
+ const am = doc.agentModel;
144
+ if (am !== undefined && (!am || typeof am !== "object" || Array.isArray(am))) {
145
+ return { ok: false, problem: "agentModel must be an object with a url and a name." };
146
+ }
147
+ if (am?.url !== undefined && am.url !== "" && !isHttpUrl(am.url)) {
148
+ return { ok: false, problem: `agentModel.url is not http(s): ${JSON.stringify(am.url)}.` };
149
+ }
150
+ if (am?.name !== undefined && !isStr(am.name)) {
151
+ return { ok: false, problem: "agentModel.name must be text." };
152
+ }
153
+ if (doc.keys !== undefined && !Array.isArray(doc.keys)) {
154
+ return { ok: false, problem: "keys must be a list." };
155
+ }
156
+ const keys = [];
157
+ for (const [i, k] of (doc.keys ?? []).entries()) {
158
+ const at = `keys[${i}]`;
159
+ if (!k || typeof k !== "object" || Array.isArray(k)) return { ok: false, problem: `${at} is not an object.` };
160
+ if (!clean(k.name)) return { ok: false, problem: `${at} has no name — a key is identified by its name.` };
161
+ if (k.key !== undefined && !isStr(k.key)) return { ok: false, problem: `${at}.key must be text.` };
162
+ if (k.backendUrl !== undefined && k.backendUrl !== "" && !isHttpUrl(k.backendUrl)) {
163
+ return { ok: false, problem: `${at}.backendUrl is not http(s): ${JSON.stringify(k.backendUrl)}.` };
164
+ }
165
+ keys.push({
166
+ name: clean(k.name),
167
+ tail: clean(k.tail),
168
+ ...(clean(k.backendUrl) ? { backendUrl: clean(k.backendUrl) } : {}),
169
+ // Present only when a person put it there by hand; gu never writes one.
170
+ ...(clean(k.key) ? { key: clean(k.key) } : {}),
171
+ });
172
+ }
173
+ return {
174
+ ok: true,
175
+ value: { backends, agentModel: { url: clean(am?.url), name: clean(am?.name) }, keys, savedAt: clean(doc.savedAt), savedOn: clean(doc.savedOn) },
176
+ // Loading a file someone hand-edited to carry live credentials is allowed, but it is not
177
+ // allowed to be quiet: that file is now a secret on disk.
178
+ carriesSecrets: keys.some((k) => k.key),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * What loading this file would do to the machine it is loaded on.
184
+ *
185
+ * REPLACES, DOES NOT MERGE. Two providers with the same URL are the same provider, and merging
186
+ * would leave a machine holding both the file's idea of a provider and its own — which is how a
187
+ * /model list ends up with two rows that look identical and behave differently. The plan says so
188
+ * plainly, because "replace" is the part a person needs to agree to.
189
+ */
190
+ export function planLoad(file, current = {}) {
191
+ const currentRows = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
192
+ const currentKeys = Array.isArray(current.keys) ? current.keys : [];
193
+ const have = new Set(currentKeys.map((k) => clean(k.name).toLowerCase()).filter(Boolean));
194
+ const missingKeys = file.keys.filter((k) => !have.has(k.name.toLowerCase()) && !k.key);
195
+ const lines = [];
196
+ lines.push(
197
+ file.backends.length
198
+ ? `${file.backends.length} coding provider${file.backends.length === 1 ? "" : "s"}${currentRows.length ? `, replacing the ${currentRows.length} on this account` : ""}:`
199
+ : "no coding providers in the file — the ones on this account stay as they are.",
200
+ );
201
+ for (const b of file.backends) {
202
+ const models = b.models.length ? `${b.models.length} model${b.models.length === 1 ? "" : "s"}` : "no models";
203
+ lines.push(` ${b.kind || "?"} ${b.url || "(no url)"} ${models}${b.defaultModel ? `, default ${b.defaultModel}` : ""}${b.enabled ? "" : " (disabled)"}`);
204
+ }
205
+ if (file.agentModel.url || file.agentModel.name) {
206
+ lines.push(`agent model: ${file.agentModel.name || "(server default)"}${file.agentModel.url ? ` at ${file.agentModel.url}` : ""}`);
207
+ }
208
+ if (missingKeys.length) {
209
+ lines.push(`${missingKeys.length} API key${missingKeys.length === 1 ? "" : "s"} this account does not have — gu will ask for ${missingKeys.length === 1 ? "it" : "them"} after:`);
210
+ for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
211
+ }
212
+ // The account default is NOT in the file and is not touched — said out loud, because someone
213
+ // loading "their models" onto a machine reasonably expects the coder to change too.
214
+ lines.push("your current coding model choice is not changed — pick one with /model.");
215
+ return { lines, missingKeys, replaces: currentRows.length, adds: file.backends.length };
216
+ }
217
+
218
+ /**
219
+ * The POST body for /api/worker/settings. Only fields a worker key may write.
220
+ *
221
+ * A PROVIDER THIS ACCOUNT ALREADY HAS KEEPS ITS ROW ID, and that is not a detail. An API key is
222
+ * bound to a row BY ID; a key whose row has gone is, in this codebase's own words, "ineligible
223
+ * everywhere AND filtered out of every picker" — it can never be selected, used or deleted again.
224
+ * Folder picks and job records name rows the same way. So loading a file onto a machine that
225
+ * already has api.openai.com must reuse that row rather than mint a second one: matched on
226
+ * kind + url, which is what makes two rows the same provider. Genuinely new rows carry no id and
227
+ * the API mints one, which is what keeps ids unique per account instead of imported from someone
228
+ * else's.
229
+ */
230
+ export function toSettingsPatch(file, current = {}) {
231
+ const patch = {};
232
+ const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
233
+ const idFor = (kind, url) =>
234
+ existing.find(
235
+ (r) => r?.id && r.kind === kind && clean(r.url).toLowerCase() === clean(url).toLowerCase(),
236
+ )?.id ?? "";
237
+ if (file.backends.length) {
238
+ patch.agentCodingBackends = file.backends.map((b) => {
239
+ const id = idFor(b.kind, b.url);
240
+ return {
241
+ ...(id ? { id } : {}),
242
+ kind: b.kind,
243
+ url: b.url,
244
+ defaultModel: b.defaultModel,
245
+ models: b.models,
246
+ enabled: b.enabled,
247
+ };
248
+ });
249
+ }
250
+ if (file.agentModel.url) patch.agentModelUrl = file.agentModel.url;
251
+ if (file.agentModel.name) patch.agentModelName = file.agentModel.name;
252
+ return patch;
253
+ }