@tiens.nguyen/gu-cli 1.0.872 → 1.0.874

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
@@ -543,6 +543,9 @@ const color256 = (n, s) => `\x1b[38;5;${n}m${s}\x1b[0m`;
543
543
  // WAIT_COLORS_256 colour only when this run was started with `gu --color`. White goes through
544
544
  // paint(), so NO_COLOR / TERM=dumb still turn it off.
545
545
  const waitPaint = (n, s) => (REPL_COLOR ? color256(n, s) : white(s));
546
+ // The live thought line above it (#245) follows the same switch: white by default, the accent
547
+ // cyan only with `gu --color`.
548
+ const thoughtPaint = (s) => (REPL_COLOR ? accent(s) : white(s));
546
549
  const orange = (s) => color256(208, s); // peak-token label (task #84)
547
550
  // Compact token count: 950 → "950", 12300 → "12.3k", 1500000 → "1.5M" (task #83).
548
551
  const fmtTokens = (n) => {
@@ -636,7 +639,7 @@ if (cliVerdict.kind === "run") {
636
639
  " -v, --verbose show RAG index-file activity in the bottom bar (each shard written)\n" +
637
640
  " --log log this run in detail (~/.gonext/worker.log). Without it only starts,\n" +
638
641
  " stops and errors are logged. Ends when this gu closes.\n" +
639
- " --color colour the thinking spinner and its playful word (cycling colours).\n" +
642
+ " --color colour the live thought, the thinking spinner and its playful word.\n" +
640
643
  " Without it they are plain white.\n" +
641
644
  " --name <text> show <text> instead of the gu logo in the startup banner\n" +
642
645
  " (this run only; letters, digits, space - _ are drawn as art)\n" +
@@ -1038,6 +1041,7 @@ const COMMANDS = [
1038
1041
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
1039
1042
  { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
1040
1043
  { name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
1044
+ { name: "/skills-copy-to-claude", usage: "/skills-copy-to-claude [--dry-run] [name…]", desc: "copy gu's skills (.gonext) into Claude Code's folders (.claude), renamed to Claude's names, with their MCP servers" },
1041
1045
  { name: "/help", desc: "list these commands" },
1042
1046
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
1043
1047
  ];
@@ -6232,7 +6236,7 @@ async function pickExtraModels(rest, preChecked = []) {
6232
6236
  *
6233
6237
  * Returns the model id, or null when the user cancelled.
6234
6238
  */
6235
- async function chooseCodingModelFrom(ps, all, question, current) {
6239
+ async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
6236
6240
  const { models: coders, filtered } = ps.codingModelCandidates(all);
6237
6241
  let list = coders;
6238
6242
  let showingAll = filtered === 0;
@@ -6247,7 +6251,8 @@ async function chooseCodingModelFrom(ps, all, question, current) {
6247
6251
  dim(` ${question} (${PICK_HINT})`) +
6248
6252
  (showingAll || !filtered ? "" : dim(` · ${filtered} non-coding models hidden`))
6249
6253
  );
6250
- const idx = await pickFromList(rows, at >= 0 ? at : 0);
6254
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6255
+ if (idx === PICK_BACK) return BACK;
6251
6256
  if (idx < 0) return null;
6252
6257
  if (idx === list.length) {
6253
6258
  const typed = (await ask(" model name: ")).trim();
@@ -6275,11 +6280,12 @@ async function chooseCodingModelFrom(ps, all, question, current) {
6275
6280
  * stays available for the same reason it does there — a listed model may be unusable and an
6276
6281
  * unlisted one may be exactly right.
6277
6282
  */
6278
- async function chooseFromModelList(all, question, current = 0) {
6283
+ async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6279
6284
  const rows = [...all, "type a model name…"];
6280
6285
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6281
6286
  console.log(dim(` ${question} (${PICK_HINT})`));
6282
- const idx = await pickFromList(rows, at >= 0 ? at : 0);
6287
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6288
+ if (idx === PICK_BACK) return BACK;
6283
6289
  if (idx < 0) return null;
6284
6290
  if (idx === all.length) {
6285
6291
  const typed = (await ask(" model name: ")).trim();
@@ -6288,51 +6294,65 @@ async function chooseFromModelList(all, question, current = 0) {
6288
6294
  return all[idx];
6289
6295
  }
6290
6296
 
6291
- async function manageCodingProviders() {
6297
+ /**
6298
+ * /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
6299
+ * "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
6300
+ * step backed out of saves nothing but the list is cheap to trust fresh.
6301
+ */
6302
+ async function manageCodingProviders({ back = false } = {}) {
6292
6303
  const ps = await import("./coding-provider-setup.mjs");
6293
- const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
6294
- if (!settings) {
6295
- console.log(red(" couldn't reach the API to read your current settings.\n"));
6296
- return;
6297
- }
6298
- const rows = ps.existingBackendRows(settings);
6299
- const providers = ps.manageableProviders(rows);
6300
- // NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
6301
- if (providers.length === 0) {
6302
- await addServer({ role: "coding", dialect: "openai" });
6303
- return;
6304
- }
6304
+ let selAt = 0;
6305
+ for (;;) {
6306
+ const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
6307
+ if (!settings) {
6308
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
6309
+ return;
6310
+ }
6311
+ const rows = ps.existingBackendRows(settings);
6312
+ const providers = ps.manageableProviders(rows);
6313
+ // NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
6314
+ if (providers.length === 0) return addServer({ role: "coding", dialect: "openai", back });
6305
6315
 
6306
- const labels = providers.map(
6307
- (p) =>
6308
- `${p.model || dim("(no model set)")}${dim(` · ${p.host}`)}${p.enabled ? "" : dim(" · off")}`
6309
- );
6310
- const ADD = labels.length;
6311
- labels.push("add another provider — Claude API, OpenAI, Kimi…");
6312
- console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6313
- const idx = await pickFromList(labels, 0);
6314
- if (idx < 0) {
6315
- console.log(dim(" (cancelled)\n"));
6316
- return;
6317
- }
6318
- if (idx === ADD) {
6319
- await addServer({ role: "coding", dialect: "openai" });
6320
- return;
6321
- }
6316
+ const labels = providers.map(
6317
+ (p) =>
6318
+ `${p.model || dim("(no model set)")}${dim(` · ${p.host}`)}${p.enabled ? "" : dim(" · off")}`
6319
+ );
6320
+ const ADD = labels.length;
6321
+ labels.push("add another provider — Claude API, OpenAI, Kimi…");
6322
+ console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6323
+ const idx = await pickFromList(labels, selAt, { back });
6324
+ if (idx === PICK_BACK) return BACK;
6325
+ if (idx < 0) {
6326
+ console.log(dim(" (cancelled)\n"));
6327
+ return;
6328
+ }
6329
+ selAt = idx;
6330
+ if (idx === ADD) {
6331
+ if ((await addServer({ role: "coding", dialect: "openai", back: true })) === BACK) continue;
6332
+ return;
6333
+ }
6322
6334
 
6323
- const p = providers[idx];
6324
- console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6325
- const action = await pickFromList(
6326
- [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6327
- 1
6328
- );
6329
- if (action <= 0) {
6330
- console.log(dim(" (nothing changed)\n"));
6331
- return;
6335
+ const p = providers[idx];
6336
+ for (;;) {
6337
+ console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6338
+ const action = await pickFromList(
6339
+ [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6340
+ 1,
6341
+ { back: true }
6342
+ );
6343
+ if (action === PICK_BACK) break; // → the provider list
6344
+ if (action <= 0) {
6345
+ console.log(dim(" (nothing changed)\n"));
6346
+ return;
6347
+ }
6348
+ let r;
6349
+ if (action === 1) r = await changeProviderModel(ps, rows, p);
6350
+ else if (action === 2) r = await changeProviderUrl(ps, rows, p);
6351
+ else r = await removeCodingProvider(ps, rows, p);
6352
+ if (r === BACK) continue; // → this provider's actions
6353
+ return;
6354
+ }
6332
6355
  }
6333
- if (action === 1) await changeProviderModel(ps, rows, p);
6334
- else if (action === 2) await changeProviderUrl(ps, rows, p);
6335
- else await removeCodingProvider(ps, rows, p);
6336
6356
  }
6337
6357
 
6338
6358
  /** Point a provider at a different model. */
@@ -6353,9 +6373,14 @@ async function changeProviderModel(ps, rows, p) {
6353
6373
  const r = await fetchCodingKey(p.id);
6354
6374
  return r.keyId && r.keyId !== inUse.id ? { key: "", name: "" } : { key: r.key, name: String(inUse.name ?? "") };
6355
6375
  });
6376
+ // AN OLLAMA ROW (#243) lists through Ollama's own /api/tags, the way it was added.
6377
+ const as = p.dialect === "ollama" ? await import("./add-server.mjs") : null;
6378
+ const ollamaTarget = as ? as.serverTarget("ollama", p.url) : null;
6356
6379
  const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
6357
6380
  try {
6358
- const lr = ps.modelListRequest(p.url, own.key); // the list's own dialect too (Claude's headers, #261)
6381
+ const lr = ollamaTarget?.ok
6382
+ ? as.listRequest(ollamaTarget, own.key)
6383
+ : ps.modelListRequest(p.url, own.key); // the list's own dialect too (Claude's headers, #261)
6359
6384
  const res = await proxyFetch(lr.url, { headers: lr.headers, signal: AbortSignal.timeout(10_000) });
6360
6385
  if (!res.ok) return { status: res.status };
6361
6386
  return { body: await res.json().catch(() => null) };
@@ -6365,7 +6390,7 @@ async function changeProviderModel(ps, rows, p) {
6365
6390
  });
6366
6391
  let models = [];
6367
6392
  if (!probe.status && !probe.err) {
6368
- const parsed = ps.parseProviderModels(probe.body);
6393
+ const parsed = ollamaTarget?.ok ? as.modelsForRole("coding", "ollama", probe.body) : ps.parseProviderModels(probe.body);
6369
6394
  // The CURRENT model stays in the list: after switching to another one it becomes a perfectly
6370
6395
  // good extra, and filtering it out here would make it unofferable without a second visit.
6371
6396
  models = parsed.models;
@@ -6384,7 +6409,8 @@ async function changeProviderModel(ps, rows, p) {
6384
6409
 
6385
6410
  let model = "";
6386
6411
  if (models.length) {
6387
- model = await chooseCodingModelFrom(ps, models, `Which model should ${p.host} use?`, p.model);
6412
+ model = await chooseCodingModelFrom(ps, models, `Which model should ${p.host} use?`, p.model, { back: true });
6413
+ if (model === BACK) return BACK;
6388
6414
  if (model === null) {
6389
6415
  console.log(dim(" (nothing changed)\n"));
6390
6416
  return;
@@ -6437,10 +6463,13 @@ async function changeProviderUrl(ps, rows, p) {
6437
6463
  // THE SAME PRESET LIST AS ADDING (#132), OpenAI-compatible ones only: an Ollama row has another
6438
6464
  // kind, and this flow keeps the row's id and kind. "type a URL…" is the default row, so Enter
6439
6465
  // still gets the old free-text question.
6440
- const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === "openai");
6466
+ // An Ollama row (#243) is offered the Ollama presets (Ollama cloud) and keeps its kind too.
6467
+ const dialect = p.dialect === "ollama" ? "ollama" : "openai";
6468
+ const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === dialect);
6441
6469
  const urlRows = [...urlPresets.map((x) => `${x.name}${dim(` · ${x.url}`)}`), "type a URL…"];
6442
6470
  console.log("\n" + GUTTER + dim(`Which provider? (${PICK_HINT})`));
6443
- const upi = await pickFromList(urlRows, urlRows.length - 1);
6471
+ const upi = await pickFromList(urlRows, urlRows.length - 1, { back: true });
6472
+ if (upi === PICK_BACK) return BACK;
6444
6473
  if (upi < 0) {
6445
6474
  console.log(GUTTER + dim("(nothing changed)\n"));
6446
6475
  return;
@@ -6456,11 +6485,27 @@ async function changeProviderUrl(ps, rows, p) {
6456
6485
  console.log(GUTTER + dim("(nothing changed)\n"));
6457
6486
  return;
6458
6487
  }
6459
- const r = ps.normalizeProviderUrl(typed);
6460
- if (!r.ok) {
6461
- for (const l of ps.describeUrlProblem(r.why, typed)) console.log(GUTTER + dim(l));
6462
- console.log(GUTTER + dim("(nothing changed)\n"));
6463
- return;
6488
+ let r;
6489
+ if (dialect === "ollama") {
6490
+ const as = await import("./add-server.mjs");
6491
+ const t = as.serverTarget("ollama", typed);
6492
+ r = t.ok ? { ok: true, url: t.url, host: t.host } : { ok: false, why: t.why };
6493
+ if (!r.ok) {
6494
+ console.log(GUTTER + dim(`"${typed}" is ${r.why} — try ollama.com, or host:port such as 192.168.1.5:11434`));
6495
+ console.log(GUTTER + dim("(nothing changed)\n"));
6496
+ return;
6497
+ }
6498
+ if (r.url === p.url) {
6499
+ console.log(GUTTER + dim("(nothing changed)\n"));
6500
+ return;
6501
+ }
6502
+ } else {
6503
+ r = ps.normalizeProviderUrl(typed);
6504
+ if (!r.ok) {
6505
+ for (const l of ps.describeUrlProblem(r.why, typed)) console.log(GUTTER + dim(l));
6506
+ console.log(GUTTER + dim("(nothing changed)\n"));
6507
+ return;
6508
+ }
6464
6509
  }
6465
6510
  const clash = rows.find(
6466
6511
  (b) => ps.ensureV1Root(String(b.url ?? "")) === r.url && !ps.manageableProviders([b]).some((x) => x.id === p.id)
@@ -6569,7 +6614,7 @@ async function removeCodingProvider(ps, rows, p) {
6569
6614
  * model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
6570
6615
  * newKey ask for a key even if the server has one (the stored one was refused)
6571
6616
  */
6572
- async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
6617
+ async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
6573
6618
  const as = await import("./add-server.mjs");
6574
6619
  const ps = await import("./coding-provider-setup.mjs");
6575
6620
  const sk = await import("./server-keys.mjs");
@@ -6591,166 +6636,190 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6591
6636
  ? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
6592
6637
  : null;
6593
6638
 
6594
- // --- 1. the server --------------------------------------------------------------------
6595
- let target = null;
6596
- if (presetUrl) {
6597
- target = as.serverTarget(dialect, presetUrl);
6598
- if (!target.ok) {
6599
- console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
6600
- return;
6639
+ // STEPS 1–4 ARE ONE LOOP (#242): "← back" on the model list returns to "Which provider?", and
6640
+ // "← back" there returns BACK to the menu that opened this (with `back`). Nothing is saved
6641
+ // before step 5, so going back leaves nothing behind — a key typed on the way is dropped.
6642
+ const providerPicker = !presetUrl && dialect === "openai";
6643
+ const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6644
+ let target, host, found, need, key, keyName, typedKey, models, model;
6645
+ for (;;) {
6646
+ // --- 1. the server --------------------------------------------------------------------
6647
+ target = null;
6648
+ // The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
6649
+ let askDialect = dialect;
6650
+ if (presetUrl) {
6651
+ target = as.serverTarget(dialect, presetUrl);
6652
+ if (!target.ok) {
6653
+ console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
6654
+ return;
6655
+ }
6656
+ } else if (dialect === "openai") {
6657
+ // THE HOSTED PROVIDERS BY NAME (#261): Claude, OpenAI, Kimi — then any other URL. One picker in
6658
+ // the one add flow, so every role that adds a provider offers the same list (the embedder
6659
+ // without Claude, which serves no embeddings).
6660
+ const presets = ps.providerPresets(role);
6661
+ // #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
6662
+ // menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
6663
+ const OLLAMA_HERE = presets.length;
6664
+ const rowsShown = [
6665
+ ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6666
+ `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6667
+ "another URL…",
6668
+ ];
6669
+ // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6670
+ console.log(dim(` Which provider? (${PICK_HINT})`));
6671
+ const pi = await pickFromList(rowsShown, 0, { back });
6672
+ if (pi === PICK_BACK) return BACK;
6673
+ if (pi < 0) {
6674
+ console.log(dim(" (nothing saved)\n"));
6675
+ return;
6676
+ }
6677
+ if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6678
+ else if (pi === OLLAMA_HERE) askDialect = "ollama";
6679
+ }
6680
+ if (!target && !presetUrl) {
6681
+ const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
6682
+ console.log(dim(askDialect === "ollama"
6683
+ ? " An Ollama server: this machine, a box on your network, or ollama.com (with a key)."
6684
+ : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
6685
+ // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
6686
+ console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
6687
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
6688
+ const typed = await ask(" server URL: ");
6689
+ if (typed === null || as.isCancelWord(typed)) break;
6690
+ const raw = typed.trim() || dflt;
6691
+ if (!raw) break;
6692
+ const t = as.serverTarget(askDialect, raw);
6693
+ if (!t.ok) {
6694
+ if (askDialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
6695
+ else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
6696
+ continue;
6697
+ }
6698
+ // SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
6699
+ // guess shows up later as a timeout that reads like a dead server.
6700
+ if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
6701
+ target = t;
6702
+ }
6601
6703
  }
6602
- } else if (dialect === "openai") {
6603
- // THE HOSTED PROVIDERS BY NAME (#261): Claude, OpenAI, Kimi — then any other URL. One picker in
6604
- // the one add flow, so every role that adds a provider offers the same list (the embedder
6605
- // without Claude, which serves no embeddings).
6606
- const presets = ps.providerPresets(role);
6607
- const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
6608
- // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6609
- console.log(dim(` Which provider? (${PICK_HINT})`));
6610
- const pi = await pickFromList(rowsShown, 0);
6611
- if (pi < 0) {
6704
+ if (!target) {
6612
6705
  console.log(dim(" (nothing saved)\n"));
6613
6706
  return;
6614
6707
  }
6615
- if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6616
- }
6617
- if (!target && !presetUrl) {
6618
- const dflt = as.defaultServerUrl(role, dialect, settings, existing);
6619
- console.log(dim(dialect === "ollama"
6620
- ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
6621
- : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
6622
- // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
6623
- console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
6624
- for (let attempt = 0; attempt < 3 && !target; attempt++) {
6625
- const typed = await ask(" server URL: ");
6626
- if (typed === null || as.isCancelWord(typed)) break;
6627
- const raw = typed.trim() || dflt;
6628
- if (!raw) break;
6629
- const t = as.serverTarget(dialect, raw);
6630
- if (!t.ok) {
6631
- if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
6632
- else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
6633
- continue;
6634
- }
6635
- // SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
6636
- // guess shows up later as a timeout that reads like a dead server.
6637
- if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
6638
- target = t;
6708
+ host = target.host;
6709
+ const refusal = as.roleRefusal(role, target);
6710
+ if (refusal.length) {
6711
+ for (const l of refusal) console.log(GUTTER + yellow(l));
6712
+ console.log("");
6713
+ return;
6714
+ }
6715
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
6716
+ if (target.claude && role === "coding") {
6717
+ await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
6639
6718
  }
6640
- }
6641
- if (!target) {
6642
- console.log(dim(" (nothing saved)\n"));
6643
- return;
6644
- }
6645
- const host = target.host;
6646
- const refusal = as.roleRefusal(role, target);
6647
- if (refusal.length) {
6648
- for (const l of refusal) console.log(GUTTER + yellow(l));
6649
- console.log("");
6650
- return;
6651
- }
6652
- // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
6653
- if (target.claude && role === "coding") {
6654
- await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
6655
- }
6656
-
6657
- // --- 2. the server's key --------------------------------------------------------------
6658
- // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
6659
- // entries identical in everything a person can see) — its keys come with it.
6660
- const found = ps.providerRowForUrl(existing, target.url);
6661
- // THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
6662
- // and asking for a key and a model first would be the worst order to discover that in.
6663
- if (!found && existing.length >= ps.MAX_BACKENDS) {
6664
- console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
6665
- return;
6666
- }
6667
- const need = as.keyRequirement(target);
6668
- let key = "";
6669
- let keyName = "";
6670
- let typedKey = false;
6671
- if (found && !newKey) {
6672
- // A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
6673
- const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
6674
- const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
6675
- const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
6676
- if (inUse && inUse.backendId === found.id) {
6677
- key = (await fetchCodingKey(found.id)).key;
6678
- if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6679
- }
6680
- }
6681
- const askKey = async () => {
6682
- const t = await askServerKey({ host, optional: need === "optional" });
6683
- key = t.secret;
6684
- keyName = t.name;
6685
- typedKey = Boolean(t.secret);
6686
- return t.secret || need !== "required";
6687
- };
6688
- if (!key && (need !== "none" || newKey)) {
6689
- if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6690
- if (!(await askKey())) return;
6691
- }
6692
6719
 
6693
- // --- 3. its models, listed with that key ----------------------------------------------
6694
- let models = [];
6695
- if (!presetModel) {
6696
- const list = () =>
6697
- withPendingStatus(`asking ${host} what it has`, async () => {
6698
- const req = as.listRequest(target, key);
6699
- try {
6700
- const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
6701
- return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6702
- } catch (err) {
6703
- return { err };
6720
+ // --- 2. the server's key --------------------------------------------------------------
6721
+ // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
6722
+ // entries identical in everything a person can see) — its keys come with it.
6723
+ found = ps.providerRowForUrl(existing, target.url);
6724
+ // THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
6725
+ // and asking for a key and a model first would be the worst order to discover that in.
6726
+ if (!found && existing.length >= ps.MAX_BACKENDS) {
6727
+ console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
6728
+ return;
6729
+ }
6730
+ need = as.keyRequirement(target);
6731
+ key = "";
6732
+ keyName = "";
6733
+ typedKey = false;
6734
+ if (found && !newKey) {
6735
+ // A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
6736
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
6737
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
6738
+ const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
6739
+ if (inUse && inUse.backendId === found.id) {
6740
+ key = (await fetchCodingKey(found.id)).key;
6741
+ if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6742
+ }
6743
+ }
6744
+ const askKey = async () => {
6745
+ const t = await askServerKey({ host, optional: need === "optional" });
6746
+ key = t.secret;
6747
+ keyName = t.name;
6748
+ typedKey = Boolean(t.secret);
6749
+ return t.secret || need !== "required";
6750
+ };
6751
+ if (!key && (need !== "none" || newKey)) {
6752
+ if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6753
+ if (!(await askKey())) return;
6754
+ }
6755
+
6756
+ // --- 3. its models, listed with that key ----------------------------------------------
6757
+ models = [];
6758
+ if (!presetModel) {
6759
+ const list = () =>
6760
+ withPendingStatus(`asking ${host} what it has`, async () => {
6761
+ const req = as.listRequest(target, key);
6762
+ try {
6763
+ const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
6764
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6765
+ } catch (err) {
6766
+ return { err };
6767
+ }
6768
+ });
6769
+ let probe = await list();
6770
+ // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
6771
+ if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
6772
+ console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
6773
+ if (!(await askKey()) || !key) return;
6774
+ probe = await list();
6775
+ }
6776
+ if (probe.status || probe.err) {
6777
+ const d = ps.describeProbeFailure(probe);
6778
+ for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
6779
+ // FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
6780
+ // a problem for later. Anything else is survivable — the model id can be typed.
6781
+ if (d.fatal) {
6782
+ console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
6783
+ return;
6704
6784
  }
6705
- });
6706
- let probe = await list();
6707
- // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
6708
- if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
6709
- console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
6710
- if (!(await askKey()) || !key) return;
6711
- probe = await list();
6712
- }
6713
- if (probe.status || probe.err) {
6714
- const d = ps.describeProbeFailure(probe);
6715
- for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
6716
- // FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
6717
- // a problem for later. Anything else is survivable — the model id can be typed.
6718
- if (d.fatal) {
6719
- console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
6720
- return;
6785
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6786
+ } else {
6787
+ const parsed = as.modelsForRole(role, target.dialect, probe.body);
6788
+ models = parsed.models;
6789
+ for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
6790
+ const rn = ps.retiredNote(parsed.retired, host);
6791
+ if (rn) console.log(GUTTER + dim(rn));
6792
+ if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
6721
6793
  }
6722
- console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6723
- } else {
6724
- const parsed = as.modelsForRole(role, target.dialect, probe.body);
6725
- models = parsed.models;
6726
- for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
6727
- const rn = ps.retiredNote(parsed.retired, host);
6728
- if (rn) console.log(GUTTER + dim(rn));
6729
- if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
6730
6794
  }
6731
- }
6732
6795
 
6733
- // --- 4. the model ---------------------------------------------------------------------
6734
- let model = presetModel;
6735
- const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6736
- if (!model) {
6737
- if (models.length) {
6738
- const am = await import("./agent-model-command.mjs");
6739
- const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
6740
- model = R.filterCoders
6741
- ? await chooseCodingModelFrom(ps, models, R.question, 0)
6742
- : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)));
6743
- if (model === null) {
6744
- console.log(dim(" (cancelled — nothing was saved)\n"));
6796
+ // --- 4. the model ---------------------------------------------------------------------
6797
+ model = presetModel;
6798
+ if (!model) {
6799
+ if (models.length) {
6800
+ const am = await import("./agent-model-command.mjs");
6801
+ const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
6802
+ const canBack = { back: providerPicker || back };
6803
+ model = R.filterCoders
6804
+ ? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
6805
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
6806
+ if (model === BACK) {
6807
+ if (providerPicker) continue; // → Which provider?
6808
+ return BACK;
6809
+ }
6810
+ if (model === null) {
6811
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6812
+ return;
6813
+ }
6814
+ } else {
6815
+ model = String((await ask(" model name: ")) ?? "").trim();
6816
+ }
6817
+ if (!model) {
6818
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6745
6819
  return;
6746
6820
  }
6747
- } else {
6748
- model = String((await ask(" model name: ")) ?? "").trim();
6749
- }
6750
- if (!model) {
6751
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6752
- return;
6753
6821
  }
6822
+ break;
6754
6823
  }
6755
6824
  // THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
6756
6825
  // that stop working are named BEFORE anything is saved. The same model is a pure server move.
@@ -7023,8 +7092,8 @@ async function chooseModel() {
7023
7092
  [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7024
7093
  1
7025
7094
  );
7026
- if (go === 1) await manageCodingProviders();
7027
- else console.log("");
7095
+ if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7096
+ if (go !== 1) console.log("");
7028
7097
  return;
7029
7098
  }
7030
7099
  // Shown even with ONE model: "only one is available" often means the wrong server, and the
@@ -7037,8 +7106,8 @@ async function chooseModel() {
7037
7106
  [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7038
7107
  0
7039
7108
  );
7040
- if (go === 1) await manageCodingProviders();
7041
- else console.log("");
7109
+ if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7110
+ if (go !== 1) console.log("");
7042
7111
  return;
7043
7112
  }
7044
7113
  const active = sessionCodingModel || def;
@@ -7073,7 +7142,7 @@ async function chooseModel() {
7073
7142
  }
7074
7143
  // The one appended entry sits past the last model.
7075
7144
  if (idx >= choices.length) {
7076
- await manageCodingProviders();
7145
+ if ((await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7077
7146
  return;
7078
7147
  }
7079
7148
  await applyChosenCoder(choices[idx], { def, tag: showTag ? tags[idx] : "" });
@@ -7619,6 +7688,91 @@ async function runSwitchSkillCommand(line) {
7619
7688
  if (barPinned()) paintBar();
7620
7689
  }
7621
7690
 
7691
+ /**
7692
+ * /skills-copy-to-claude [--dry-run] [name…] — gu's skills into Claude Code's folders (#241):
7693
+ * ~/.gonext/skills → ~/.claude/skills and ./.gonext/skills → ./.claude/skills, "gu-" names become
7694
+ * "claude-", and a skill's MCP server goes into ~/.claude.json (skills-copy.mjs). Shows the plan,
7695
+ * then asks; a Claude skill that differs is kept unless the user picks "replace" (backed up).
7696
+ */
7697
+ async function runSkillsCopyCommand(line) {
7698
+ const sc = await import("./skills-copy.mjs");
7699
+ const words = line.trim().split(/\s+/).slice(1);
7700
+ const dryRun = words.includes("--dry-run");
7701
+ const names = words.filter((w) => !w.startsWith("--"));
7702
+ const home = homedir();
7703
+ const cwd = resolve(process.cwd());
7704
+ const tilde = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : p.startsWith(cwd) ? "." + p.slice(cwd.length) : p);
7705
+ const say = (l) => console.log(GUTTER + l);
7706
+ console.log("");
7707
+ let plan;
7708
+ try {
7709
+ plan = sc.planSkillsCopy({ cwd, home, names });
7710
+ } catch (err) {
7711
+ say(red(`couldn't read gu's skills — ${err.message}\n`));
7712
+ return;
7713
+ }
7714
+ for (const w of plan.warnings) say(yellow(w));
7715
+ if (plan.missing.length) say(yellow(`gu has no skill named ${plan.missing.join(", ")}`));
7716
+ if (!plan.items.length) {
7717
+ say(dim("gu has no skills to copy — ~/.gonext/skills and ./.gonext/skills have none.\n"));
7718
+ return;
7719
+ }
7720
+ const word = { new: accent("new"), same: dim("already there"), differs: yellow("Claude's differs") };
7721
+ say(bold("Copy gu's skills to Claude Code"));
7722
+ for (const it of plan.items) {
7723
+ const renamed = it.newName !== it.name ? dim(` (was ${it.name})`) : "";
7724
+ say(` ${it.newName}${renamed} ${word[it.status]}`);
7725
+ say(dim(` → ${tilde(it.to)}`));
7726
+ for (const s of it.servers) {
7727
+ say(dim(` MCP server ${s.name} → ~/.claude.json${s.scope === "local" ? " (this folder)" : ""} `) + word[s.status]);
7728
+ }
7729
+ if (it.guLines.length) {
7730
+ say(yellow(` SKILL.md line${it.guLines.length === 1 ? "" : "s"} ${it.guLines.join(", ")} still name${it.guLines.length === 1 ? "s" : ""} ~/.gonext — check the copy`));
7731
+ }
7732
+ }
7733
+ const todo = plan.items.filter((i) => i.status !== "same" || i.servers.some((s) => s.status !== "same"));
7734
+ const differs = plan.items.filter((i) => i.status === "differs" || i.servers.some((s) => s.status === "differs"));
7735
+ if (!todo.length) {
7736
+ say(dim("\n Nothing to copy — Claude Code already has all of them.\n"));
7737
+ return;
7738
+ }
7739
+ if (dryRun) {
7740
+ say(dim("\n --dry-run: nothing was written.\n"));
7741
+ return;
7742
+ }
7743
+ const fresh = todo.length - differs.length;
7744
+ const options = differs.length
7745
+ ? [
7746
+ ...(fresh ? [`Copy the ${fresh} new — keep Claude's own where it differs`] : []),
7747
+ `Copy all — replace Claude's ${differs.length} (old one${differs.length === 1 ? "" : "s"} backed up)`,
7748
+ "Cancel",
7749
+ ]
7750
+ : [`Copy ${todo.length === 1 ? "it" : `all ${todo.length}`}`, "Cancel"];
7751
+ console.log(dim(`\n ${PICK_HINT}:`));
7752
+ const idx = await pickFromList(options, 0);
7753
+ if (idx < 0 || options[idx] === "Cancel") {
7754
+ console.log(dim(" (cancelled — nothing copied)\n"));
7755
+ paintBar();
7756
+ return;
7757
+ }
7758
+ const replace = options[idx].startsWith("Copy all — replace");
7759
+ const r = sc.applySkillsCopy(plan, { replace, cwd, home });
7760
+ const verb = { copied: "copied", replaced: "replaced", kept: "kept Claude's", already: "already there" };
7761
+ for (const d of r.done) {
7762
+ const mark = d.action === "copied" || d.action === "replaced" ? accent("✓") : dim("·");
7763
+ say(`${mark} ${d.newName} ${dim(verb[d.action] + (d.action === "copied" || d.action === "replaced" ? " → " + tilde(d.to) : ""))}`);
7764
+ if (d.backup) say(dim(` Claude's old one: ${tilde(d.backup)}`));
7765
+ }
7766
+ for (const s of r.servers) {
7767
+ if (s.action === "copied" || s.action === "replaced") say(`${accent("✓")} MCP server ${s.name} ${dim(verb[s.action] + " → ~/.claude.json")}`);
7768
+ }
7769
+ for (const e of r.errors) say(red(`✗ ${e}`));
7770
+ say(dim(" A new Claude Code session loads them."));
7771
+ say(dim(" In gu, /switch-skill claude makes this folder use them too."));
7772
+ console.log("");
7773
+ if (barPinned()) paintBar();
7774
+ }
7775
+
7622
7776
  /**
7623
7777
  * /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
7624
7778
  * run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
@@ -8594,13 +8748,14 @@ async function confirmEmbedSwitch(ec, current, chosen) {
8594
8748
 
8595
8749
  // WHICH EMBEDDER, ASKED ONCE. The model sub-flow of /embed-model: the curated picker, then the
8596
8750
  // shared recognition step. Returns { changed, chosen, plan }.
8597
- async function pickEmbedModel(ec, current) {
8751
+ async function pickEmbedModel(ec, current, { back = false } = {}) {
8598
8752
  const rows = ec.embedChoiceRows(current);
8599
8753
  console.log(dim(` Choose the embedder (${PICK_HINT}):`));
8600
8754
  const labels = rows.map(
8601
8755
  (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` + (r.current ? accent(" (current)") : "")
8602
8756
  );
8603
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
8757
+ const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back });
8758
+ if (idx === PICK_BACK) return BACK;
8604
8759
  if (idx < 0) {
8605
8760
  console.log(dim(" (cancelled)\n"));
8606
8761
  return { changed: false, chosen: "", plan: null };
@@ -8654,14 +8809,19 @@ async function chooseEmbedModel() {
8654
8809
  // EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
8655
8810
  // /model and /agent-model. The curated arm has already chosen the model (and passed the
8656
8811
  // recognition wall), so it only asks WHERE; the other two list what the server has.
8812
+ // "← back" in any arm returns to this menu (#242).
8657
8813
  if (action === "model") {
8658
- const { changed, chosen, plan } = await pickEmbedModel(ec, current);
8814
+ const picked = await pickEmbedModel(ec, current, { back: true });
8815
+ if (picked === BACK) return chooseEmbedModel();
8816
+ const { changed, chosen, plan } = picked;
8659
8817
  if (!changed) return;
8660
8818
  await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
8661
8819
  return;
8662
8820
  }
8663
- if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
8664
- if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
8821
+ if (action === "provider") {
8822
+ if ((await addServer({ role: "embed", dialect: "openai", back: true })) === BACK) return chooseEmbedModel();
8823
+ return;
8824
+ }
8665
8825
 
8666
8826
  if (action === "key") {
8667
8827
  // THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
@@ -8674,7 +8834,7 @@ async function chooseEmbedModel() {
8674
8834
  await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
8675
8835
  return;
8676
8836
  } else {
8677
- await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
8837
+ if ((await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current, back: true })) === BACK) return chooseEmbedModel();
8678
8838
  }
8679
8839
  // The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
8680
8840
  if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
@@ -8758,7 +8918,7 @@ async function enterAgentProviderKey(row, host) {
8758
8918
  /**
8759
8919
  * #44: switch the agent to a provider already saved on this machine, or add a new one.
8760
8920
  */
8761
- async function chooseAgentProvider() {
8921
+ async function chooseAgentProvider({ back = false } = {}) {
8762
8922
  const am = await import("./agent-model-command.mjs");
8763
8923
  const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
8764
8924
  if (!settings) {
@@ -8766,17 +8926,25 @@ async function chooseAgentProvider() {
8766
8926
  return;
8767
8927
  }
8768
8928
  const choices = am.agentProviderChoices(settings);
8769
- if (choices.length === 1) return addServer({ role: "agent", dialect: "openai" });
8770
- console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
8771
- const idx = await pickFromList(choices.map((c) => c.label), 0);
8772
- if (idx < 0) {
8773
- console.log(dim(" (cancelled)\n"));
8774
- return;
8929
+ if (choices.length === 1) return addServer({ role: "agent", dialect: "openai", back });
8930
+ let at = 0;
8931
+ for (;;) {
8932
+ console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
8933
+ const idx = await pickFromList(choices.map((c) => c.label), at, { back });
8934
+ if (idx === PICK_BACK) return BACK;
8935
+ if (idx < 0) {
8936
+ console.log(dim(" (cancelled)\n"));
8937
+ return;
8938
+ }
8939
+ at = idx;
8940
+ const pick = choices[idx];
8941
+ // "← back" inside either screen returns to this list (#242).
8942
+ const r = pick.action === "add"
8943
+ ? await addServer({ role: "agent", dialect: "openai", back: true })
8944
+ // A server already set up: the same screen, URL known — its key is reused, its models listed.
8945
+ : await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? ""), back: true });
8946
+ if (r !== BACK) return;
8775
8947
  }
8776
- const pick = choices[idx];
8777
- if (pick.action === "add") return addServer({ role: "agent", dialect: "openai" });
8778
- // A server already set up: the same screen, URL known — its key is reused, its models listed.
8779
- await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? "") });
8780
8948
  }
8781
8949
 
8782
8950
  /**
@@ -8869,7 +9037,6 @@ async function chooseAgentModel() {
8869
9037
  );
8870
9038
  const action = go >= 0 ? options[go].action : "leave";
8871
9039
  if (action === "key") await enterAgentProviderKey(source.row, listHost);
8872
- else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
8873
9040
  else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
8874
9041
  else console.log("");
8875
9042
  return;
@@ -8886,13 +9053,17 @@ async function chooseAgentModel() {
8886
9053
  console.log(dim(" (cancelled)\n"));
8887
9054
  return;
8888
9055
  }
8889
- if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
8890
- if (firstAction === "provider") return chooseAgentProvider();
9056
+ // "← back" in any of these returns to this menu (#242) — read again, as on a first open.
9057
+ if (firstAction === "provider") {
9058
+ if ((await chooseAgentProvider({ back: true })) === BACK) return chooseAgentModel();
9059
+ return;
9060
+ }
8891
9061
  if (firstAction === "key") {
8892
9062
  // Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
8893
9063
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
8894
9064
  if (!rowId) return enterAgentProviderKey(source.row, listHost);
8895
- await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
9065
+ const r = await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings), back: true });
9066
+ if (r === BACK) return chooseAgentModel();
8896
9067
  return;
8897
9068
  }
8898
9069
 
@@ -8924,7 +9095,8 @@ async function chooseAgentModel() {
8924
9095
  });
8925
9096
  // No other entries here any more (#44): the ways to change the provider are on the menu above,
8926
9097
  // where they can be seen, rather than after every model.
8927
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
9098
+ const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back: true });
9099
+ if (idx === PICK_BACK) return chooseAgentModel();
8928
9100
  if (idx < 0) {
8929
9101
  console.log(dim(" (cancelled)\n"));
8930
9102
  return;
@@ -10217,7 +10389,7 @@ async function replaceCodingKey() {
10217
10389
  * `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
10218
10390
  * Returns the id now in use for this folder's role, or "" when nothing changed.
10219
10391
  */
10220
- async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
10392
+ async function manageServerKeys({ role = "coding", backendId, host, model = "", back = false }) {
10221
10393
  const sk = await import("./server-keys.mjs");
10222
10394
  const { label: roleLabel } = sk.roleOf(role);
10223
10395
  const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
@@ -10240,8 +10412,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "" }
10240
10412
  rows.push(dim("add a new key…"));
10241
10413
  console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
10242
10414
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
10243
- const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true });
10415
+ const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
10244
10416
  const idx = picked?.index ?? -1;
10417
+ if (idx === PICK_BACK) return BACK;
10245
10418
  if (idx < 0) {
10246
10419
  console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
10247
10420
  paintBar();
@@ -11100,7 +11273,24 @@ function pickManyFromList(items, preChecked = [], { max = 0 } = {}) {
11100
11273
  });
11101
11274
  }
11102
11275
 
11276
+ /**
11277
+ * "← back" (#242): with `opts.back`, a last row that returns to the menu this one was opened from.
11278
+ * Choosing it resolves PICK_BACK (-2) — still < 0, so a caller that does not handle it reads it as a
11279
+ * cancel and no existing picker can change meaning. Esc still cancels the whole command.
11280
+ */
11281
+ const PICK_BACK = -2;
11282
+ const BACK_ROW = "← back";
11283
+ /** What a flow opened from another menu returns when the user chose "← back" at its first question. */
11284
+ const BACK = Symbol("back");
11285
+
11103
11286
  function pickFromList(items, initialSel = 0, opts = {}) {
11287
+ if (opts.back && process.stdin.isTTY && items.length) {
11288
+ const n = items.length;
11289
+ return pickFromList([...items, dim(BACK_ROW)], initialSel, { ...opts, back: false }).then((v) => {
11290
+ if (v && typeof v === "object") return v.index === n ? { ...v, index: PICK_BACK } : v;
11291
+ return v === n ? PICK_BACK : v;
11292
+ });
11293
+ }
11104
11294
  return new Promise((resolve) => {
11105
11295
  if (!process.stdin.isTTY || items.length === 0) {
11106
11296
  resolve(-1);
@@ -12311,7 +12501,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12311
12501
  // Only worth probing when the mouse is captured — thoughtRow exists for hover/click
12312
12502
  // hit-testing, and with `/hover` off nothing reads it (and nothing would consume the reply).
12313
12503
  if (hoverMouse && process.stdin.isTTY && now - lastDsrAt > 400) { lastDsrAt = now; ui("\x1b[6n"); }
12314
- // Line 1: cyan ● + cyan phase/seconds — the logo's colour, which is what this REPL uses for
12504
+ // Line 1 (#245: white by default; the cyan below only with `gu --color`, like the spinner row):
12505
+ // cyan ● + cyan phase/seconds — the logo's colour, which is what this REPL uses for
12315
12506
  // WHAT IS LIVE RIGHT NOW: the ">> " prompt is cyan while you are typing and goes dim once
12316
12507
  // submitted, and a selection is drawn on the same cyan. Green is left to mean SUCCEEDED (the
12317
12508
  // ✓ bullets), which is what it means everywhere else here — a running step is not a finished
@@ -12330,7 +12521,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12330
12521
  const clipRow = (r) => clipVisible(r, Math.max(20, process.stdout.columns || 80) - 1);
12331
12522
  const contentRows = [];
12332
12523
  for (let i = 0; i < LEAD_ROWS; i++) contentRows.push(""); // the separator — see LEAD_ROWS
12333
- contentRows.push(clipRow(`${markLine(accent(BULLET))}${hovering ? hoverThought(label) : accent(label)}`));
12524
+ contentRows.push(clipRow(`${markLine(thoughtPaint(BULLET))}${hovering ? hoverThought(label) : thoughtPaint(label)}`));
12334
12525
  for (const dr of detailRows) contentRows.push(clipRow(dim(dr)));
12335
12526
  for (const wl of expLines) contentRows.push(clipRow(GUTTER + dim(wl)));
12336
12527
  for (let i = 0; i < WORD_GAP; i++) contentRows.push(""); // see WORD_GAP
@@ -14000,7 +14191,7 @@ async function main() {
14000
14191
  red("gu: this machine has no models set up yet.") +
14001
14192
  dim(
14002
14193
  "\n Nothing can run until it knows which Ollama server to use." +
14003
- "\n Start `gu`, then `/agent-model` → use an Ollama server, `/embed-model` for RAG," +
14194
+ "\n Start `gu`, then `/agent-model` → use another provider → Ollama, `/embed-model` for RAG," +
14004
14195
  "\n and `/model` → add a provider → another URL… for the coding model." +
14005
14196
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
14006
14197
  `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
@@ -14535,6 +14726,10 @@ async function main() {
14535
14726
  await runSwitchSkillCommand(line);
14536
14727
  continue;
14537
14728
  }
14729
+ if (line === "/skills-copy-to-claude" || line.startsWith("/skills-copy-to-claude ")) {
14730
+ await runSkillsCopyCommand(line);
14731
+ continue;
14732
+ }
14538
14733
  if (line === "/hooks" || line.startsWith("/hooks ")) {
14539
14734
  const { runHooksCommand } = await import("./hooks-command.mjs");
14540
14735
  const style = { ok: accent, warn: yellow, dim };