@tiens.nguyen/gu-cli 1.0.871 → 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.
- package/coding-provider-setup.mjs +11 -6
- package/gu-repl.mjs +417 -231
- package/package.json +2 -1
- package/skills-copy.mjs +266 -0
- package/skills.mjs +3 -0
|
@@ -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
|
|
618
|
-
*
|
|
619
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
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
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
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
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
6460
|
-
if (
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
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
|
-
//
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
//
|
|
6623
|
-
|
|
6624
|
-
|
|
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
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
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
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
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
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
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
|
|
7027
|
-
|
|
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
|
|
7041
|
-
|
|
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
|
|
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"
|
|
8664
|
-
|
|
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
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
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
|
-
|
|
8890
|
-
if (firstAction === "
|
|
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.
|
|
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",
|
package/skills-copy.mjs
ADDED
|
@@ -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 = [];
|