@tiens.nguyen/gu-cli 1.0.773 → 1.0.775
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/cli-token.mjs +69 -0
- package/client-expert-run.mjs +20 -0
- package/gu-repl.mjs +241 -90
- package/package.json +2 -1
- package/preset-editor.mjs +187 -0
- package/setup-prompts.mjs +21 -2
- package/terminal-layout.mjs +24 -0
package/cli-token.mjs
CHANGED
|
@@ -185,6 +185,75 @@ export function trialStartedLine(endsAt, site = DEFAULT_SITE) {
|
|
|
185
185
|
`Then get a free token for 1 computer at ${accountUrl} — gu token <paste> links it.`;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* How the trial token is shown in the input: its ends, with "…" between (a whole token is ~520
|
|
190
|
+
* characters — five rows of input). Only ever compared, never saved: the real token is kept aside.
|
|
191
|
+
*/
|
|
192
|
+
export function shortToken(token) {
|
|
193
|
+
const t = String(token ?? "");
|
|
194
|
+
return t.length <= 24 ? t : `${t.slice(0, 16)}…${t.slice(-6)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305, the user's correction: "gu should still
|
|
199
|
+
* wait for the user to input a token, but show a default token and hint that it is a free trial
|
|
200
|
+
* for 10 days — not silently").
|
|
201
|
+
*
|
|
202
|
+
* Input token: gu1.eyJ2IjoxLCJz…Xk9aQw ← typed in: Enter takes it, or delete it
|
|
203
|
+
* This is a free trial token: 10 days on this computer, until 10 Oct 2026. ← yellow, under
|
|
204
|
+
* For a free token for 1 computer, log in at gu.example/account and create one — or buy Pro …
|
|
205
|
+
*
|
|
206
|
+
* The trial is fetched first (it is the same one every time for this computer). Nothing is saved
|
|
207
|
+
* without an answer: the shown default → the trial; anything else is checked as a pasted token;
|
|
208
|
+
* nothing at all (the default deleted) → skipped. Asked at the end of first-time setup, and before
|
|
209
|
+
* a private-mode turn whenever there is still no token.
|
|
210
|
+
*
|
|
211
|
+
* `ask(label, { prefill, hint })` → the typed line. `log(line, kind)` prints: "title" | "ok" | "warn".
|
|
212
|
+
* → { ok: true, kind: "trial" | "pasted" } | { ok: false, why: "skipped" | "refused" }
|
|
213
|
+
*/
|
|
214
|
+
export async function promptForToken({
|
|
215
|
+
ask,
|
|
216
|
+
dir,
|
|
217
|
+
log = (l) => console.log(l),
|
|
218
|
+
hostedBase,
|
|
219
|
+
site = siteUrl(),
|
|
220
|
+
device,
|
|
221
|
+
claim = claimTrial,
|
|
222
|
+
publicKeyB64 = publicKey(),
|
|
223
|
+
}) {
|
|
224
|
+
const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
|
|
225
|
+
const dev = device === undefined ? await trialDevice().catch(() => null) : device;
|
|
226
|
+
const trial = dev ? await claim({ hostedBase, device: dev }) : { ok: false, why: "no-device" };
|
|
227
|
+
const trialOk = trial.ok && verifyCliToken(trial.token, publicKeyB64).ok;
|
|
228
|
+
|
|
229
|
+
const shown = trialOk ? shortToken(trial.token) : "";
|
|
230
|
+
const getOne = `For a free token for 1 computer, log in at ${accountUrl} and create one — or buy Pro there for more computers.`;
|
|
231
|
+
const hint = trialOk
|
|
232
|
+
? [`This is a free trial token: 10 days on this computer, until ${shortDate(trial.endsAt)}.`, getOne, "Enter uses it — or delete it and paste your own token."]
|
|
233
|
+
: [
|
|
234
|
+
...(trial.why === "used" && trial.endedAt ? [`The free trial on this computer ended on ${shortDate(trial.endedAt)}.`] : []),
|
|
235
|
+
getOne,
|
|
236
|
+
"Paste it here — or Enter to skip for now.",
|
|
237
|
+
];
|
|
238
|
+
log("This computer needs a gu token before the agent can start — one time only.", "title");
|
|
239
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
240
|
+
const typed = String((await ask("Input token: ", { prefill: shown, hint })) ?? "").trim();
|
|
241
|
+
if (!typed) return { ok: false, why: "skipped" };
|
|
242
|
+
if (trialOk && (typed === shown || typed === trial.token)) {
|
|
243
|
+
await writeState(dir, { token: trial.token, lastAttemptAt: new Date().toISOString() });
|
|
244
|
+
log(`✓ free trial token saved — 10 days on this computer, until ${shortDate(trial.endsAt)}.`, "ok");
|
|
245
|
+
return { ok: true, kind: "trial" };
|
|
246
|
+
}
|
|
247
|
+
const saved = await saveTokenFromPaste(dir, typed, publicKeyB64);
|
|
248
|
+
if (saved.ok) {
|
|
249
|
+
log("✓ token saved — it renews itself from now on.", "ok");
|
|
250
|
+
return { ok: true, kind: "pasted" };
|
|
251
|
+
}
|
|
252
|
+
log(`That token was not saved: ${saved.reason}.`, "warn");
|
|
253
|
+
}
|
|
254
|
+
return { ok: false, why: "refused" };
|
|
255
|
+
}
|
|
256
|
+
|
|
188
257
|
/** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
|
|
189
258
|
export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
|
|
190
259
|
const raw = machineId ?? (await osMachineId({ platform }));
|
package/client-expert-run.mjs
CHANGED
|
@@ -1153,6 +1153,26 @@ export async function runClientExpertSetup({
|
|
|
1153
1153
|
await saveState(state);
|
|
1154
1154
|
}
|
|
1155
1155
|
|
|
1156
|
+
// THE gu TOKEN, AS THE LAST SETUP STEP (#305): private mode needs one before the first turn, so
|
|
1157
|
+
// it is asked while the person is still answering setup questions — with this computer's free
|
|
1158
|
+
// 10-day trial as the default (Enter), never taken silently. Skipped → asked at the first turn.
|
|
1159
|
+
try {
|
|
1160
|
+
const ct = await import("./cli-token.mjs");
|
|
1161
|
+
const had = await ct.readState(join(homedir(), ".gonext"));
|
|
1162
|
+
if (ct.publicKey() && !(had.token && ct.verifyCliToken(had.token, ct.publicKey()).ok)) {
|
|
1163
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
1164
|
+
console.log("");
|
|
1165
|
+
await ct.promptForToken({
|
|
1166
|
+
ask: (label, opts) => ask(" " + label, opts),
|
|
1167
|
+
dir: join(homedir(), ".gonext"),
|
|
1168
|
+
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
1169
|
+
log: (l, kind) => console.log(" " + (kind === "ok" ? cyan(l) : kind === "title" ? bold(l) : kind === "warn" ? yellow(l) : dim(l))),
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
} catch (e) {
|
|
1173
|
+
console.log(dim(` (the gu token can be added later: gu token <paste> — ${e?.message ?? e})`));
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1156
1176
|
console.log(
|
|
1157
1177
|
"\n " + cyan(bold("Local mode is set up.")) +
|
|
1158
1178
|
dim("\n Everything — conversations, token counts, workspaces — stays in MongoDB on this") +
|
package/gu-repl.mjs
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
inputPhysicalRows,
|
|
99
99
|
buildFooterRows,
|
|
100
100
|
pasteHint,
|
|
101
|
+
hintRows as questionHintRows,
|
|
101
102
|
footerDrawSeq,
|
|
102
103
|
footerEraseSeq,
|
|
103
104
|
clearBlockSeq,
|
|
@@ -657,8 +658,11 @@ if (!workerKey || !apiBase) {
|
|
|
657
658
|
input: process.stdin,
|
|
658
659
|
output: process.stdout,
|
|
659
660
|
});
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
// #305: a question with a typed-in default or a hint under it goes through askOn, which draws both.
|
|
662
|
+
const ask0 = async (q, opts) =>
|
|
663
|
+
opts
|
|
664
|
+
? String((await (await import("./setup-prompts.mjs")).askOn(rl0, q, { style: accent, ...opts })) || "")
|
|
665
|
+
: new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
|
|
662
666
|
let typed = "";
|
|
663
667
|
// Set when Client Expert setup stops on something the user must do first: the run ends
|
|
664
668
|
// after the instructions instead of continuing into questions that assume a paired,
|
|
@@ -879,8 +883,8 @@ const COMMANDS = [
|
|
|
879
883
|
// object {apiUrl, apiKey, agentModel, codingModel, embedModel} and validates every field
|
|
880
884
|
// before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
|
|
881
885
|
// location), so it is marked "gu": "preset" and the two commands never offer each other's files.
|
|
882
|
-
{ name: "/save-preset", usage:
|
|
883
|
-
{ name: "/load-presets", usage: "/load-presets [name]", desc: "pick a saved preset (
|
|
886
|
+
{ name: "/save-preset", usage: "/save-preset <name> [{json}]", desc: "save a named preset — server, API key and the agent/coding/embedding models — in your editor ($EDITOR, nano…), or as one line of JSON; all validated" },
|
|
887
|
+
{ name: "/load-presets", usage: "/load-presets [name]", desc: "pick a saved preset: Enter applies it (asks first) · v view · c copy as a command · j copy JSON · e edit" },
|
|
884
888
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
885
889
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
886
890
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
@@ -1477,6 +1481,7 @@ function applyClick({ row, col }) {
|
|
|
1477
1481
|
// A click in the SCROLLING area is not aimed at the caret — it may be aimed at a thought
|
|
1478
1482
|
// block asking to be opened (task #148). Checked before the input-row test so the two
|
|
1479
1483
|
// never compete for the same event.
|
|
1484
|
+
if (!onInputRow(row) && clickCopyButton(row, col)) return;
|
|
1480
1485
|
if (!onInputRow(row) && toggleThoughtAtRow(row)) return;
|
|
1481
1486
|
if (!onInputRow(row) || !clickOffsetAt) return;
|
|
1482
1487
|
rl.cursor = clickOffsetAt(inputRowIndex(row), col);
|
|
@@ -2186,31 +2191,6 @@ let startupRenewDone = false;
|
|
|
2186
2191
|
let licenceBlock = null;
|
|
2187
2192
|
let licenceDecided = false;
|
|
2188
2193
|
|
|
2189
|
-
/**
|
|
2190
|
-
* THE FREE TRIAL (#305): a first install gets ten days on this computer instead of a "paste your
|
|
2191
|
-
* token" stop. Once per computer — the gu service decides, keyed on the OS machine id's hash. → true
|
|
2192
|
-
* when a trial token is now saved. Tried at most once per start by the startup refresh, and again
|
|
2193
|
-
* right before the token question (a first start may have been offline).
|
|
2194
|
-
*/
|
|
2195
|
-
let trialTried = false;
|
|
2196
|
-
let trialEndedAt = ""; // this computer's trial has ended — the token question says so
|
|
2197
|
-
async function tryTrialToken(ct, dir, { announce = true } = {}) {
|
|
2198
|
-
const device = await ct.trialDevice().catch(() => null);
|
|
2199
|
-
if (!device) return false; // no stable machine id → no trial (see trialDevice)
|
|
2200
|
-
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
2201
|
-
const hostedBase = String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE;
|
|
2202
|
-
const r = await ct.claimTrial({ hostedBase, device });
|
|
2203
|
-
if (r.ok && ct.verifyCliToken(r.token, ct.publicKey()).ok) {
|
|
2204
|
-
await ct.writeState(dir, { token: r.token, lastAttemptAt: new Date().toISOString() });
|
|
2205
|
-
if (announce) {
|
|
2206
|
-
console.log(GUTTER + accent(r.created ? ct.trialStartedLine(r.endsAt, ct.siteUrl()) : `Free trial on this computer until ${ct.shortDate(r.endsAt)}.`));
|
|
2207
|
-
}
|
|
2208
|
-
return true;
|
|
2209
|
-
}
|
|
2210
|
-
if (r.why === "used" && r.endedAt) trialEndedAt = r.endedAt;
|
|
2211
|
-
return false;
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
2194
|
async function refreshCliToken() {
|
|
2215
2195
|
if (tokenBusy) return;
|
|
2216
2196
|
tokenBusy = true;
|
|
@@ -2225,11 +2205,6 @@ async function refreshCliToken() {
|
|
|
2225
2205
|
const mode = isClientExpert() ? "expert" : "client";
|
|
2226
2206
|
const site = ct.siteUrl();
|
|
2227
2207
|
let state = await ct.readState(dir);
|
|
2228
|
-
// #305: private mode with no usable token → this computer's free trial, before anything asks.
|
|
2229
|
-
if (mode === "expert" && !trialTried && !(state.token && ct.verifyCliToken(state.token, pk).ok)) {
|
|
2230
|
-
trialTried = true;
|
|
2231
|
-
if (await tryTrialToken(ct, dir)) state = await ct.readState(dir);
|
|
2232
|
-
}
|
|
2233
2208
|
const now = new Date();
|
|
2234
2209
|
const noticeNow = () =>
|
|
2235
2210
|
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
@@ -2511,6 +2486,9 @@ const footerRows = () =>
|
|
|
2511
2486
|
// Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
|
|
2512
2487
|
missing: missingNoticeText() ? red(missingNoticeText()) : "",
|
|
2513
2488
|
});
|
|
2489
|
+
// An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
|
|
2490
|
+
let askHint = [];
|
|
2491
|
+
const askHintRows = (width) => questionHintRows(askHint, { width, gutter: GUTTER, paint: yellow });
|
|
2514
2492
|
// True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
|
|
2515
2493
|
// prompt, so it must never appear under an ask()/askSecret() question or during a turn.
|
|
2516
2494
|
let footerActive = false;
|
|
@@ -2652,7 +2630,7 @@ function frozenZone() {
|
|
|
2652
2630
|
selection: secretInput ? null : inputSelection,
|
|
2653
2631
|
highlight: highlightRow,
|
|
2654
2632
|
});
|
|
2655
|
-
const rows = status.concat(zone.rows, footerRows());
|
|
2633
|
+
const rows = status.concat(zone.rows, askHintRows(width), footerRows());
|
|
2656
2634
|
// THE SEPARATOR IS NO LONGER ROW 0. With the status block prepended it sits `status.length`
|
|
2657
2635
|
// rows down, and every affordance below writes to it by index — a bare rows[0] would paint
|
|
2658
2636
|
// the jump hint over the thought line and hit-test the wrong row.
|
|
@@ -2787,7 +2765,7 @@ let inputSelection = null;
|
|
|
2787
2765
|
*/
|
|
2788
2766
|
const maxInputRows = () => {
|
|
2789
2767
|
const MIN_VIEW = 4;
|
|
2790
|
-
const spare = viewportRows() - footerRows().length - INPUT_HEAD_ROWS - MIN_VIEW;
|
|
2768
|
+
const spare = viewportRows() - footerRows().length - askHintRows(process.stdout.columns || 80).length - INPUT_HEAD_ROWS - MIN_VIEW;
|
|
2791
2769
|
return Math.max(1, Math.min(10, spare));
|
|
2792
2770
|
};
|
|
2793
2771
|
/**
|
|
@@ -3149,7 +3127,7 @@ async function copyAtRow(row) {
|
|
|
3149
3127
|
* `trailingBlanks` is how many blank lines the write left BELOW the block — see
|
|
3150
3128
|
* registeredRange, where getting it wrong silently shifts the whole range by one.
|
|
3151
3129
|
*/
|
|
3152
|
-
function registerCopyBlock(rowCount, text, trailingBlanks = 0) {
|
|
3130
|
+
function registerCopyBlock(rowCount, text, trailingBlanks = 0, extra = {}) {
|
|
3153
3131
|
if (!rowCount || !String(text ?? "").trim()) return;
|
|
3154
3132
|
const { from, to } = registeredRange({
|
|
3155
3133
|
dropped: transcript.dropped(),
|
|
@@ -3157,7 +3135,9 @@ function registerCopyBlock(rowCount, text, trailingBlanks = 0) {
|
|
|
3157
3135
|
rowCount,
|
|
3158
3136
|
trailingBlanks,
|
|
3159
3137
|
});
|
|
3160
|
-
|
|
3138
|
+
// `extra.button` = { from, to } columns on the block's FIRST row that copy on a single click
|
|
3139
|
+
// (the preset block's "⧉ copy", #312); `extra.note` is the badge it shows.
|
|
3140
|
+
copyBlocks.push({ from, to, text: String(text), ...extra });
|
|
3161
3141
|
if (copyBlocks.length > 200) copyBlocks = copyBlocks.slice(-200);
|
|
3162
3142
|
}
|
|
3163
3143
|
|
|
@@ -3397,6 +3377,30 @@ function paintCopyBadge(row, place, ok = true) {
|
|
|
3397
3377
|
}, 2000);
|
|
3398
3378
|
}
|
|
3399
3379
|
|
|
3380
|
+
/**
|
|
3381
|
+
* A single click on a block's "⧉ copy" (#312) → copy its source text. → true when the click was
|
|
3382
|
+
* on a button (handled), false to let the click do whatever else it does.
|
|
3383
|
+
*/
|
|
3384
|
+
function clickCopyButton(row, col) {
|
|
3385
|
+
if (!barPinned() || !copyBlocks.length) return false;
|
|
3386
|
+
const width = Math.max(20, process.stdout.columns || 80) - 1;
|
|
3387
|
+
const owners = displayRowOwners(transcript.lines(), width);
|
|
3388
|
+
const logical = lineAtViewportRow(owners, row, transcriptRows(), scrollOffset);
|
|
3389
|
+
if (logical < 0) return false;
|
|
3390
|
+
const abs = transcript.dropped() + logical;
|
|
3391
|
+
const b = blockAt(copyBlocks, abs);
|
|
3392
|
+
if (!b?.button || abs !== b.from || col - 1 < b.button.from || col - 1 >= b.button.to) return false;
|
|
3393
|
+
void (async () => {
|
|
3394
|
+
try {
|
|
3395
|
+
const res = await writeClipboard(b.text);
|
|
3396
|
+
showCopyBadge(row, res.ok ? b.note || "copied" : copyBadge({ ok: false, why: res.why }), res.ok);
|
|
3397
|
+
} catch {
|
|
3398
|
+
/* a failed copy must never take the session down */
|
|
3399
|
+
}
|
|
3400
|
+
})();
|
|
3401
|
+
return true;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3400
3404
|
function copyTargetAtRow(row) {
|
|
3401
3405
|
if (!barPinned()) return null;
|
|
3402
3406
|
const width = Math.max(20, process.stdout.columns || 80) - 1;
|
|
@@ -4268,7 +4272,7 @@ function maybeCancelPrompt(str, key) {
|
|
|
4268
4272
|
submitSyntheticLine(CANCEL_LINE);
|
|
4269
4273
|
return true;
|
|
4270
4274
|
}
|
|
4271
|
-
const ask = async (q) => {
|
|
4275
|
+
const ask = async (q, { prefill = "", hint = [] } = {}) => {
|
|
4272
4276
|
// Route the question through readline's own prompt so its redraw logic stays
|
|
4273
4277
|
// consistent (a raw stdout write here would re-create the stale-cursor artifact
|
|
4274
4278
|
// that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
|
|
@@ -4333,6 +4337,13 @@ const ask = async (q) => {
|
|
|
4333
4337
|
// the turn's empty-prompt behaviour must come back.
|
|
4334
4338
|
const wasVisible = inputVisible;
|
|
4335
4339
|
inputVisible = true;
|
|
4340
|
+
// #305: a default typed in for the person to take (Enter) or delete, and a hint drawn under
|
|
4341
|
+
// the input (frozenZone) — both only for this question.
|
|
4342
|
+
askHint = hint;
|
|
4343
|
+
if (prefill) {
|
|
4344
|
+
rl.line = String(prefill);
|
|
4345
|
+
rl.cursor = rl.line.length;
|
|
4346
|
+
}
|
|
4336
4347
|
// ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.gomarsic.cc/v1]:"
|
|
4337
4348
|
// to "…[https:/" hides the default Enter would accept, which is worse than not showing it —
|
|
4338
4349
|
// the reader cannot tell whether the value is short or the row lied. Callers should keep the
|
|
@@ -4350,13 +4361,17 @@ const ask = async (q) => {
|
|
|
4350
4361
|
return (a ?? "").trim();
|
|
4351
4362
|
} finally {
|
|
4352
4363
|
askWaiting = false;
|
|
4364
|
+
askHint = [];
|
|
4353
4365
|
inlinePrompt = "";
|
|
4354
4366
|
inputVisible = wasVisible;
|
|
4355
4367
|
paintBar(); // put ">> " back, or the next question wears this one's label
|
|
4356
4368
|
}
|
|
4357
4369
|
}
|
|
4370
|
+
// Unpinned (piped output, a short window): no frozen zone to draw under, so the hint goes above.
|
|
4371
|
+
for (const l of hint) console.log(GUTTER + yellow(l));
|
|
4358
4372
|
rl.setPrompt(gutterLines(q));
|
|
4359
4373
|
rl.prompt();
|
|
4374
|
+
if (prefill) rl.write(String(prefill));
|
|
4360
4375
|
let a;
|
|
4361
4376
|
try {
|
|
4362
4377
|
askWaiting = true;
|
|
@@ -6712,24 +6727,70 @@ function settingsArg(line, word) {
|
|
|
6712
6727
|
}
|
|
6713
6728
|
|
|
6714
6729
|
/**
|
|
6715
|
-
*
|
|
6716
|
-
*
|
|
6717
|
-
*
|
|
6718
|
-
*
|
|
6719
|
-
*
|
|
6720
|
-
*
|
|
6730
|
+
* Give the terminal to a full-screen program (an editor, #312) and take it back afterwards.
|
|
6731
|
+
*
|
|
6732
|
+
* gu owns the screen while it runs: a pinned frozen zone (DECSTBM), raw mode, mouse reporting and
|
|
6733
|
+
* bracketed paste. An editor started on top of that would get gu's scroll region and mouse bytes,
|
|
6734
|
+
* and gu's readline would keep reading the keys meant for the editor. Everything is switched off
|
|
6735
|
+
* first and switched back on — and the view repainted from the transcript — whatever happens.
|
|
6736
|
+
*/
|
|
6737
|
+
function withTerminalHandedOver(run) {
|
|
6738
|
+
const wasPinned = barPinned();
|
|
6739
|
+
const hadClick = _clickEnabled;
|
|
6740
|
+
disableMouse();
|
|
6741
|
+
disableClickToPosition();
|
|
6742
|
+
ui(PASTE_MODE_OFF);
|
|
6743
|
+
unpinBar();
|
|
6744
|
+
const wasRaw = Boolean(process.stdin.isRaw);
|
|
6745
|
+
try {
|
|
6746
|
+
process.stdin.setRawMode?.(false);
|
|
6747
|
+
} catch {
|
|
6748
|
+
/* not a TTY — nothing to switch */
|
|
6749
|
+
}
|
|
6750
|
+
rl.pause();
|
|
6751
|
+
try {
|
|
6752
|
+
return run();
|
|
6753
|
+
} finally {
|
|
6754
|
+
try {
|
|
6755
|
+
process.stdin.setRawMode?.(wasRaw);
|
|
6756
|
+
} catch {
|
|
6757
|
+
/* not a TTY */
|
|
6758
|
+
}
|
|
6759
|
+
rl.resume();
|
|
6760
|
+
ui(PASTE_MODE_ON);
|
|
6761
|
+
if (hadClick) enableClickToPosition();
|
|
6762
|
+
if (wasPinned) {
|
|
6763
|
+
ui("\x1b[2J");
|
|
6764
|
+
pinBar();
|
|
6765
|
+
paintLiveTail();
|
|
6766
|
+
}
|
|
6767
|
+
}
|
|
6768
|
+
}
|
|
6769
|
+
|
|
6770
|
+
/**
|
|
6771
|
+
* /save-preset <name> — THE PRESET IN AN EDITOR (#312). Opens $VISUAL / $EDITOR / nano / vi
|
|
6772
|
+
* (Notepad on Windows) on a temp file filled in with the preset being edited or the current
|
|
6773
|
+
* setup; save and quit stores it, quit without a change cancels. A wrong value is named and the
|
|
6774
|
+
* editor reopens with the person's OWN text, never the template again.
|
|
6775
|
+
*
|
|
6776
|
+
* /save-preset <name> <json> — the one-line form (#6), kept for scripts and for pasting the
|
|
6777
|
+
* command /load-presets copies. Both validate everything before a byte is written. A preset SAVES
|
|
6778
|
+
* the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
|
|
6779
|
+
* "gu": "preset" so the two commands never offer each other's files.
|
|
6721
6780
|
*/
|
|
6722
6781
|
async function runSavePresetCommand(line) {
|
|
6723
6782
|
const pr = await import("./presets-transfer.mjs");
|
|
6724
6783
|
const home = homedir();
|
|
6725
6784
|
console.log("");
|
|
6726
|
-
const usage =
|
|
6727
|
-
|
|
6728
|
-
const rest = line.trim().slice("/save-preset".length).trim();
|
|
6785
|
+
const usage = "usage: /save-preset <name> (opens your editor) or /save-preset <name> {\"apiUrl\":\"…\",\"apiKey\":\"…\",\"agentModel\":\"…\",\"codingModel\":\"…\",\"embedModel\":\"…\"}";
|
|
6786
|
+
let rest = line.trim().slice("/save-preset".length).trim().replace(/(^|\s)--edit(?=\s|$)/, " ").trim();
|
|
6729
6787
|
if (!rest) {
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6788
|
+
const typed = String((await ask("preset name: ")) ?? "").trim();
|
|
6789
|
+
if (!typed) {
|
|
6790
|
+
console.log(GUTTER + dim("cancelled — nothing saved.") + "\n");
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6793
|
+
rest = typed;
|
|
6733
6794
|
}
|
|
6734
6795
|
const space = rest.search(/\s/);
|
|
6735
6796
|
const name = space === -1 ? rest : rest.slice(0, space);
|
|
@@ -6739,22 +6800,68 @@ async function runSavePresetCommand(line) {
|
|
|
6739
6800
|
console.log("");
|
|
6740
6801
|
return;
|
|
6741
6802
|
}
|
|
6742
|
-
|
|
6743
|
-
|
|
6803
|
+
const file = pr.presetFilePath(name, home);
|
|
6804
|
+
const store = (preset) => {
|
|
6805
|
+
writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
|
|
6806
|
+
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
6807
|
+
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6744
6808
|
console.log("");
|
|
6809
|
+
};
|
|
6810
|
+
if (json) {
|
|
6811
|
+
const checked = pr.validatePreset(json);
|
|
6812
|
+
if (!checked.ok) {
|
|
6813
|
+
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6814
|
+
console.log("");
|
|
6815
|
+
return;
|
|
6816
|
+
}
|
|
6817
|
+
store(checked.preset);
|
|
6745
6818
|
return;
|
|
6746
6819
|
}
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6820
|
+
|
|
6821
|
+
// THE EDITOR. Filled in from the preset being edited, else from what this account runs now.
|
|
6822
|
+
const ed = await import("./preset-editor.mjs");
|
|
6823
|
+
const editor = ed.pickEditor();
|
|
6824
|
+
if (!editor || !process.stdin.isTTY) {
|
|
6825
|
+
console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
|
|
6750
6826
|
console.log("");
|
|
6751
6827
|
return;
|
|
6752
6828
|
}
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6829
|
+
let start = null;
|
|
6830
|
+
if (existsSync(file)) {
|
|
6831
|
+
try {
|
|
6832
|
+
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
6833
|
+
if (doc?.gu === "preset") start = doc;
|
|
6834
|
+
} catch {
|
|
6835
|
+
/* unreadable — start from the current setup instead */
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
if (!start) start = ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6839
|
+
const initial = ed.editorText(name, start);
|
|
6840
|
+
console.log(GUTTER + dim(`${start.gu ? "editing" : "new"} preset "${name}" in ${basename(editor.cmd)} — save and quit to store it, quit without changes to cancel.`));
|
|
6841
|
+
let text = initial;
|
|
6842
|
+
for (;;) {
|
|
6843
|
+
const r = withTerminalHandedOver(() => ed.editText(text, { editor, fileName: `${name}.json` }));
|
|
6844
|
+
if (!r.ok) {
|
|
6845
|
+
console.log(GUTTER + `Preset not saved — ${r.why}.`);
|
|
6846
|
+
console.log("");
|
|
6847
|
+
return;
|
|
6848
|
+
}
|
|
6849
|
+
if (r.text === initial) {
|
|
6850
|
+
console.log(GUTTER + dim("Not saved — nothing changed.") + "\n");
|
|
6851
|
+
return;
|
|
6852
|
+
}
|
|
6853
|
+
const parsed = ed.parseEdited(r.text);
|
|
6854
|
+
if (parsed.ok) {
|
|
6855
|
+
store(parsed.preset);
|
|
6856
|
+
return;
|
|
6857
|
+
}
|
|
6858
|
+
console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
|
|
6859
|
+
if (!(await askYesNo("Edit again?", true))) {
|
|
6860
|
+
console.log(GUTTER + dim("Not saved.") + "\n");
|
|
6861
|
+
return;
|
|
6862
|
+
}
|
|
6863
|
+
text = r.text; // their text, with the mistake in it — not the template again
|
|
6864
|
+
}
|
|
6758
6865
|
}
|
|
6759
6866
|
|
|
6760
6867
|
/**
|
|
@@ -6887,26 +6994,61 @@ async function runLoadPresetsCommand(line) {
|
|
|
6887
6994
|
return;
|
|
6888
6995
|
}
|
|
6889
6996
|
} else {
|
|
6997
|
+
// THE PICKER DOES MORE THAN APPLY (#312): v shows the whole preset (key masked) with a
|
|
6998
|
+
// clickable "⧉ copy", c / j copy it as the /save-preset command or as JSON (key included), e
|
|
6999
|
+
// opens it in the editor. Only Enter applies. Each of the others ENDS the command: the mouse
|
|
7000
|
+
// is gu's only at the ">> " prompt (never while a picker is up), so a "⧉ copy" printed under a
|
|
7001
|
+
// picker that stayed open could not be clicked until it closed.
|
|
7002
|
+
const ed = await import("./preset-editor.mjs");
|
|
6890
7003
|
const found = pr.presetFilesIn(listJsonFiles(home));
|
|
6891
7004
|
if (found.length === 0) {
|
|
6892
|
-
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name
|
|
7005
|
+
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name>`);
|
|
6893
7006
|
console.log("");
|
|
6894
7007
|
return;
|
|
6895
7008
|
}
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
console.log(GUTTER + `one preset here: ${found[0]}`);
|
|
6900
|
-
} else {
|
|
6901
|
-
console.log(dim(` Presets in ${home} (${PICK_HINT}):`));
|
|
6902
|
-
pick = await pickFromList(found.map((n) => ({ label: n })));
|
|
6903
|
-
}
|
|
6904
|
-
if (pick < 0) {
|
|
7009
|
+
console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
|
|
7010
|
+
const pick = await pickFromList(found, 0, { keys: ed.PICKER_KEYS });
|
|
7011
|
+
if (!pick || pick.index < 0) {
|
|
6905
7012
|
console.log(GUTTER + "load cancelled.");
|
|
6906
7013
|
console.log("");
|
|
6907
7014
|
return;
|
|
6908
7015
|
}
|
|
6909
|
-
file = pr.presetFilePath(found[pick], home);
|
|
7016
|
+
file = pr.presetFilePath(found[pick.index], home);
|
|
7017
|
+
if (pick.action === "edit") {
|
|
7018
|
+
await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
|
|
7019
|
+
return;
|
|
7020
|
+
}
|
|
7021
|
+
if (pick.action !== "select") {
|
|
7022
|
+
const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
7023
|
+
if (!shown.ok) {
|
|
7024
|
+
console.log(GUTTER + `Can't show it — ${shown.problem}`);
|
|
7025
|
+
console.log("");
|
|
7026
|
+
return;
|
|
7027
|
+
}
|
|
7028
|
+
const pname = shown.name || found[pick.index];
|
|
7029
|
+
const command = ed.presetCopyCommand(pname, shown.preset);
|
|
7030
|
+
if (pick.action === "view") {
|
|
7031
|
+
const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
|
|
7032
|
+
const [top, ...restRows] = block.rows;
|
|
7033
|
+
const cut = block.button.from;
|
|
7034
|
+
console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
|
|
7035
|
+
for (const r of restRows) console.log(r);
|
|
7036
|
+
registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
|
|
7037
|
+
console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
|
|
7038
|
+
console.log(GUTTER + dim(`/load-presets ${pname} applies it.`));
|
|
7039
|
+
console.log("");
|
|
7040
|
+
return;
|
|
7041
|
+
}
|
|
7042
|
+
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
|
|
7043
|
+
console.log(
|
|
7044
|
+
GUTTER +
|
|
7045
|
+
(res.ok
|
|
7046
|
+
? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
|
|
7047
|
+
: yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
|
|
7048
|
+
);
|
|
7049
|
+
console.log("");
|
|
7050
|
+
return;
|
|
7051
|
+
}
|
|
6910
7052
|
}
|
|
6911
7053
|
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
6912
7054
|
if (!checked.ok) {
|
|
@@ -9148,6 +9290,10 @@ let listAllowDelete = false;
|
|
|
9148
9290
|
// Set by the ctrl+d branch and read by finishList, so the resolved value can say WHICH action
|
|
9149
9291
|
// happened rather than encoding it into the index and hoping every caller decodes it.
|
|
9150
9292
|
let listDeleteRequested = false;
|
|
9293
|
+
// Single letters that finish a single-select picker with an ACTION on the highlighted row, e.g.
|
|
9294
|
+
// /load-presets' { v: "view", c: "copy" } (#312). Null unless a caller asks, like ctrl+d above.
|
|
9295
|
+
let listKeys = null;
|
|
9296
|
+
let listKeyAction = "";
|
|
9151
9297
|
function drawList(redraw) {
|
|
9152
9298
|
// Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
|
|
9153
9299
|
// every other in-place block: a long item (a model id on a narrow terminal) that wrapped
|
|
@@ -9210,6 +9356,10 @@ function finishList(index) {
|
|
|
9210
9356
|
const deleting = listDeleteRequested;
|
|
9211
9357
|
listAllowDelete = false;
|
|
9212
9358
|
listDeleteRequested = false;
|
|
9359
|
+
const keyed = listKeys;
|
|
9360
|
+
const keyAction = listKeyAction;
|
|
9361
|
+
listKeys = null;
|
|
9362
|
+
listKeyAction = "";
|
|
9213
9363
|
// Multi-select resolves with the TICKED indices — and with null, not [], when cancelled: an
|
|
9214
9364
|
// empty selection is a legitimate answer ("offer none of them"), so it must be distinguishable
|
|
9215
9365
|
// from Escape, or cancelling would silently save "none".
|
|
@@ -9223,7 +9373,9 @@ function finishList(index) {
|
|
|
9223
9373
|
: checked
|
|
9224
9374
|
: allowDelete
|
|
9225
9375
|
? { index, action: index >= 0 && deleting ? "delete" : "select" }
|
|
9226
|
-
:
|
|
9376
|
+
: keyed
|
|
9377
|
+
? { index, action: index >= 0 ? keyAction || "select" : "cancel" }
|
|
9378
|
+
: index
|
|
9227
9379
|
);
|
|
9228
9380
|
}
|
|
9229
9381
|
}
|
|
@@ -9278,6 +9430,9 @@ function onListKey(key) {
|
|
|
9278
9430
|
// Enter still selects; nothing about the ordinary path changes.
|
|
9279
9431
|
listDeleteRequested = true;
|
|
9280
9432
|
finishList(listSel);
|
|
9433
|
+
} else if (listKeys && !listMulti && !key.ctrl && !key.meta && key.name && Object.hasOwn(listKeys, key.name)) {
|
|
9434
|
+
listKeyAction = listKeys[key.name];
|
|
9435
|
+
finishList(listSel);
|
|
9281
9436
|
} else if (key.name === "return" || key.name === "enter") {
|
|
9282
9437
|
finishList(listSel);
|
|
9283
9438
|
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
@@ -9322,6 +9477,8 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
9322
9477
|
listMulti = false; // explicit: a cancelled multi-select must not leak its mode into this one
|
|
9323
9478
|
listAllowDelete = opts.allowDelete === true;
|
|
9324
9479
|
listDeleteRequested = false;
|
|
9480
|
+
listKeys = opts.keys && typeof opts.keys === "object" ? opts.keys : null;
|
|
9481
|
+
listKeyAction = "";
|
|
9325
9482
|
listChecked = new Set();
|
|
9326
9483
|
listItems = items;
|
|
9327
9484
|
listTop = 0;
|
|
@@ -9759,26 +9916,20 @@ async function ensureTokenForTurn() {
|
|
|
9759
9916
|
const state = await ct.readState(dir);
|
|
9760
9917
|
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
|
|
9761
9918
|
console.log("");
|
|
9762
|
-
// #305: the
|
|
9763
|
-
|
|
9919
|
+
// #305: the question, with this computer's free trial as the default — offered, never taken
|
|
9920
|
+
// silently (the user's rule). Enter takes the trial; a pasted token replaces it.
|
|
9921
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
9922
|
+
const r = await ct.promptForToken({
|
|
9923
|
+
ask: (label, opts) => ask(" " + label, opts),
|
|
9924
|
+
dir,
|
|
9925
|
+
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
9926
|
+
log: (l, kind) =>
|
|
9927
|
+
console.log(GUTTER + (kind === "title" ? yellow(l) : kind === "ok" ? accent(l) : kind === "warn" ? yellow(l) : dim(l))),
|
|
9928
|
+
});
|
|
9929
|
+
if (r.ok) {
|
|
9764
9930
|
console.log("");
|
|
9765
9931
|
return true;
|
|
9766
9932
|
}
|
|
9767
|
-
if (trialEndedAt) console.log(GUTTER + dim(`The free trial on this computer ended on ${ct.shortDate(trialEndedAt)}.`));
|
|
9768
|
-
for (const l of ct.tokenAskLines()) console.log(GUTTER + (l.startsWith(" ") ? dim(l) : yellow(l)));
|
|
9769
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
9770
|
-
const pasted = String((await ask(" gu token: ")) ?? "").trim();
|
|
9771
|
-
if (!pasted) {
|
|
9772
|
-
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
9773
|
-
return false;
|
|
9774
|
-
}
|
|
9775
|
-
const saved = await ct.saveTokenFromPaste(dir, pasted);
|
|
9776
|
-
if (saved.ok) {
|
|
9777
|
-
console.log(GUTTER + accent("✓ token saved") + dim(" — it renews itself from now on.") + "\n");
|
|
9778
|
-
return true;
|
|
9779
|
-
}
|
|
9780
|
-
console.log(GUTTER + yellow(`That token was not saved: ${saved.reason}.`));
|
|
9781
|
-
}
|
|
9782
9933
|
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
9783
9934
|
return false;
|
|
9784
9935
|
}
|
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.775",
|
|
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",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"credential-recovery.mjs",
|
|
66
66
|
"settings-transfer.mjs",
|
|
67
67
|
"presets-transfer.mjs",
|
|
68
|
+
"preset-editor.mjs",
|
|
68
69
|
"device-login.mjs",
|
|
69
70
|
"daemon-control.mjs",
|
|
70
71
|
"log-policy.mjs",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRESETS IN A REAL EDITOR, AND OUT AGAIN AS TEXT (#312).
|
|
3
|
+
*
|
|
4
|
+
* Asked for: "it doesn't allow to input the presets as a text editor, we need to allow with
|
|
5
|
+
* something like sudo nano … and for load-preset should load all as a long text somehow so that
|
|
6
|
+
* I can copy with a copy button or icon".
|
|
7
|
+
*
|
|
8
|
+
* · /save-preset <name> opens the person's own editor ($VISUAL, $EDITOR, then nano, then vi;
|
|
9
|
+
* Notepad on Windows) on a temp file already filled in — the current setup, or the preset
|
|
10
|
+
* being edited — so it is edited rather than typed from nothing. NOT sudo: the preset is the
|
|
11
|
+
* user's own 0600 file; root would make it root-owned and ask for a password for nothing.
|
|
12
|
+
* · The temp file holds a live key: it is created 0600 inside a private 0700 directory and the
|
|
13
|
+
* directory is removed on every path out, a Ctrl+C included.
|
|
14
|
+
* · /load-presets can show a preset as one framed block — the key MASKED on screen (scrollback
|
|
15
|
+
* and logs must not keep it, #248) — and copy it with the real key: as the ready-to-paste
|
|
16
|
+
* `/save-preset <name> {…}` command (paste it into gu on another computer and it is there),
|
|
17
|
+
* or as plain JSON.
|
|
18
|
+
*
|
|
19
|
+
* Pure helpers plus one spawn; the terminal hand-over (unpinning gu's frozen zone, raw mode, the
|
|
20
|
+
* mouse) is the REPL's job, because only the REPL knows what it switched on.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { validatePreset } from "./presets-transfer.mjs";
|
|
27
|
+
|
|
28
|
+
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Which editor to run → { cmd, args }. $VISUAL then $EDITOR (either may carry arguments, e.g.
|
|
32
|
+
* "code --wait"), then the first of nano / vi that exists; Notepad on Windows, which blocks until
|
|
33
|
+
* its window closes. `has(cmd)` says whether a program is on PATH (injected for tests).
|
|
34
|
+
*/
|
|
35
|
+
export function pickEditor({ env = process.env, platform = process.platform, has = onPath } = {}) {
|
|
36
|
+
for (const v of [env.VISUAL, env.EDITOR]) {
|
|
37
|
+
const words = splitCommand(v);
|
|
38
|
+
if (words.length) return { cmd: words[0], args: words.slice(1) };
|
|
39
|
+
}
|
|
40
|
+
if (platform === "win32") return { cmd: "notepad", args: [] };
|
|
41
|
+
for (const cmd of ["nano", "vi"]) if (has(cmd)) return { cmd, args: [] };
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** "code --wait" → ["code", "--wait"]; quotes group ("'/Applications/My Editor' -w"). */
|
|
46
|
+
export function splitCommand(s) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(String(s ?? "")))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onPath(cmd) {
|
|
55
|
+
const probe = process.platform === "win32" ? spawnSync("where", [cmd]) : spawnSync("/bin/sh", ["-c", `command -v ${cmd}`]);
|
|
56
|
+
return probe.status === 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The preset as it would be in a preset file, from whatever is set on this account now. The key
|
|
61
|
+
* is never known here (the API keeps it), so it is left empty for the person to fill in.
|
|
62
|
+
*/
|
|
63
|
+
export function presetFromSettings(settings = {}) {
|
|
64
|
+
const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
|
|
65
|
+
const agentUrl = clean(settings.agentModelUrl);
|
|
66
|
+
const norm = (u) => clean(u).replace(/\/+$/, "").toLowerCase();
|
|
67
|
+
const row = rows.find((r) => agentUrl && norm(r?.url) === norm(agentUrl)) ?? rows.find((r) => r?.enabled !== false) ?? rows[0] ?? {};
|
|
68
|
+
const apiUrl = agentUrl || clean(row.url);
|
|
69
|
+
return {
|
|
70
|
+
apiUrl,
|
|
71
|
+
apiKey: "",
|
|
72
|
+
agentModel: clean(settings.agentModelName),
|
|
73
|
+
codingModel: clean(row.defaultModel),
|
|
74
|
+
embedModel: clean(settings.ragEmbedModel),
|
|
75
|
+
embedUrl: clean(settings.ragEmbedUrl) || apiUrl,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FIELDS = ["apiUrl", "apiKey", "agentModel", "codingModel", "embedModel", "embedUrl"];
|
|
80
|
+
|
|
81
|
+
/** The text the editor opens with: a few `//` lines saying what to do, then the JSON. */
|
|
82
|
+
export function editorText(name, preset = {}) {
|
|
83
|
+
const body = {};
|
|
84
|
+
for (const f of FIELDS) body[f] = clean(preset[f]);
|
|
85
|
+
return (
|
|
86
|
+
[
|
|
87
|
+
`// gu preset "${name}" — edit the values, then save and quit to store it.`,
|
|
88
|
+
"// Every field is needed except embedUrl (it defaults to apiUrl). Lines starting with // are ignored.",
|
|
89
|
+
"// Quit without saving (or leave it unchanged) to cancel. The file you save CONTAINS your API key.",
|
|
90
|
+
].join("\n") +
|
|
91
|
+
"\n" +
|
|
92
|
+
JSON.stringify(body, null, 2) +
|
|
93
|
+
"\n"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What came back from the editor → { ok, preset } | { ok: false, problem }. The // lines are
|
|
99
|
+
* blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
|
|
100
|
+
*/
|
|
101
|
+
export function parseEdited(text) {
|
|
102
|
+
const src = String(text ?? "").replace(/^[ \t]*\/\/.*$/gm, "");
|
|
103
|
+
if (!src.trim()) return { ok: false, empty: true, problem: "the file is empty." };
|
|
104
|
+
try {
|
|
105
|
+
JSON.parse(src);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
const at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
|
|
108
|
+
const where = at ? ` (line ${at[1]}, column ${at[2]})` : "";
|
|
109
|
+
return { ok: false, problem: `that is not valid JSON${where} — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$|\s*\(line \d+ column \d+\)/g, "")}.` };
|
|
110
|
+
}
|
|
111
|
+
return validatePreset(src);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Open `initial` in `editor` and return what was saved: { ok: true, text } | { ok: false, why }.
|
|
116
|
+
* The temp file is 0600 in a 0700 directory, and the directory is removed whatever happens.
|
|
117
|
+
* `run` is spawnSync with the terminal inherited (injected for tests).
|
|
118
|
+
*/
|
|
119
|
+
export function editText(initial, { editor, run = spawnSync, tmpRoot = tmpdir(), fileName = "gu-preset.json" } = {}) {
|
|
120
|
+
if (!editor) return { ok: false, why: "no editor found — set $EDITOR (e.g. export EDITOR=nano)" };
|
|
121
|
+
const dir = mkdtempSync(join(tmpRoot, "gu-preset-"));
|
|
122
|
+
try {
|
|
123
|
+
chmodSync(dir, 0o700);
|
|
124
|
+
const file = join(dir, fileName);
|
|
125
|
+
writeFileSync(file, initial, { mode: 0o600 });
|
|
126
|
+
const r = run(editor.cmd, [...editor.args, file], { stdio: "inherit" });
|
|
127
|
+
if (r?.error) return { ok: false, why: `${editor.cmd} could not start — ${r.error.message}` };
|
|
128
|
+
if (typeof r?.status === "number" && r.status !== 0) return { ok: false, why: `${editor.cmd} exited with status ${r.status} — nothing saved` };
|
|
129
|
+
return { ok: true, text: readFileSync(file, "utf8") };
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A key as it may appear on screen: its first few and last four characters. */
|
|
136
|
+
export function maskKey(key) {
|
|
137
|
+
const k = clean(key);
|
|
138
|
+
if (!k) return "";
|
|
139
|
+
if (k.length <= 8) return "…" + k.slice(-2);
|
|
140
|
+
const head = k.startsWith("sk-") ? "sk-" : k.slice(0, 3);
|
|
141
|
+
return `${head}…${k.slice(-4)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const presetBody = (preset) => {
|
|
145
|
+
const o = {};
|
|
146
|
+
for (const f of FIELDS) if (f !== "embedUrl" || clean(preset.embedUrl) !== clean(preset.apiUrl)) o[f] = clean(preset[f]);
|
|
147
|
+
return o;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key. */
|
|
151
|
+
export function presetCopyCommand(name, preset) {
|
|
152
|
+
return `/save-preset ${clean(name)} ${JSON.stringify(presetBody(preset))}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** …or just the JSON, pretty, WITH the key. */
|
|
156
|
+
export function presetCopyJson(preset) {
|
|
157
|
+
return JSON.stringify(presetBody(preset), null, 2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The framed block /load-presets prints for "view": the key masked, "⧉ copy" in the top border.
|
|
162
|
+
* Every row fits `width - 1` so none wraps (a wrapped row breaks the click arithmetic, #109).
|
|
163
|
+
* → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
|
|
164
|
+
*/
|
|
165
|
+
export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
166
|
+
const shown = { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
167
|
+
const inner = JSON.stringify(shown, null, 2).split("\n");
|
|
168
|
+
const room = Math.max(24, width - 1 - gutter.length);
|
|
169
|
+
const innerW = Math.min(room - 4, Math.max(...inner.map((l) => [...l].length), 30));
|
|
170
|
+
const fit = (l) => {
|
|
171
|
+
const cs = [...l];
|
|
172
|
+
return cs.length > innerW ? cs.slice(0, innerW - 1).join("") + "…" : l + " ".repeat(innerW - cs.length);
|
|
173
|
+
};
|
|
174
|
+
const title = `┌ preset "${clean(name)}" `;
|
|
175
|
+
const copy = " ⧉ copy ";
|
|
176
|
+
const dashes = Math.max(1, innerW + 4 - [...title].length - [...copy].length - 1);
|
|
177
|
+
const top = `${title}${"─".repeat(dashes)}${copy}┐`;
|
|
178
|
+
const buttonFrom = gutter.length + [...title].length + dashes;
|
|
179
|
+
return {
|
|
180
|
+
rows: [top, ...inner.map((l) => `│ ${fit(l)} │`), `└${"─".repeat(innerW + 2)}┘`].map((r) => gutter + r),
|
|
181
|
+
button: { from: buttonFrom, to: buttonFrom + [...copy].length },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The keys the /load-presets picker takes, in the order its hint line names them. */
|
|
186
|
+
export const PICKER_KEYS = { v: "view", c: "copy", j: "json", e: "edit" };
|
|
187
|
+
export const PICKER_HINT = "↑↓ · Enter apply · v view · c copy command · j copy JSON · e edit · Esc back";
|
package/setup-prompts.mjs
CHANGED
|
@@ -16,23 +16,39 @@
|
|
|
16
16
|
* the one stretch of the program that runs before it.
|
|
17
17
|
*/
|
|
18
18
|
import { SKIP_ALL, isEscapeKey } from "./model-setup-ui.mjs";
|
|
19
|
+
import { footerDrawSeq, visibleLen, hintRows } from "./terminal-layout.mjs";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* One question on `rl`. Options: `secret` (mask + keep out of history), `esc` (Esc → SKIP_ALL).
|
|
22
|
-
* `style` decorates the label (the accent colour).
|
|
23
|
+
* `style` decorates the label (the accent colour). `prefill` is typed into the input for the
|
|
24
|
+
* person to take with Enter or delete (#305); `hint` is lines drawn in yellow UNDER the input,
|
|
25
|
+
* word-wrapped to one physical row each (terminal-layout hintRows), and wiped once answered.
|
|
26
|
+
* Resolves with the answer string, or SKIP_ALL.
|
|
23
27
|
*/
|
|
24
|
-
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
28
|
+
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, prefill = "", hint = [], input = process.stdin, output = process.stdout } = {}) {
|
|
25
29
|
return new Promise((resolve) => {
|
|
26
30
|
const shown = style(q);
|
|
27
31
|
const ac = new AbortController();
|
|
28
32
|
const origWrite = rl._writeToOutput;
|
|
29
33
|
const origRefresh = rl._refreshLine;
|
|
30
34
|
let done = false;
|
|
35
|
+
const showHint = !secret && hint.length > 0 && output.isTTY;
|
|
36
|
+
// Drawn below the input's LAST row with relative moves (footerDrawSeq), so it survives the
|
|
37
|
+
// terminal scrolling; redrawn after every key because readline clears below on a refresh.
|
|
38
|
+
const drawHint = () => {
|
|
39
|
+
const cols = Math.max(20, output.columns || 80);
|
|
40
|
+
const pos = rl.getCursorPos?.() ?? { rows: 0, cols: 0 };
|
|
41
|
+
const inputRows = Math.floor((visibleLen(shown) + String(rl.line ?? "").length) / cols) + 1;
|
|
42
|
+
const rows = hintRows(hint, { width: cols, gutter: " ", paint: (t) => `\x1b[33m${t}\x1b[0m` });
|
|
43
|
+
output.write(footerDrawSeq({ rows, caretRows: pos.rows, caretCols: pos.cols, inputRows }));
|
|
44
|
+
};
|
|
31
45
|
|
|
32
46
|
const finish = (value) => {
|
|
33
47
|
if (done) return;
|
|
34
48
|
done = true;
|
|
35
49
|
input.removeListener?.("keypress", onKey);
|
|
50
|
+
// Enter left the caret on the first hint row: the hint goes with the question.
|
|
51
|
+
if (showHint && value !== SKIP_ALL) output.write("\r\x1b[J");
|
|
36
52
|
if (secret) {
|
|
37
53
|
rl._writeToOutput = origWrite;
|
|
38
54
|
rl._refreshLine = origRefresh;
|
|
@@ -60,11 +76,14 @@ export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, in
|
|
|
60
76
|
if (secret && output.isTTY) {
|
|
61
77
|
output.write(`\r\x1b[2K${shown}${"•".repeat(String(rl.line ?? "").length)}`);
|
|
62
78
|
}
|
|
79
|
+
if (showHint) drawHint();
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
input.on?.("keypress", onKey);
|
|
66
83
|
// question() draws the label synchronously — mute only AFTER, or the label is muted too.
|
|
67
84
|
rl.question(shown, { signal: ac.signal }, (a) => finish(String(a ?? "")));
|
|
85
|
+
if (prefill && !secret) rl.write(String(prefill));
|
|
86
|
+
if (showHint) drawHint();
|
|
68
87
|
if (secret) {
|
|
69
88
|
rl._writeToOutput = () => {};
|
|
70
89
|
rl._refreshLine = () => {};
|
package/terminal-layout.mjs
CHANGED
|
@@ -2788,3 +2788,27 @@ export function slashMenuWindow(total, sel, top, rows = SLASH_MENU_ROWS) {
|
|
|
2788
2788
|
const w = listWindow(n, sel < 0 ? 0 : sel, sel < 0 ? 0 : top, rows + 2);
|
|
2789
2789
|
return { from: w.from, to: w.to, above: w.above, below: w.below, top: w.top };
|
|
2790
2790
|
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* A QUESTION'S HINT, drawn UNDER the input (#305: the trial token's "this is a trial — get a free
|
|
2794
|
+
* token for 1 computer, or Pro for more" sits under the token it describes). Word-wrapped so every
|
|
2795
|
+
* row is ONE physical row — a wrapped row corrupts every redraw that counts rows (#109/#132/#138).
|
|
2796
|
+
* A word longer than the row is hard-cut rather than allowed to wrap.
|
|
2797
|
+
*/
|
|
2798
|
+
export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}) {
|
|
2799
|
+
const room = Math.max(10, Math.max(20, width || 80) - 1 - visibleLen(gutter));
|
|
2800
|
+
const out = [];
|
|
2801
|
+
for (const line of lines ?? []) {
|
|
2802
|
+
let row = "";
|
|
2803
|
+
for (const word of String(line).split(/\s+/).filter(Boolean)) {
|
|
2804
|
+
const next = row ? `${row} ${word}` : word;
|
|
2805
|
+
if (visibleLen(next) <= room) row = next;
|
|
2806
|
+
else {
|
|
2807
|
+
if (row) out.push(row);
|
|
2808
|
+
row = visibleLen(word) > room ? clipVisible(word, room) : word;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (row) out.push(row);
|
|
2812
|
+
}
|
|
2813
|
+
return out.map((r) => gutter + paint(r));
|
|
2814
|
+
}
|