@tiens.nguyen/gu-cli 1.0.872 → 1.0.873

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.
@@ -614,20 +614,24 @@ export function providerHost(url) {
614
614
  * KIND, so a second openai row is not even visible there. Add-only was a dead end and this is the
615
615
  * exit from it.
616
616
  *
617
- * OPENAI ROWS ONLY. An Ollama or MLX row is owned by its own flow ("change the Ollama server this
618
- * machine uses…"), which sets URL, models and roles together; editing one field of it from here
619
- * would half-configure a thing that has its own notion of complete.
617
+ * OPENAI AND OLLAMA ROWS (#243). Ollama rows — ollama.com, or a box added with "another URL…" —
618
+ * used to be left to "change the Ollama server this machine uses…"; #412 removed that menu, which
619
+ * left them with no editor at all: an ollama.com coder worked but could not be found here to change
620
+ * or remove. They are listed with `dialect: "ollama"`, and their model list and URL are handled in
621
+ * that dialect (/api/tags, serverTarget). MLX / "local" rows stay out: they are set up by gu itself.
620
622
  */
621
623
  export function manageableProviders(rows) {
622
624
  const out = [];
623
625
  const taken = new Set();
624
626
  for (const b of Array.isArray(rows) ? rows : []) {
625
627
  if (!b || typeof b !== "object") continue;
626
- if (String(b.kind ?? "") !== "openai") continue;
628
+ const kind = String(b.kind ?? "");
629
+ if (kind !== "openai" && kind !== "ollama") continue;
627
630
  const url = String(b.url ?? "").trim();
628
631
  if (!url) continue;
629
- // The same identity rule the API uses, so an id shown here is the id it will patch.
630
- const id = String(b.id ?? "").trim() || (taken.has("openai") ? "" : "openai");
632
+ // The same identity rule the API uses, so an id shown here is the id it will patch: an id-less
633
+ // row is known by its kind, and only the first of each kind can be.
634
+ const id = String(b.id ?? "").trim() || (taken.has(kind) ? "" : kind);
631
635
  if (!id || taken.has(id)) continue;
632
636
  taken.add(id);
633
637
  out.push({
@@ -636,6 +640,7 @@ export function manageableProviders(rows) {
636
640
  model: String(b.defaultModel ?? "").trim(),
637
641
  host: providerHost(url),
638
642
  enabled: b.enabled !== false,
643
+ dialect: kind === "ollama" ? "ollama" : "openai",
639
644
  });
640
645
  }
641
646
  return out;
package/gu-repl.mjs CHANGED
@@ -1038,6 +1038,7 @@ const COMMANDS = [
1038
1038
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
1039
1039
  { 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
1040
  { 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)" },
1041
+ { 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
1042
  { name: "/help", desc: "list these commands" },
1042
1043
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
1043
1044
  ];
@@ -6232,7 +6233,7 @@ async function pickExtraModels(rest, preChecked = []) {
6232
6233
  *
6233
6234
  * Returns the model id, or null when the user cancelled.
6234
6235
  */
6235
- async function chooseCodingModelFrom(ps, all, question, current) {
6236
+ async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
6236
6237
  const { models: coders, filtered } = ps.codingModelCandidates(all);
6237
6238
  let list = coders;
6238
6239
  let showingAll = filtered === 0;
@@ -6247,7 +6248,8 @@ async function chooseCodingModelFrom(ps, all, question, current) {
6247
6248
  dim(` ${question} (${PICK_HINT})`) +
6248
6249
  (showingAll || !filtered ? "" : dim(` · ${filtered} non-coding models hidden`))
6249
6250
  );
6250
- const idx = await pickFromList(rows, at >= 0 ? at : 0);
6251
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6252
+ if (idx === PICK_BACK) return BACK;
6251
6253
  if (idx < 0) return null;
6252
6254
  if (idx === list.length) {
6253
6255
  const typed = (await ask(" model name: ")).trim();
@@ -6275,11 +6277,12 @@ async function chooseCodingModelFrom(ps, all, question, current) {
6275
6277
  * stays available for the same reason it does there — a listed model may be unusable and an
6276
6278
  * unlisted one may be exactly right.
6277
6279
  */
6278
- async function chooseFromModelList(all, question, current = 0) {
6280
+ async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6279
6281
  const rows = [...all, "type a model name…"];
6280
6282
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6281
6283
  console.log(dim(` ${question} (${PICK_HINT})`));
6282
- const idx = await pickFromList(rows, at >= 0 ? at : 0);
6284
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6285
+ if (idx === PICK_BACK) return BACK;
6283
6286
  if (idx < 0) return null;
6284
6287
  if (idx === all.length) {
6285
6288
  const typed = (await ask(" model name: ")).trim();
@@ -6288,51 +6291,65 @@ async function chooseFromModelList(all, question, current = 0) {
6288
6291
  return all[idx];
6289
6292
  }
6290
6293
 
6291
- async function manageCodingProviders() {
6294
+ /**
6295
+ * /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
6296
+ * "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
6297
+ * step backed out of saves nothing but the list is cheap to trust fresh.
6298
+ */
6299
+ async function manageCodingProviders({ back = false } = {}) {
6292
6300
  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
- }
6301
+ let selAt = 0;
6302
+ for (;;) {
6303
+ const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
6304
+ if (!settings) {
6305
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
6306
+ return;
6307
+ }
6308
+ const rows = ps.existingBackendRows(settings);
6309
+ const providers = ps.manageableProviders(rows);
6310
+ // NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
6311
+ if (providers.length === 0) return addServer({ role: "coding", dialect: "openai", back });
6305
6312
 
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
- }
6313
+ const labels = providers.map(
6314
+ (p) =>
6315
+ `${p.model || dim("(no model set)")}${dim(` · ${p.host}`)}${p.enabled ? "" : dim(" · off")}`
6316
+ );
6317
+ const ADD = labels.length;
6318
+ labels.push("add another provider — Claude API, OpenAI, Kimi…");
6319
+ console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6320
+ const idx = await pickFromList(labels, selAt, { back });
6321
+ if (idx === PICK_BACK) return BACK;
6322
+ if (idx < 0) {
6323
+ console.log(dim(" (cancelled)\n"));
6324
+ return;
6325
+ }
6326
+ selAt = idx;
6327
+ if (idx === ADD) {
6328
+ if ((await addServer({ role: "coding", dialect: "openai", back: true })) === BACK) continue;
6329
+ return;
6330
+ }
6322
6331
 
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;
6332
+ const p = providers[idx];
6333
+ for (;;) {
6334
+ console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6335
+ const action = await pickFromList(
6336
+ [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6337
+ 1,
6338
+ { back: true }
6339
+ );
6340
+ if (action === PICK_BACK) break; // → the provider list
6341
+ if (action <= 0) {
6342
+ console.log(dim(" (nothing changed)\n"));
6343
+ return;
6344
+ }
6345
+ let r;
6346
+ if (action === 1) r = await changeProviderModel(ps, rows, p);
6347
+ else if (action === 2) r = await changeProviderUrl(ps, rows, p);
6348
+ else r = await removeCodingProvider(ps, rows, p);
6349
+ if (r === BACK) continue; // → this provider's actions
6350
+ return;
6351
+ }
6332
6352
  }
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
6353
  }
6337
6354
 
6338
6355
  /** Point a provider at a different model. */
@@ -6353,9 +6370,14 @@ async function changeProviderModel(ps, rows, p) {
6353
6370
  const r = await fetchCodingKey(p.id);
6354
6371
  return r.keyId && r.keyId !== inUse.id ? { key: "", name: "" } : { key: r.key, name: String(inUse.name ?? "") };
6355
6372
  });
6373
+ // AN OLLAMA ROW (#243) lists through Ollama's own /api/tags, the way it was added.
6374
+ const as = p.dialect === "ollama" ? await import("./add-server.mjs") : null;
6375
+ const ollamaTarget = as ? as.serverTarget("ollama", p.url) : null;
6356
6376
  const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
6357
6377
  try {
6358
- const lr = ps.modelListRequest(p.url, own.key); // the list's own dialect too (Claude's headers, #261)
6378
+ const lr = ollamaTarget?.ok
6379
+ ? as.listRequest(ollamaTarget, own.key)
6380
+ : ps.modelListRequest(p.url, own.key); // the list's own dialect too (Claude's headers, #261)
6359
6381
  const res = await proxyFetch(lr.url, { headers: lr.headers, signal: AbortSignal.timeout(10_000) });
6360
6382
  if (!res.ok) return { status: res.status };
6361
6383
  return { body: await res.json().catch(() => null) };
@@ -6365,7 +6387,7 @@ async function changeProviderModel(ps, rows, p) {
6365
6387
  });
6366
6388
  let models = [];
6367
6389
  if (!probe.status && !probe.err) {
6368
- const parsed = ps.parseProviderModels(probe.body);
6390
+ const parsed = ollamaTarget?.ok ? as.modelsForRole("coding", "ollama", probe.body) : ps.parseProviderModels(probe.body);
6369
6391
  // The CURRENT model stays in the list: after switching to another one it becomes a perfectly
6370
6392
  // good extra, and filtering it out here would make it unofferable without a second visit.
6371
6393
  models = parsed.models;
@@ -6384,7 +6406,8 @@ async function changeProviderModel(ps, rows, p) {
6384
6406
 
6385
6407
  let model = "";
6386
6408
  if (models.length) {
6387
- model = await chooseCodingModelFrom(ps, models, `Which model should ${p.host} use?`, p.model);
6409
+ model = await chooseCodingModelFrom(ps, models, `Which model should ${p.host} use?`, p.model, { back: true });
6410
+ if (model === BACK) return BACK;
6388
6411
  if (model === null) {
6389
6412
  console.log(dim(" (nothing changed)\n"));
6390
6413
  return;
@@ -6437,10 +6460,13 @@ async function changeProviderUrl(ps, rows, p) {
6437
6460
  // THE SAME PRESET LIST AS ADDING (#132), OpenAI-compatible ones only: an Ollama row has another
6438
6461
  // kind, and this flow keeps the row's id and kind. "type a URL…" is the default row, so Enter
6439
6462
  // still gets the old free-text question.
6440
- const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === "openai");
6463
+ // An Ollama row (#243) is offered the Ollama presets (Ollama cloud) and keeps its kind too.
6464
+ const dialect = p.dialect === "ollama" ? "ollama" : "openai";
6465
+ const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === dialect);
6441
6466
  const urlRows = [...urlPresets.map((x) => `${x.name}${dim(` · ${x.url}`)}`), "type a URL…"];
6442
6467
  console.log("\n" + GUTTER + dim(`Which provider? (${PICK_HINT})`));
6443
- const upi = await pickFromList(urlRows, urlRows.length - 1);
6468
+ const upi = await pickFromList(urlRows, urlRows.length - 1, { back: true });
6469
+ if (upi === PICK_BACK) return BACK;
6444
6470
  if (upi < 0) {
6445
6471
  console.log(GUTTER + dim("(nothing changed)\n"));
6446
6472
  return;
@@ -6456,11 +6482,27 @@ async function changeProviderUrl(ps, rows, p) {
6456
6482
  console.log(GUTTER + dim("(nothing changed)\n"));
6457
6483
  return;
6458
6484
  }
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;
6485
+ let r;
6486
+ if (dialect === "ollama") {
6487
+ const as = await import("./add-server.mjs");
6488
+ const t = as.serverTarget("ollama", typed);
6489
+ r = t.ok ? { ok: true, url: t.url, host: t.host } : { ok: false, why: t.why };
6490
+ if (!r.ok) {
6491
+ console.log(GUTTER + dim(`"${typed}" is ${r.why} — try ollama.com, or host:port such as 192.168.1.5:11434`));
6492
+ console.log(GUTTER + dim("(nothing changed)\n"));
6493
+ return;
6494
+ }
6495
+ if (r.url === p.url) {
6496
+ console.log(GUTTER + dim("(nothing changed)\n"));
6497
+ return;
6498
+ }
6499
+ } else {
6500
+ r = ps.normalizeProviderUrl(typed);
6501
+ if (!r.ok) {
6502
+ for (const l of ps.describeUrlProblem(r.why, typed)) console.log(GUTTER + dim(l));
6503
+ console.log(GUTTER + dim("(nothing changed)\n"));
6504
+ return;
6505
+ }
6464
6506
  }
6465
6507
  const clash = rows.find(
6466
6508
  (b) => ps.ensureV1Root(String(b.url ?? "")) === r.url && !ps.manageableProviders([b]).some((x) => x.id === p.id)
@@ -6569,7 +6611,7 @@ async function removeCodingProvider(ps, rows, p) {
6569
6611
  * model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
6570
6612
  * newKey ask for a key even if the server has one (the stored one was refused)
6571
6613
  */
6572
- async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
6614
+ async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
6573
6615
  const as = await import("./add-server.mjs");
6574
6616
  const ps = await import("./coding-provider-setup.mjs");
6575
6617
  const sk = await import("./server-keys.mjs");
@@ -6591,166 +6633,180 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6591
6633
  ? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
6592
6634
  : null;
6593
6635
 
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;
6636
+ // STEPS 1–4 ARE ONE LOOP (#242): "← back" on the model list returns to "Which provider?", and
6637
+ // "← back" there returns BACK to the menu that opened this (with `back`). Nothing is saved
6638
+ // before step 5, so going back leaves nothing behind — a key typed on the way is dropped.
6639
+ const providerPicker = !presetUrl && dialect === "openai";
6640
+ const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6641
+ let target, host, found, need, key, keyName, typedKey, models, model;
6642
+ for (;;) {
6643
+ // --- 1. the server --------------------------------------------------------------------
6644
+ target = null;
6645
+ if (presetUrl) {
6646
+ target = as.serverTarget(dialect, presetUrl);
6647
+ if (!target.ok) {
6648
+ console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
6649
+ return;
6650
+ }
6651
+ } else if (dialect === "openai") {
6652
+ // THE HOSTED PROVIDERS BY NAME (#261): Claude, OpenAI, Kimi — then any other URL. One picker in
6653
+ // the one add flow, so every role that adds a provider offers the same list (the embedder
6654
+ // without Claude, which serves no embeddings).
6655
+ const presets = ps.providerPresets(role);
6656
+ const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
6657
+ // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6658
+ console.log(dim(` Which provider? (${PICK_HINT})`));
6659
+ const pi = await pickFromList(rowsShown, 0, { back });
6660
+ if (pi === PICK_BACK) return BACK;
6661
+ if (pi < 0) {
6662
+ console.log(dim(" (nothing saved)\n"));
6663
+ return;
6664
+ }
6665
+ if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6666
+ }
6667
+ if (!target && !presetUrl) {
6668
+ const dflt = as.defaultServerUrl(role, dialect, settings, existing);
6669
+ console.log(dim(dialect === "ollama"
6670
+ ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
6671
+ : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
6672
+ // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
6673
+ console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
6674
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
6675
+ const typed = await ask(" server URL: ");
6676
+ if (typed === null || as.isCancelWord(typed)) break;
6677
+ const raw = typed.trim() || dflt;
6678
+ if (!raw) break;
6679
+ const t = as.serverTarget(dialect, raw);
6680
+ if (!t.ok) {
6681
+ if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
6682
+ else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
6683
+ continue;
6684
+ }
6685
+ // SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
6686
+ // guess shows up later as a timeout that reads like a dead server.
6687
+ if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
6688
+ target = t;
6689
+ }
6601
6690
  }
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) {
6691
+ if (!target) {
6612
6692
  console.log(dim(" (nothing saved)\n"));
6613
6693
  return;
6614
6694
  }
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;
6695
+ host = target.host;
6696
+ const refusal = as.roleRefusal(role, target);
6697
+ if (refusal.length) {
6698
+ for (const l of refusal) console.log(GUTTER + yellow(l));
6699
+ console.log("");
6700
+ return;
6701
+ }
6702
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
6703
+ if (target.claude && role === "coding") {
6704
+ await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
6639
6705
  }
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
6706
 
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 };
6707
+ // --- 2. the server's key --------------------------------------------------------------
6708
+ // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
6709
+ // entries identical in everything a person can see) — its keys come with it.
6710
+ found = ps.providerRowForUrl(existing, target.url);
6711
+ // THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
6712
+ // and asking for a key and a model first would be the worst order to discover that in.
6713
+ if (!found && existing.length >= ps.MAX_BACKENDS) {
6714
+ console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
6715
+ return;
6716
+ }
6717
+ need = as.keyRequirement(target);
6718
+ key = "";
6719
+ keyName = "";
6720
+ typedKey = false;
6721
+ if (found && !newKey) {
6722
+ // A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
6723
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
6724
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
6725
+ const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
6726
+ if (inUse && inUse.backendId === found.id) {
6727
+ key = (await fetchCodingKey(found.id)).key;
6728
+ if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6729
+ }
6730
+ }
6731
+ const askKey = async () => {
6732
+ const t = await askServerKey({ host, optional: need === "optional" });
6733
+ key = t.secret;
6734
+ keyName = t.name;
6735
+ typedKey = Boolean(t.secret);
6736
+ return t.secret || need !== "required";
6737
+ };
6738
+ if (!key && (need !== "none" || newKey)) {
6739
+ if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6740
+ if (!(await askKey())) return;
6741
+ }
6742
+
6743
+ // --- 3. its models, listed with that key ----------------------------------------------
6744
+ models = [];
6745
+ if (!presetModel) {
6746
+ const list = () =>
6747
+ withPendingStatus(`asking ${host} what it has`, async () => {
6748
+ const req = as.listRequest(target, key);
6749
+ try {
6750
+ const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
6751
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6752
+ } catch (err) {
6753
+ return { err };
6754
+ }
6755
+ });
6756
+ let probe = await list();
6757
+ // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
6758
+ if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
6759
+ console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
6760
+ if (!(await askKey()) || !key) return;
6761
+ probe = await list();
6762
+ }
6763
+ if (probe.status || probe.err) {
6764
+ const d = ps.describeProbeFailure(probe);
6765
+ for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
6766
+ // FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
6767
+ // a problem for later. Anything else is survivable — the model id can be typed.
6768
+ if (d.fatal) {
6769
+ console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
6770
+ return;
6704
6771
  }
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;
6772
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6773
+ } else {
6774
+ const parsed = as.modelsForRole(role, target.dialect, probe.body);
6775
+ models = parsed.models;
6776
+ for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
6777
+ const rn = ps.retiredNote(parsed.retired, host);
6778
+ if (rn) console.log(GUTTER + dim(rn));
6779
+ if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
6721
6780
  }
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
6781
  }
6731
- }
6732
6782
 
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"));
6783
+ // --- 4. the model ---------------------------------------------------------------------
6784
+ model = presetModel;
6785
+ if (!model) {
6786
+ if (models.length) {
6787
+ const am = await import("./agent-model-command.mjs");
6788
+ const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
6789
+ const canBack = { back: providerPicker || back };
6790
+ model = R.filterCoders
6791
+ ? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
6792
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
6793
+ if (model === BACK) {
6794
+ if (providerPicker) continue; // → Which provider?
6795
+ return BACK;
6796
+ }
6797
+ if (model === null) {
6798
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6799
+ return;
6800
+ }
6801
+ } else {
6802
+ model = String((await ask(" model name: ")) ?? "").trim();
6803
+ }
6804
+ if (!model) {
6805
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6745
6806
  return;
6746
6807
  }
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
6808
  }
6809
+ break;
6754
6810
  }
6755
6811
  // THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
6756
6812
  // that stop working are named BEFORE anything is saved. The same model is a pure server move.
@@ -7023,8 +7079,8 @@ async function chooseModel() {
7023
7079
  [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7024
7080
  1
7025
7081
  );
7026
- if (go === 1) await manageCodingProviders();
7027
- else console.log("");
7082
+ if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7083
+ if (go !== 1) console.log("");
7028
7084
  return;
7029
7085
  }
7030
7086
  // Shown even with ONE model: "only one is available" often means the wrong server, and the
@@ -7037,8 +7093,8 @@ async function chooseModel() {
7037
7093
  [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7038
7094
  0
7039
7095
  );
7040
- if (go === 1) await manageCodingProviders();
7041
- else console.log("");
7096
+ if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7097
+ if (go !== 1) console.log("");
7042
7098
  return;
7043
7099
  }
7044
7100
  const active = sessionCodingModel || def;
@@ -7073,7 +7129,7 @@ async function chooseModel() {
7073
7129
  }
7074
7130
  // The one appended entry sits past the last model.
7075
7131
  if (idx >= choices.length) {
7076
- await manageCodingProviders();
7132
+ if ((await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7077
7133
  return;
7078
7134
  }
7079
7135
  await applyChosenCoder(choices[idx], { def, tag: showTag ? tags[idx] : "" });
@@ -7619,6 +7675,91 @@ async function runSwitchSkillCommand(line) {
7619
7675
  if (barPinned()) paintBar();
7620
7676
  }
7621
7677
 
7678
+ /**
7679
+ * /skills-copy-to-claude [--dry-run] [name…] — gu's skills into Claude Code's folders (#241):
7680
+ * ~/.gonext/skills → ~/.claude/skills and ./.gonext/skills → ./.claude/skills, "gu-" names become
7681
+ * "claude-", and a skill's MCP server goes into ~/.claude.json (skills-copy.mjs). Shows the plan,
7682
+ * then asks; a Claude skill that differs is kept unless the user picks "replace" (backed up).
7683
+ */
7684
+ async function runSkillsCopyCommand(line) {
7685
+ const sc = await import("./skills-copy.mjs");
7686
+ const words = line.trim().split(/\s+/).slice(1);
7687
+ const dryRun = words.includes("--dry-run");
7688
+ const names = words.filter((w) => !w.startsWith("--"));
7689
+ const home = homedir();
7690
+ const cwd = resolve(process.cwd());
7691
+ const tilde = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : p.startsWith(cwd) ? "." + p.slice(cwd.length) : p);
7692
+ const say = (l) => console.log(GUTTER + l);
7693
+ console.log("");
7694
+ let plan;
7695
+ try {
7696
+ plan = sc.planSkillsCopy({ cwd, home, names });
7697
+ } catch (err) {
7698
+ say(red(`couldn't read gu's skills — ${err.message}\n`));
7699
+ return;
7700
+ }
7701
+ for (const w of plan.warnings) say(yellow(w));
7702
+ if (plan.missing.length) say(yellow(`gu has no skill named ${plan.missing.join(", ")}`));
7703
+ if (!plan.items.length) {
7704
+ say(dim("gu has no skills to copy — ~/.gonext/skills and ./.gonext/skills have none.\n"));
7705
+ return;
7706
+ }
7707
+ const word = { new: accent("new"), same: dim("already there"), differs: yellow("Claude's differs") };
7708
+ say(bold("Copy gu's skills to Claude Code"));
7709
+ for (const it of plan.items) {
7710
+ const renamed = it.newName !== it.name ? dim(` (was ${it.name})`) : "";
7711
+ say(` ${it.newName}${renamed} ${word[it.status]}`);
7712
+ say(dim(` → ${tilde(it.to)}`));
7713
+ for (const s of it.servers) {
7714
+ say(dim(` MCP server ${s.name} → ~/.claude.json${s.scope === "local" ? " (this folder)" : ""} `) + word[s.status]);
7715
+ }
7716
+ if (it.guLines.length) {
7717
+ say(yellow(` SKILL.md line${it.guLines.length === 1 ? "" : "s"} ${it.guLines.join(", ")} still name${it.guLines.length === 1 ? "s" : ""} ~/.gonext — check the copy`));
7718
+ }
7719
+ }
7720
+ const todo = plan.items.filter((i) => i.status !== "same" || i.servers.some((s) => s.status !== "same"));
7721
+ const differs = plan.items.filter((i) => i.status === "differs" || i.servers.some((s) => s.status === "differs"));
7722
+ if (!todo.length) {
7723
+ say(dim("\n Nothing to copy — Claude Code already has all of them.\n"));
7724
+ return;
7725
+ }
7726
+ if (dryRun) {
7727
+ say(dim("\n --dry-run: nothing was written.\n"));
7728
+ return;
7729
+ }
7730
+ const fresh = todo.length - differs.length;
7731
+ const options = differs.length
7732
+ ? [
7733
+ ...(fresh ? [`Copy the ${fresh} new — keep Claude's own where it differs`] : []),
7734
+ `Copy all — replace Claude's ${differs.length} (old one${differs.length === 1 ? "" : "s"} backed up)`,
7735
+ "Cancel",
7736
+ ]
7737
+ : [`Copy ${todo.length === 1 ? "it" : `all ${todo.length}`}`, "Cancel"];
7738
+ console.log(dim(`\n ${PICK_HINT}:`));
7739
+ const idx = await pickFromList(options, 0);
7740
+ if (idx < 0 || options[idx] === "Cancel") {
7741
+ console.log(dim(" (cancelled — nothing copied)\n"));
7742
+ paintBar();
7743
+ return;
7744
+ }
7745
+ const replace = options[idx].startsWith("Copy all — replace");
7746
+ const r = sc.applySkillsCopy(plan, { replace, cwd, home });
7747
+ const verb = { copied: "copied", replaced: "replaced", kept: "kept Claude's", already: "already there" };
7748
+ for (const d of r.done) {
7749
+ const mark = d.action === "copied" || d.action === "replaced" ? accent("✓") : dim("·");
7750
+ say(`${mark} ${d.newName} ${dim(verb[d.action] + (d.action === "copied" || d.action === "replaced" ? " → " + tilde(d.to) : ""))}`);
7751
+ if (d.backup) say(dim(` Claude's old one: ${tilde(d.backup)}`));
7752
+ }
7753
+ for (const s of r.servers) {
7754
+ if (s.action === "copied" || s.action === "replaced") say(`${accent("✓")} MCP server ${s.name} ${dim(verb[s.action] + " → ~/.claude.json")}`);
7755
+ }
7756
+ for (const e of r.errors) say(red(`✗ ${e}`));
7757
+ say(dim(" A new Claude Code session loads them."));
7758
+ say(dim(" In gu, /switch-skill claude makes this folder use them too."));
7759
+ console.log("");
7760
+ if (barPinned()) paintBar();
7761
+ }
7762
+
7622
7763
  /**
7623
7764
  * /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
7624
7765
  * run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
@@ -8594,13 +8735,14 @@ async function confirmEmbedSwitch(ec, current, chosen) {
8594
8735
 
8595
8736
  // WHICH EMBEDDER, ASKED ONCE. The model sub-flow of /embed-model: the curated picker, then the
8596
8737
  // shared recognition step. Returns { changed, chosen, plan }.
8597
- async function pickEmbedModel(ec, current) {
8738
+ async function pickEmbedModel(ec, current, { back = false } = {}) {
8598
8739
  const rows = ec.embedChoiceRows(current);
8599
8740
  console.log(dim(` Choose the embedder (${PICK_HINT}):`));
8600
8741
  const labels = rows.map(
8601
8742
  (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` + (r.current ? accent(" (current)") : "")
8602
8743
  );
8603
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
8744
+ const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back });
8745
+ if (idx === PICK_BACK) return BACK;
8604
8746
  if (idx < 0) {
8605
8747
  console.log(dim(" (cancelled)\n"));
8606
8748
  return { changed: false, chosen: "", plan: null };
@@ -8654,14 +8796,19 @@ async function chooseEmbedModel() {
8654
8796
  // EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
8655
8797
  // /model and /agent-model. The curated arm has already chosen the model (and passed the
8656
8798
  // recognition wall), so it only asks WHERE; the other two list what the server has.
8799
+ // "← back" in any arm returns to this menu (#242).
8657
8800
  if (action === "model") {
8658
- const { changed, chosen, plan } = await pickEmbedModel(ec, current);
8801
+ const picked = await pickEmbedModel(ec, current, { back: true });
8802
+ if (picked === BACK) return chooseEmbedModel();
8803
+ const { changed, chosen, plan } = picked;
8659
8804
  if (!changed) return;
8660
8805
  await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
8661
8806
  return;
8662
8807
  }
8663
- if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
8664
- if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
8808
+ if (action === "ollama" || action === "provider") {
8809
+ if ((await addServer({ role: "embed", dialect: action === "ollama" ? "ollama" : "openai", back: true })) === BACK) return chooseEmbedModel();
8810
+ return;
8811
+ }
8665
8812
 
8666
8813
  if (action === "key") {
8667
8814
  // THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
@@ -8674,7 +8821,7 @@ async function chooseEmbedModel() {
8674
8821
  await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
8675
8822
  return;
8676
8823
  } else {
8677
- await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
8824
+ if ((await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current, back: true })) === BACK) return chooseEmbedModel();
8678
8825
  }
8679
8826
  // The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
8680
8827
  if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
@@ -8758,7 +8905,7 @@ async function enterAgentProviderKey(row, host) {
8758
8905
  /**
8759
8906
  * #44: switch the agent to a provider already saved on this machine, or add a new one.
8760
8907
  */
8761
- async function chooseAgentProvider() {
8908
+ async function chooseAgentProvider({ back = false } = {}) {
8762
8909
  const am = await import("./agent-model-command.mjs");
8763
8910
  const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
8764
8911
  if (!settings) {
@@ -8766,17 +8913,25 @@ async function chooseAgentProvider() {
8766
8913
  return;
8767
8914
  }
8768
8915
  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;
8916
+ if (choices.length === 1) return addServer({ role: "agent", dialect: "openai", back });
8917
+ let at = 0;
8918
+ for (;;) {
8919
+ console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
8920
+ const idx = await pickFromList(choices.map((c) => c.label), at, { back });
8921
+ if (idx === PICK_BACK) return BACK;
8922
+ if (idx < 0) {
8923
+ console.log(dim(" (cancelled)\n"));
8924
+ return;
8925
+ }
8926
+ at = idx;
8927
+ const pick = choices[idx];
8928
+ // "← back" inside either screen returns to this list (#242).
8929
+ const r = pick.action === "add"
8930
+ ? await addServer({ role: "agent", dialect: "openai", back: true })
8931
+ // A server already set up: the same screen, URL known — its key is reused, its models listed.
8932
+ : await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? ""), back: true });
8933
+ if (r !== BACK) return;
8775
8934
  }
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
8935
  }
8781
8936
 
8782
8937
  /**
@@ -8886,13 +9041,21 @@ async function chooseAgentModel() {
8886
9041
  console.log(dim(" (cancelled)\n"));
8887
9042
  return;
8888
9043
  }
8889
- if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
8890
- if (firstAction === "provider") return chooseAgentProvider();
9044
+ // "← back" in any of these returns to this menu (#242) — read again, as on a first open.
9045
+ if (firstAction === "ollama") {
9046
+ if ((await addServer({ role: "agent", dialect: "ollama", back: true })) === BACK) return chooseAgentModel();
9047
+ return;
9048
+ }
9049
+ if (firstAction === "provider") {
9050
+ if ((await chooseAgentProvider({ back: true })) === BACK) return chooseAgentModel();
9051
+ return;
9052
+ }
8891
9053
  if (firstAction === "key") {
8892
9054
  // Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
8893
9055
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
8894
9056
  if (!rowId) return enterAgentProviderKey(source.row, listHost);
8895
- await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
9057
+ const r = await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings), back: true });
9058
+ if (r === BACK) return chooseAgentModel();
8896
9059
  return;
8897
9060
  }
8898
9061
 
@@ -8924,7 +9087,8 @@ async function chooseAgentModel() {
8924
9087
  });
8925
9088
  // No other entries here any more (#44): the ways to change the provider are on the menu above,
8926
9089
  // where they can be seen, rather than after every model.
8927
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
9090
+ const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back: true });
9091
+ if (idx === PICK_BACK) return chooseAgentModel();
8928
9092
  if (idx < 0) {
8929
9093
  console.log(dim(" (cancelled)\n"));
8930
9094
  return;
@@ -10217,7 +10381,7 @@ async function replaceCodingKey() {
10217
10381
  * `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
10218
10382
  * Returns the id now in use for this folder's role, or "" when nothing changed.
10219
10383
  */
10220
- async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
10384
+ async function manageServerKeys({ role = "coding", backendId, host, model = "", back = false }) {
10221
10385
  const sk = await import("./server-keys.mjs");
10222
10386
  const { label: roleLabel } = sk.roleOf(role);
10223
10387
  const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
@@ -10240,8 +10404,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "" }
10240
10404
  rows.push(dim("add a new key…"));
10241
10405
  console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
10242
10406
  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 });
10407
+ const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
10244
10408
  const idx = picked?.index ?? -1;
10409
+ if (idx === PICK_BACK) return BACK;
10245
10410
  if (idx < 0) {
10246
10411
  console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
10247
10412
  paintBar();
@@ -11100,7 +11265,24 @@ function pickManyFromList(items, preChecked = [], { max = 0 } = {}) {
11100
11265
  });
11101
11266
  }
11102
11267
 
11268
+ /**
11269
+ * "← back" (#242): with `opts.back`, a last row that returns to the menu this one was opened from.
11270
+ * Choosing it resolves PICK_BACK (-2) — still < 0, so a caller that does not handle it reads it as a
11271
+ * cancel and no existing picker can change meaning. Esc still cancels the whole command.
11272
+ */
11273
+ const PICK_BACK = -2;
11274
+ const BACK_ROW = "← back";
11275
+ /** What a flow opened from another menu returns when the user chose "← back" at its first question. */
11276
+ const BACK = Symbol("back");
11277
+
11103
11278
  function pickFromList(items, initialSel = 0, opts = {}) {
11279
+ if (opts.back && process.stdin.isTTY && items.length) {
11280
+ const n = items.length;
11281
+ return pickFromList([...items, dim(BACK_ROW)], initialSel, { ...opts, back: false }).then((v) => {
11282
+ if (v && typeof v === "object") return v.index === n ? { ...v, index: PICK_BACK } : v;
11283
+ return v === n ? PICK_BACK : v;
11284
+ });
11285
+ }
11104
11286
  return new Promise((resolve) => {
11105
11287
  if (!process.stdin.isTTY || items.length === 0) {
11106
11288
  resolve(-1);
@@ -14535,6 +14717,10 @@ async function main() {
14535
14717
  await runSwitchSkillCommand(line);
14536
14718
  continue;
14537
14719
  }
14720
+ if (line === "/skills-copy-to-claude" || line.startsWith("/skills-copy-to-claude ")) {
14721
+ await runSkillsCopyCommand(line);
14722
+ continue;
14723
+ }
14538
14724
  if (line === "/hooks" || line.startsWith("/hooks ")) {
14539
14725
  const { runHooksCommand } = await import("./hooks-command.mjs");
14540
14726
  const style = { ok: accent, warn: yellow, dim };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.872",
3
+ "version": "1.0.873",
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",
@@ -134,6 +134,7 @@
134
134
  "update-verify.mjs",
135
135
  "skills.mjs",
136
136
  "skill-source.mjs",
137
+ "skills-copy.mjs",
137
138
  "shell-details.mjs",
138
139
  "proc-identity.mjs",
139
140
  "win-path.mjs",
@@ -0,0 +1,266 @@
1
+ /**
2
+ * /skills-copy-to-claude — copy gu's skills into Claude Code's folders, under Claude's names (#241).
3
+ *
4
+ * ~/.gonext/skills/<name>/ → ~/.claude/skills/<claude-name>/
5
+ * <project>/.gonext/skills/<name>/ → <project>/.claude/skills/<claude-name>/
6
+ * the skill's MCP server in ~/.gonext/mcp.json → ~/.claude.json
7
+ * (user scope for a home skill, projects["<folder>"] for a project skill)
8
+ *
9
+ * The web-v2 Skills page's installer (#208, src/skills/email/install.mjs) puts a skill's files into
10
+ * ~/.gonext/skills/<name>/ and its MCP server into ~/.gonext/mcp.json. This is the same move in the
11
+ * other direction, so a skill set up for gu also works in Claude Code.
12
+ *
13
+ * NAMES. gu's own skills start with "gu-" where Claude's start with "claude-" (the memory skill is
14
+ * gu-session-memory-orchestration here and claude-session-memory-orchestration there). So "gu-"
15
+ * becomes "claude-" in the folder name, the frontmatter `name:` and the text. Other names stay.
16
+ *
17
+ * TEXT. Only SKILL.md is rewritten, and only what is certain: paths into the skill's own folder
18
+ * (they now point at the copy) and the old name. Other mentions of .gonext (gu's memory CLI, say)
19
+ * may have no Claude equivalent, so they are left as they are and REPORTED, never guessed.
20
+ *
21
+ * A COPY. Nothing under ~/.gonext changes. Claude's file ~/.claude.json is changed only through
22
+ * updateClaudeConfig, and only its mcpServers. A Claude skill that differs is kept unless the user
23
+ * chose "replace"; a replaced folder goes to ~/.gonext/skills-copy-backups/<time>/, never next to
24
+ * the others in .claude/skills, where Claude would load it as a second skill.
25
+ */
26
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join, relative, resolve, sep } from "node:path";
29
+ import { scanSkillsDir } from "./skills.mjs";
30
+ import { readClaudeServers, readUserConfig, updateClaudeConfig } from "./mcp-config.mjs";
31
+
32
+ export const BACKUP_DIR_NAME = "skills-copy-backups";
33
+
34
+ /** Claude's name for a gu skill: "gu-" → "claude-", else the same. Kept within Claude's 64. */
35
+ export function claudeSkillName(name) {
36
+ const n = String(name);
37
+ return n.startsWith("gu-") ? `claude-${n.slice(3)}`.slice(0, 64) : n;
38
+ }
39
+
40
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+
42
+ /**
43
+ * SKILL.md for Claude: its own-folder paths and its old name rewritten. → { text, guLines }
44
+ * guLines = line numbers (1-based) that still mention .gonext after the rewrite — for the user to check.
45
+ */
46
+ export function rewriteSkillText(text, { from, to }) {
47
+ let out = String(text);
48
+ // ".gonext/skills/<from>" in any form (~/, $HOME/, an absolute home, ./, Windows backslashes).
49
+ const pathRe = new RegExp(`\\.gonext([\\\\/])skills\\1${escapeRe(from)}(?![a-z0-9-])`, "g");
50
+ out = out.replace(pathRe, (_m, s) => `.claude${s}skills${s}${to}`);
51
+ if (to !== from) {
52
+ // The name itself: in the frontmatter, as /<name>, in use_skill("<name>")…
53
+ const nameRe = new RegExp(`(?<![a-z0-9-])${escapeRe(from)}(?![a-z0-9-])`, "g");
54
+ out = out.replace(nameRe, to);
55
+ }
56
+ const guLines = out
57
+ .split("\n")
58
+ .map((l, i) => (/\.gonext\b/.test(l) ? i + 1 : 0))
59
+ .filter(Boolean);
60
+ return { text: out, guLines };
61
+ }
62
+
63
+ /**
64
+ * `p` inside `dir` → the same path inside `toDir`, else null. Slashes and (on Windows) case are
65
+ * ignored when comparing, and a leading ~ counts as the home folder.
66
+ */
67
+ export function movePath(p, dir, toDir, { home = homedir(), platform = process.platform } = {}) {
68
+ if (typeof p !== "string" || !p) return null;
69
+ const expanded = /^~(?=$|[\\/])/.test(p) ? home + p.slice(1) : p;
70
+ const norm = (s) => {
71
+ const x = s.replace(/\\/g, "/").replace(/\/+$/, "");
72
+ return platform === "win32" ? x.toLowerCase() : x;
73
+ };
74
+ const a = norm(expanded);
75
+ const d = norm(dir);
76
+ if (a !== d && !a.startsWith(d + "/")) return null;
77
+ const rest = expanded.replace(/\\/g, "/").replace(/\/+$/, "").slice(d.length).replace(/^\//, "");
78
+ const s = platform === "win32" ? "\\" : "/";
79
+ const base = toDir.replace(/[\\/]+$/, "");
80
+ return rest ? base + s + rest.split("/").join(s) : base;
81
+ }
82
+
83
+ /** An MCP entry with every path into the skill folder moved to the copy. → { entry, linked } */
84
+ export function moveServerEntry(entry, fromDir, toDir, opts) {
85
+ let linked = false;
86
+ const mv = (v) => {
87
+ const m = movePath(v, fromDir, toDir, opts);
88
+ if (m == null) return v;
89
+ linked = true;
90
+ return m;
91
+ };
92
+ const out = { ...entry };
93
+ if (typeof out.command === "string") out.command = mv(out.command);
94
+ if (Array.isArray(out.args)) out.args = out.args.map((a) => (typeof a === "string" ? mv(a) : a));
95
+ if (out.env && typeof out.env === "object") {
96
+ out.env = Object.fromEntries(Object.entries(out.env).map(([k, v]) => [k, typeof v === "string" ? mv(v) : v]));
97
+ }
98
+ if (typeof out.cwd === "string") out.cwd = mv(out.cwd);
99
+ return { entry: out, linked };
100
+ }
101
+
102
+ /** Every file of a folder (following links), as relative path → { data, mode }. .DS_Store left out. */
103
+ function readTree(dir) {
104
+ const files = new Map();
105
+ const walk = (d) => {
106
+ for (const name of readdirSync(d)) {
107
+ if (name === ".DS_Store") continue;
108
+ const p = join(d, name);
109
+ let st;
110
+ try {
111
+ st = statSync(p);
112
+ } catch {
113
+ continue; // a dangling link
114
+ }
115
+ if (st.isDirectory()) walk(p);
116
+ else if (st.isFile()) files.set(relative(dir, p).split(sep).join("/"), { data: readFileSync(p), mode: st.mode & 0o777 });
117
+ }
118
+ };
119
+ walk(dir);
120
+ return files;
121
+ }
122
+
123
+ function sameTree(a, b) {
124
+ if (a.size !== b.size) return false;
125
+ for (const [k, v] of a) {
126
+ const w = b.get(k);
127
+ if (!w || !w.data.equals(v.data)) return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
133
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
134
+
135
+ /**
136
+ * What would be copied. Reads only. `names` (gu's or Claude's names) narrows it to those skills.
137
+ *
138
+ * → { items: [{ name, newName, scope, from, to, status: "new"|"same"|"differs", guLines, files,
139
+ * servers: [{ name, scope, entry, status }] }],
140
+ * warnings, missing: names asked for that gu does not have }
141
+ */
142
+ export function planSkillsCopy({ cwd = process.cwd(), home = homedir(), names = [], platform = process.platform } = {}) {
143
+ const dirs = [{ scope: "user", from: join(home, ".gonext", "skills"), to: join(home, ".claude", "skills") }];
144
+ if (cwd && resolve(cwd) !== resolve(home)) {
145
+ dirs.push({ scope: "project", from: join(cwd, ".gonext", "skills"), to: join(cwd, ".claude", "skills") });
146
+ }
147
+ const gu = readUserConfig(home);
148
+ const claude = readClaudeServers({ cwd, home });
149
+ const warnings = [];
150
+ if (gu.error) warnings.push(`~/.gonext/mcp.json is ${gu.error} — no MCP servers are copied with the skills`);
151
+ if (claude.error) warnings.push(`~/.claude.json is ${claude.error} — no MCP servers can be copied into it`);
152
+
153
+ const wanted = new Set(names.map((n) => String(n).replace(/^\//, "")));
154
+ const items = [];
155
+ for (const d of dirs) {
156
+ const scan = scanSkillsDir(d.from, d.scope);
157
+ warnings.push(...scan.warnings);
158
+ for (const s of scan.skills) {
159
+ const newName = claudeSkillName(s.name);
160
+ if (wanted.size && !wanted.has(s.name) && !wanted.has(newName)) continue;
161
+ const folder = s.dir.split(/[\\/]/).pop();
162
+ const to = join(d.to, newName);
163
+ const files = readTree(s.dir);
164
+ const md = files.get("SKILL.md");
165
+ const rewritten = rewriteSkillText(md.data.toString("utf8"), { from: s.name, to: newName });
166
+ // A folder named differently from its frontmatter name: rewrite its paths too.
167
+ const text = folder !== s.name ? rewriteSkillText(rewritten.text, { from: folder, to: newName }) : rewritten;
168
+ files.set("SKILL.md", { data: Buffer.from(text.text, "utf8"), mode: md.mode });
169
+
170
+ let status = "new";
171
+ if (existsSync(to)) status = sameTree(files, readTree(to)) ? "same" : "differs";
172
+
173
+ const servers = [];
174
+ if (!gu.error && !claude.error) {
175
+ const have = d.scope === "project" ? claude.local : claude.user;
176
+ for (const [sname, entry] of Object.entries(gu.mcpServers)) {
177
+ if (!isObj(entry)) continue;
178
+ const moved = moveServerEntry(entry, s.dir, to, { home, platform });
179
+ if (!moved.linked) continue;
180
+ const st = !(sname in have) ? "new" : sameJson(have[sname], moved.entry) ? "same" : "differs";
181
+ servers.push({ name: sname, scope: d.scope === "project" ? "local" : "user", entry: moved.entry, status: st });
182
+ }
183
+ }
184
+ items.push({ name: s.name, newName, scope: d.scope, from: s.dir, to, status, guLines: text.guLines, files, servers });
185
+ }
186
+ }
187
+ const have = new Set(items.flatMap((i) => [i.name, i.newName]));
188
+ const missing = [...wanted].filter((n) => !have.has(n));
189
+ return { items, warnings, missing };
190
+ }
191
+
192
+ function writeTree(dir, files) {
193
+ for (const [rel, f] of files) {
194
+ const p = join(dir, ...rel.split("/"));
195
+ mkdirSync(join(p, ".."), { recursive: true });
196
+ writeFileSync(p, f.data, { mode: f.mode || 0o644 });
197
+ }
198
+ }
199
+
200
+ /** Move a folder; across disks, copy then remove. */
201
+ function moveDir(from, to) {
202
+ mkdirSync(join(to, ".."), { recursive: true });
203
+ try {
204
+ renameSync(from, to);
205
+ } catch {
206
+ cpSync(from, to, { recursive: true });
207
+ rmSync(from, { recursive: true, force: true });
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Do it. `replace` = also overwrite skills and servers Claude has in a different version (the old
213
+ * folder is backed up first). → { done: [{ name, newName, to, action, backup? }],
214
+ * servers: [{ name, scope, action }], errors: string[] }
215
+ * action: "copied" | "replaced" | "kept" | "already"
216
+ */
217
+ export function applySkillsCopy(plan, { replace = false, cwd = process.cwd(), home = homedir(), now = new Date() } = {}) {
218
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
219
+ const backupRoot = join(home, ".gonext", BACKUP_DIR_NAME, stamp);
220
+ const done = [];
221
+ const servers = [];
222
+ const errors = [];
223
+ for (const it of plan.items) {
224
+ try {
225
+ if (it.status === "same") done.push({ name: it.name, newName: it.newName, to: it.to, action: "already" });
226
+ else if (it.status === "differs" && !replace) done.push({ name: it.name, newName: it.newName, to: it.to, action: "kept" });
227
+ else {
228
+ let backup;
229
+ if (it.status === "differs") {
230
+ backup = join(backupRoot, it.scope, it.newName);
231
+ moveDir(it.to, backup);
232
+ }
233
+ mkdirSync(it.to, { recursive: true });
234
+ writeTree(it.to, it.files);
235
+ done.push({ name: it.name, newName: it.newName, to: it.to, action: backup ? "replaced" : "copied", ...(backup ? { backup } : {}) });
236
+ }
237
+ } catch (err) {
238
+ errors.push(`${it.name}: ${err.message}`);
239
+ continue;
240
+ }
241
+ const write = it.servers.filter((s) => s.status === "new" || (s.status === "differs" && replace));
242
+ for (const s of it.servers) {
243
+ if (!write.includes(s)) servers.push({ name: s.name, scope: s.scope, action: s.status === "same" ? "already" : "kept" });
244
+ }
245
+ if (!write.length) continue;
246
+ try {
247
+ updateClaudeConfig((raw) => {
248
+ for (const s of write) {
249
+ let into;
250
+ if (s.scope === "local") {
251
+ raw.projects = isObj(raw.projects) ? raw.projects : {};
252
+ const key = resolve(cwd);
253
+ raw.projects[key] = isObj(raw.projects[key]) ? raw.projects[key] : {};
254
+ into = raw.projects[key];
255
+ } else into = raw;
256
+ into.mcpServers = isObj(into.mcpServers) ? into.mcpServers : {};
257
+ into.mcpServers[s.name] = s.entry;
258
+ }
259
+ }, home);
260
+ for (const s of write) servers.push({ name: s.name, scope: s.scope, action: s.status === "differs" ? "replaced" : "copied" });
261
+ } catch (err) {
262
+ errors.push(`MCP ${write.map((s) => s.name).join(", ")}: ${err.message}`);
263
+ }
264
+ }
265
+ return { done, servers, errors, backupRoot };
266
+ }
package/skills.mjs CHANGED
@@ -94,6 +94,9 @@ function isDir(p) {
94
94
  }
95
95
 
96
96
  /** One skills folder → { skills, warnings }. A folder that is not there is simply empty. */
97
+ export function scanSkillsDir(dir, scope) {
98
+ return scanDir(dir, scope);
99
+ }
97
100
  function scanDir(dir, scope) {
98
101
  const skills = [];
99
102
  const warnings = [];