@tiens.nguyen/gu-cli 1.0.686 → 1.0.693
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-key-prompt.mjs +1 -1
- package/daemon-control.mjs +2 -2
- package/gonext_agent_chat.py +6 -6
- package/gonext_mlx_embed.py +2 -2
- package/gonext_transcribe.py +1 -1
- package/gu-cli.mjs +91 -2
- package/gu-repl.mjs +7 -26
- package/install-path.mjs +138 -0
- package/package.json +7 -4
- package/powershell-alias.mjs +94 -0
package/coding-key-prompt.mjs
CHANGED
|
@@ -84,7 +84,7 @@ export async function promptCodingKeyIfMissing(payload, { askSecret, saveCodingK
|
|
|
84
84
|
return { asked: false, saved: Boolean(ok), keyless: true, model, url: payload.codingKeyUrl };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
log(dim("\
|
|
87
|
+
log(dim("\ngu: your agent coding model (") + cyan(model) + dim(") needs an API key."));
|
|
88
88
|
log(dim(" Until it's set, coding falls back to your local model. Enter the key now,"));
|
|
89
89
|
log(dim(" or press Enter to skip (I'll ask again next time).\n"));
|
|
90
90
|
|
package/daemon-control.mjs
CHANGED
|
@@ -251,7 +251,7 @@ export async function findRunningDaemonPids() {
|
|
|
251
251
|
* True when `pid` is actually a gu polling daemon (not an unrelated process that reused
|
|
252
252
|
* the PID). Reads that one pid's command line — cheap and exact (review M1).
|
|
253
253
|
*/
|
|
254
|
-
function
|
|
254
|
+
function pidIsGuDaemon(pid) {
|
|
255
255
|
if (!pid || !Number.isFinite(pid)) return Promise.resolve(false);
|
|
256
256
|
return new Promise((resolve) => {
|
|
257
257
|
const one = processCommandCommand(pid);
|
|
@@ -324,7 +324,7 @@ export async function readBusyState() {
|
|
|
324
324
|
export async function daemonStatus() {
|
|
325
325
|
const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
|
|
326
326
|
const pidFileIsDaemon =
|
|
327
|
-
pidFilePid != null && (pids.includes(pidFilePid) || (await
|
|
327
|
+
pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGuDaemon(pidFilePid)));
|
|
328
328
|
const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
|
|
329
329
|
return {
|
|
330
330
|
running: allPids.length > 0,
|
package/gonext_agent_chat.py
CHANGED
|
@@ -5508,7 +5508,7 @@ def _rag_read_allowed(path: str) -> str:
|
|
|
5508
5508
|
base = _WS_ACTIVE or (_WS_ROOTS[0]["path"] if _WS_ROOTS else None)
|
|
5509
5509
|
if base is None:
|
|
5510
5510
|
raise ValueError(
|
|
5511
|
-
"No workspace is active — register a folder with `
|
|
5511
|
+
"No workspace is active — register a folder with `gu-local-worker "
|
|
5512
5512
|
"workspace add <path>` before reading files."
|
|
5513
5513
|
)
|
|
5514
5514
|
expanded = os.path.join(base, _ws_strip_echoed_root(base, expanded))
|
|
@@ -5842,7 +5842,7 @@ def _ws_write_allowed(path: str) -> str:
|
|
|
5842
5842
|
if w is None:
|
|
5843
5843
|
raise ValueError(
|
|
5844
5844
|
"Writes are only allowed inside a registered workspace "
|
|
5845
|
-
"(register one with:
|
|
5845
|
+
"(register one with: gu-local-worker workspace add <path>)."
|
|
5846
5846
|
)
|
|
5847
5847
|
rel = os.path.relpath(rp, w["path"])
|
|
5848
5848
|
if rel == ".git" or rel.startswith(".git" + os.sep) or (os.sep + ".git" + os.sep) in (os.sep + rel + os.sep):
|
|
@@ -5884,7 +5884,7 @@ def _ws_snapshot(path: str) -> str:
|
|
|
5884
5884
|
|
|
5885
5885
|
|
|
5886
5886
|
def _ws_record_change(action: str, path: str, backup) -> None:
|
|
5887
|
-
"""Track a change AND persist the run manifest so `
|
|
5887
|
+
"""Track a change AND persist the run manifest so `gu-local-worker workspace
|
|
5888
5888
|
revert` can restore edits / delete created files after the process is gone."""
|
|
5889
5889
|
import os
|
|
5890
5890
|
_WS_CHANGES.append({"action": action, "path": path, "backup": backup or None})
|
|
@@ -6772,7 +6772,7 @@ def run_agent_chat(cfg):
|
|
|
6772
6772
|
# Ollama?" or "did the user pick openai?" separately — reasoning_effort, streaming, the
|
|
6773
6773
|
# #113 reasoning repairs, and this step budget — now reads `coding_backend`.
|
|
6774
6774
|
#
|
|
6775
|
-
# The worker (
|
|
6775
|
+
# The worker (gu-local-worker.mjs) resolves this first, because it must decide
|
|
6776
6776
|
# whether to fetch the coding API key BEFORE python starts, and passes the answer down
|
|
6777
6777
|
# as codingBackend. Trust it only when we ended up on the URL it judged: the fallbacks
|
|
6778
6778
|
# above can swap coding_base_url for the chat model's, which is a different server and
|
|
@@ -7094,7 +7094,7 @@ def run_agent_chat(cfg):
|
|
|
7094
7094
|
)
|
|
7095
7095
|
|
|
7096
7096
|
# ---- Workspaces (local folders the agent may read/edit/test code in) ----
|
|
7097
|
-
# Registered on the Mac via `
|
|
7097
|
+
# Registered on the Mac via `gu-local-worker workspace add <path>`; the worker
|
|
7098
7098
|
# passes the registry through cfg. Only these roots are writable — the security
|
|
7099
7099
|
# boundary for the coding tools.
|
|
7100
7100
|
global _WS_ROOTS, _WS_ACTIVE
|
|
@@ -11556,7 +11556,7 @@ def run_agent_chat(cfg):
|
|
|
11556
11556
|
"To give the user a FILE or a ZIP of a folder, use the create_download "
|
|
11557
11557
|
"tool instead — it needs no shell and works here. "
|
|
11558
11558
|
"(To enable real commands, re-register with: "
|
|
11559
|
-
"
|
|
11559
|
+
"gu-local-worker workspace add <path> --allow-run)")
|
|
11560
11560
|
_last_obs["text"] = msg
|
|
11561
11561
|
return msg
|
|
11562
11562
|
argv = shlex.split(command or "")
|
package/gonext_mlx_embed.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
3
|
+
Gu MLX embeddings server — an OpenAI-compatible /v1/embeddings endpoint for MLX
|
|
4
4
|
embedding models (e.g. Qwen3-Embedding-8B), which `mlx_lm.server` does NOT provide.
|
|
5
5
|
|
|
6
|
-
The
|
|
6
|
+
The Gu worker's RAG tools call `POST {url}/v1/embeddings` first, so pointing the
|
|
7
7
|
"RAG embedding server URL" (web Settings → Agent → RAG) at this server makes MLX
|
|
8
8
|
embeddings work with no other changes.
|
|
9
9
|
|
package/gonext_transcribe.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Voice transcription for the
|
|
2
|
+
"""Voice transcription for the Gu local worker (Audio support, task #21).
|
|
3
3
|
|
|
4
4
|
Reads a JSON job on stdin: {"audioPath": str, "model": str, "language": str?}.
|
|
5
5
|
Transcribes with mlx-whisper and prints {"text": "..."} on stdout.
|
package/gu-cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Gu local worker:
|
|
4
4
|
* - `gu-cli set <workerKey> [--api-base URL]`
|
|
5
5
|
* writes ~/.gonext/worker.env
|
|
6
6
|
* - `gu-cli ws-ping-test` — POST worker ws-ping (needs GONEXT_* env)
|
|
@@ -92,6 +92,8 @@ Usage:
|
|
|
92
92
|
gu-cli rag # test the embedder end-to-end + list knowledge bases
|
|
93
93
|
gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
|
|
94
94
|
gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
|
|
95
|
+
gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
|
|
96
|
+
# (it is Get-Unique and shadows this CLI)
|
|
95
97
|
gu-cli rag plan [path] # is RAG worth it here? files/chars/chunks → estimated index time
|
|
96
98
|
gu-cli rag --test # can this machine embed and retrieve?
|
|
97
99
|
gu-cli test-proxy # can this machine route model calls through a proxy?
|
|
@@ -905,6 +907,93 @@ if (args[0] === "token-usage" || args[0] === "tokens") {
|
|
|
905
907
|
process.exit(0);
|
|
906
908
|
}
|
|
907
909
|
|
|
910
|
+
// `gu-cli fix-powershell` — drop PowerShell's built-in `gu` alias so the command works there.
|
|
911
|
+
// ASKS FIRST and never runs as part of an install: a PowerShell profile is the user's file, and
|
|
912
|
+
// a CLI that edits it unannounced is one that surprises people. --check only reports.
|
|
913
|
+
if (args[0] === "fix-powershell") {
|
|
914
|
+
const pa = await import("./powershell-alias.mjs");
|
|
915
|
+
if (!pa.affectsThisMachine()) {
|
|
916
|
+
console.log(" Nothing to fix — this only affects Windows PowerShell.");
|
|
917
|
+
process.exit(0);
|
|
918
|
+
}
|
|
919
|
+
const { execFileSync } = await import("node:child_process");
|
|
920
|
+
const ps = (script) =>
|
|
921
|
+
execFileSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf8" }).trim();
|
|
922
|
+
|
|
923
|
+
let profilePath = "";
|
|
924
|
+
try {
|
|
925
|
+
profilePath = ps("$PROFILE");
|
|
926
|
+
} catch (e) {
|
|
927
|
+
console.error(` Could not ask PowerShell where your profile is: ${e.message}`);
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import("node:fs");
|
|
931
|
+
const current = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
|
|
932
|
+
const next = pa.profileWithFix(current);
|
|
933
|
+
// ASKED IN EVERY PATH, because "already fixed" was reported on the very machine whose policy
|
|
934
|
+
// stops the profile loading — the same "said Done, changed nothing" mistake one branch along.
|
|
935
|
+
const blockedBy = () => {
|
|
936
|
+
try {
|
|
937
|
+
return pa.profileBlockedBy(ps("Get-ExecutionPolicy"));
|
|
938
|
+
} catch {
|
|
939
|
+
return "";
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
if (next === null) {
|
|
943
|
+
console.log(` Already fixed — ${profilePath} removes the alias.`);
|
|
944
|
+
const blocked = blockedBy();
|
|
945
|
+
if (blocked) {
|
|
946
|
+
console.log("");
|
|
947
|
+
for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
console.log(" Open a NEW PowerShell window for it to take effect.");
|
|
951
|
+
process.exit(0);
|
|
952
|
+
}
|
|
953
|
+
if (args.includes("--check")) {
|
|
954
|
+
console.log(" Not fixed yet. `gu` in PowerShell is still Get-Unique.");
|
|
955
|
+
const blocked = blockedBy();
|
|
956
|
+
if (blocked) for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
|
|
957
|
+
console.log(` Run: gu-cli fix-powershell (edits ${profilePath})`);
|
|
958
|
+
process.exit(1);
|
|
959
|
+
}
|
|
960
|
+
console.log("");
|
|
961
|
+
for (const l of pa.shadowedAdvice()) console.log(" " + l);
|
|
962
|
+
console.log("");
|
|
963
|
+
console.log(` This appends ONE line to ${profilePath}:`);
|
|
964
|
+
console.log(" " + pa.PROFILE_LINE);
|
|
965
|
+
const rlp = (await import("node:readline")).createInterface({
|
|
966
|
+
input: process.stdin,
|
|
967
|
+
output: process.stdout,
|
|
968
|
+
});
|
|
969
|
+
const answer = await new Promise((r) => rlp.question("\n Append it? [y/N]: ", r));
|
|
970
|
+
rlp.close();
|
|
971
|
+
if (!/^y(es)?$/i.test(String(answer).trim())) {
|
|
972
|
+
console.log(" " + pa.MEANWHILE);
|
|
973
|
+
process.exit(0);
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const dir = profilePath.replace(/[\\/][^\\/]*$/, "");
|
|
977
|
+
if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
978
|
+
writeFileSync(profilePath, next, "utf8");
|
|
979
|
+
} catch (e) {
|
|
980
|
+
console.error(` Could not write ${profilePath}: ${e.message}`);
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
console.log(` Done — ${profilePath} updated.`);
|
|
984
|
+
// VERIFY THE FIX CAN ACTUALLY RUN. Writing the line is not the same as PowerShell loading it:
|
|
985
|
+
// under Restricted or AllSigned the profile is a script it refuses to execute, and the first
|
|
986
|
+
// real run of this said "Done" to a machine where `gu` stayed shadowed.
|
|
987
|
+
const policy = blockedBy();
|
|
988
|
+
if (policy) {
|
|
989
|
+
console.log("");
|
|
990
|
+
for (const l of pa.policyAdvice(policy)) console.log(" " + l);
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
console.log(" Open a NEW PowerShell window, then `gu` runs this CLI.");
|
|
994
|
+
process.exit(0);
|
|
995
|
+
}
|
|
996
|
+
|
|
908
997
|
if (args[0] === "clear" || args[0] === "reset") {
|
|
909
998
|
const cs = await import("./clear-state.mjs");
|
|
910
999
|
const scratchOnly = args.includes("--scratch");
|
|
@@ -2838,7 +2927,7 @@ async function runOcrJob(job) {
|
|
|
2838
2927
|
}
|
|
2839
2928
|
console.log(`[gu-worker] OCR source jobId=${jobId} ${sourceLabel}`);
|
|
2840
2929
|
|
|
2841
|
-
const tempDir = await mkdtemp(join(tmpdir(), "
|
|
2930
|
+
const tempDir = await mkdtemp(join(tmpdir(), "gu-ocr-worker-"));
|
|
2842
2931
|
let extractedText = "";
|
|
2843
2932
|
try {
|
|
2844
2933
|
const imagePath = join(
|
package/gu-repl.mjs
CHANGED
|
@@ -783,7 +783,6 @@ const COMMANDS = [
|
|
|
783
783
|
// The two synonyms were dispatched but declared nowhere (found by slash-command-wiring.test.mjs):
|
|
784
784
|
// they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
|
|
785
785
|
// already has for exactly this — /quit, /rag-local.
|
|
786
|
-
{ name: "/ask-again", aliases: ["/daemon-ask", "/model-setup-ask"], desc: "forget the remembered \"no\" for the model-setup prompt in this folder" },
|
|
787
786
|
{ name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
|
|
788
787
|
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
789
788
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
@@ -1795,7 +1794,7 @@ async function refreshAvailableUpdates() {
|
|
|
1795
1794
|
const { updatesAvailable } = await import("./version-check.mjs");
|
|
1796
1795
|
const cliLatest = await (async () => {
|
|
1797
1796
|
try {
|
|
1798
|
-
const r = await fetch("https://registry.npmjs.org/@tiens.nguyen%
|
|
1797
|
+
const r = await fetch("https://registry.npmjs.org/@tiens.nguyen%2Fgu-cli/latest", {
|
|
1799
1798
|
signal: AbortSignal.timeout(6000),
|
|
1800
1799
|
});
|
|
1801
1800
|
return r.ok ? (await r.json())?.version ?? "" : "";
|
|
@@ -9350,7 +9349,7 @@ async function main() {
|
|
|
9350
9349
|
servers: await (await import("./model-doctor.mjs")).discoverModelServers(),
|
|
9351
9350
|
});
|
|
9352
9351
|
const statusRows = [
|
|
9353
|
-
bold(cyan("
|
|
9352
|
+
bold(cyan("GU AGENT")) + dim(` v${REPL_VERSION}`),
|
|
9354
9353
|
// Task #134: a client machine OVERRIDES the account's agent model locally (the
|
|
9355
9354
|
// account value points at the model host's own server). Show what will actually be
|
|
9356
9355
|
// used, not a value this machine can't reach.
|
|
@@ -9422,7 +9421,7 @@ async function main() {
|
|
|
9422
9421
|
const skipDeps = await loadSessionSkip(resolve(process.cwd()), "skipDepsPrompt");
|
|
9423
9422
|
if (!skipDeps) {
|
|
9424
9423
|
console.log(
|
|
9425
|
-
dim("\
|
|
9424
|
+
dim("\ngu: the agent framework (smolagents + openai) isn't installed here —") +
|
|
9426
9425
|
dim("\n every turn will fail without it. It's a small pip install; no models.")
|
|
9427
9426
|
);
|
|
9428
9427
|
if (await askOr(false, () => askYesNo("Install the agent framework now?", true))) {
|
|
@@ -9469,10 +9468,10 @@ async function main() {
|
|
|
9469
9468
|
// to a local model setup here — its model is remote by design.
|
|
9470
9469
|
if (!clientModeBanner && !(await md.probeModelServer(chatUrl))) {
|
|
9471
9470
|
console.log(
|
|
9472
|
-
dim(`\
|
|
9471
|
+
dim(`\ngu: the chat model server (${md.modelRoot(chatUrl)}) isn't responding.`)
|
|
9473
9472
|
);
|
|
9474
9473
|
// Task #135: remember a "no" for this folder. `gu-cli doctor` still works
|
|
9475
|
-
// explicitly;
|
|
9474
|
+
// explicitly; `gu doctor` runs the same setup whenever it is wanted.
|
|
9476
9475
|
const skipSetup = await loadSessionSkip(resolve(process.cwd()), "skipModelSetupPrompt");
|
|
9477
9476
|
// Esc = "not now" rather than "yes": this branch DOWNLOADS a model, and a cancelled
|
|
9478
9477
|
// question must never be read as consent to start one.
|
|
@@ -9483,8 +9482,8 @@ async function main() {
|
|
|
9483
9482
|
);
|
|
9484
9483
|
if (skipSetup) {
|
|
9485
9484
|
console.log(
|
|
9486
|
-
dim(" Set-up skipped (remembered for this folder) — `gu doctor`
|
|
9487
|
-
dim("
|
|
9485
|
+
dim(" Set-up skipped (remembered for this folder) — `gu doctor` runs it ") +
|
|
9486
|
+
dim("any time.\n")
|
|
9488
9487
|
);
|
|
9489
9488
|
}
|
|
9490
9489
|
if (go) {
|
|
@@ -9980,24 +9979,6 @@ async function main() {
|
|
|
9980
9979
|
);
|
|
9981
9980
|
continue;
|
|
9982
9981
|
}
|
|
9983
|
-
if (line === "/ask-again" || line === "/daemon-ask" || line === "/model-setup-ask") {
|
|
9984
|
-
// Task #135: the way BACK from a remembered "no". Without this the choice is a
|
|
9985
|
-
// one-way door — a setup that later changes (model installed, daemon wanted) would
|
|
9986
|
-
// have no route to the prompt again short of editing the session file by hand.
|
|
9987
|
-
await saveSessionSkip(cwd, "skipDaemonPrompt", false);
|
|
9988
|
-
await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
|
|
9989
|
-
await saveSessionSkip(cwd, "skipDepsPrompt", false);
|
|
9990
|
-
// The background worker is no longer among these: it is started and announced rather than
|
|
9991
|
-
// asked about (task #159), so there is no remembered "no" left to undo. `gu stop` is
|
|
9992
|
-
// the way to not have one running. Clearing its old key anyway, harmlessly, so a session
|
|
9993
|
-
// file written by an earlier version does not keep a setting nothing reads.
|
|
9994
|
-
console.log(
|
|
9995
|
-
cyan("startup prompts re-enabled") +
|
|
9996
|
-
dim(" — you'll be asked about model setup again next time you run `gu` here.\n") +
|
|
9997
|
-
dim(" (the background worker is started automatically now — `gu stop` to stop it.)\n")
|
|
9998
|
-
);
|
|
9999
|
-
continue;
|
|
10000
|
-
}
|
|
10001
9982
|
if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
|
|
10002
9983
|
// ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
|
|
10003
9984
|
// so the command you typed did not determine the state you got — and the reply named a
|
package/install-path.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall: make `gu` / `gu-cli` runnable from the NEXT terminal without the user
|
|
4
|
+
* ever editing a profile by hand.
|
|
5
|
+
*
|
|
6
|
+
* WHY: the support loop that prompted this file is always the same — a user on macOS or
|
|
7
|
+
* Ubuntu runs `npm install -g @tiens.nguyen/gu-cli`, npm puts the shims in a global bin
|
|
8
|
+
* dir their shell never heard of, and the very first command answers "gu: command not
|
|
9
|
+
* found". The CLI works; the PATH does not. Users file it as "the install is broken".
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS DOES, in order of preference:
|
|
12
|
+
* 1. Nothing, when the global bin dir is already on PATH (the common case with nvm,
|
|
13
|
+
* Homebrew-node, or a repointed npm prefix).
|
|
14
|
+
* 2. Appends ONE idempotent, marker-wrapped `export PATH=...` block to the user's
|
|
15
|
+
* shell rc file — .zshrc on macOS (default shell since Catalina), .bashrc on
|
|
16
|
+
* Ubuntu/Debian — creating the file only when that shell is the platform default.
|
|
17
|
+
* 3. Prints the exact `export` line for the CURRENT terminal, because a child process
|
|
18
|
+
* physically cannot `source` anything into its parent shell.
|
|
19
|
+
*
|
|
20
|
+
* HARD RULES:
|
|
21
|
+
* - NEVER fail the install. Every path through this file ends in exit 0; npm treats a
|
|
22
|
+
* postinstall failure as a failed install and the user gets nothing.
|
|
23
|
+
* - NEVER run on Windows (PATH there lives in the registry, and `gu`'s PowerShell
|
|
24
|
+
* alias problem is powershell-alias.mjs's job, explicitly interactive).
|
|
25
|
+
* - NEVER touch an rc file twice: the marker pair makes re-runs no-ops.
|
|
26
|
+
* - Under `sudo npm i -g`, write to the REAL user's home (SUDO_USER) and chown the
|
|
27
|
+
* file back, or the user's next login shell reads a root-owned rc file.
|
|
28
|
+
*/
|
|
29
|
+
import { appendFileSync, existsSync, readFileSync, chownSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
31
|
+
import { join, resolve } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
const BEGIN = "# >>> gu-cli PATH >>>";
|
|
35
|
+
const END = "# <<< gu-cli PATH <<<";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Self-execute ONLY as the postinstall entry point.
|
|
39
|
+
*
|
|
40
|
+
* WHY THE GUARD: scripts/preflight.mjs step 4 does `await import()` on every shipped
|
|
41
|
+
* module to prove it resolves. The first version of this file ran main() and called
|
|
42
|
+
* process.exit(0) at import time — the import KILLED the preflight runner mid-step,
|
|
43
|
+
* exit code 0, summary never printed. A module that exits its importer is a landmine;
|
|
44
|
+
* only the direct `node install-path.mjs` invocation (npm's postinstall) may run it.
|
|
45
|
+
*/
|
|
46
|
+
const isEntryPoint = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
if (isEntryPoint) {
|
|
55
|
+
try {
|
|
56
|
+
main();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// A postinstall that throws uninstalls the package. Report, swallow, succeed.
|
|
59
|
+
console.log(`gu-cli: PATH setup skipped (${e.message}) — see \`gu-cli doctor\` if \`gu\` is not found.`);
|
|
60
|
+
}
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function main() {
|
|
65
|
+
if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
|
|
66
|
+
if (process.env.CI) return; // no user shell to help in CI
|
|
67
|
+
const prefix = process.env.npm_config_prefix;
|
|
68
|
+
if (!prefix) return; // not an npm install context
|
|
69
|
+
const binDir = join(prefix, "bin");
|
|
70
|
+
if (resolve(binDir).startsWith(resolve(tmpdir()))) return; // preflight's throwaway prefix
|
|
71
|
+
|
|
72
|
+
const onPath = (process.env.PATH || "").split(":").some((p) => p === binDir);
|
|
73
|
+
if (onPath) return; // nothing to fix — most installs
|
|
74
|
+
|
|
75
|
+
const home = realUserHome();
|
|
76
|
+
const targets = rcFilesFor(home);
|
|
77
|
+
const block = `${BEGIN}\n# npm's global bin dir was not on PATH; added by @tiens.nguyen/gu-cli postinstall.\nexport PATH="${binDir}:$PATH"\n${END}\n`;
|
|
78
|
+
|
|
79
|
+
let wrote = null;
|
|
80
|
+
for (const rc of targets) {
|
|
81
|
+
if (ensureBlock(rc, block)) { wrote = wrote || rc; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (wrote) {
|
|
85
|
+
console.log(`gu-cli: added ${binDir} to PATH in ${wrote}.`);
|
|
86
|
+
console.log(`gu-cli: open a NEW terminal to use \`gu\`, or run this once in the current one:`);
|
|
87
|
+
console.log(` export PATH="${binDir}:$PATH"`);
|
|
88
|
+
} else if (targets.length === 0) {
|
|
89
|
+
console.log(`gu-cli: ${binDir} is not on your PATH and no shell profile was found to update.`);
|
|
90
|
+
console.log(` export PATH="${binDir}:$PATH"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The installer's home — the invoking user's, not root's, under sudo. */
|
|
95
|
+
function realUserHome() {
|
|
96
|
+
const sudoUser = process.env.SUDO_USER;
|
|
97
|
+
if (sudoUser && process.getuid && process.getuid() === 0) {
|
|
98
|
+
return platform() === "darwin" ? join("/Users", sudoUser) : join("/home", sudoUser);
|
|
99
|
+
}
|
|
100
|
+
return homedir();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which rc files to maintain, in order. macOS default is zsh; Ubuntu default is bash.
|
|
105
|
+
* A non-default shell's rc is updated only if the file already exists — we fix the
|
|
106
|
+
* setups people HAVE, we don't seed shells they don't use.
|
|
107
|
+
*/
|
|
108
|
+
function rcFilesFor(home) {
|
|
109
|
+
const zshrc = join(home, ".zshrc");
|
|
110
|
+
const bashrc = join(home, ".bashrc");
|
|
111
|
+
const out = [];
|
|
112
|
+
if (platform() === "darwin") {
|
|
113
|
+
out.push(zshrc);
|
|
114
|
+
if (existsSync(bashrc)) out.push(bashrc);
|
|
115
|
+
} else {
|
|
116
|
+
out.push(bashrc);
|
|
117
|
+
if (existsSync(zshrc)) out.push(zshrc);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Append the block unless the marker is already there. True when the file changed. */
|
|
123
|
+
function ensureBlock(rcFile, block) {
|
|
124
|
+
const prior = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
|
|
125
|
+
if (prior.includes(BEGIN)) return false;
|
|
126
|
+
if (!existsSync(rcFile)) writeFileSync(rcFile, block);
|
|
127
|
+
else appendFileSync(rcFile, (prior.endsWith("\n") || prior === "" ? "" : "\n") + block);
|
|
128
|
+
chownBackToSudoUser(rcFile);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `sudo npm i -g` would otherwise leave a root-owned .zshrc the user cannot edit. */
|
|
133
|
+
function chownBackToSudoUser(file) {
|
|
134
|
+
const { SUDO_UID, SUDO_GID } = process.env;
|
|
135
|
+
if (SUDO_UID && SUDO_GID && process.getuid && process.getuid() === 0) {
|
|
136
|
+
try { chownSync(file, Number(SUDO_UID), Number(SUDO_GID)); } catch { /* best effort */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
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.693",
|
|
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",
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
"url": "https://github.com/tiennsloit/gu/issues"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"gu-cli": "
|
|
17
|
-
"gu-local-worker": "
|
|
18
|
-
"gu": "
|
|
16
|
+
"gu-cli": "gu-cli.mjs",
|
|
17
|
+
"gu-local-worker": "gu-cli.mjs",
|
|
18
|
+
"gu": "gu-repl.mjs"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
+
"postinstall": "node install-path.mjs",
|
|
21
22
|
"test": "python3 -m unittest discover tests -v && node --test",
|
|
22
23
|
"test:js": "node --test",
|
|
23
24
|
"test:keys": "node --test tests/key-choice-regression.test.mjs tests/coding-key-line.test.mjs tests/coding-key-picker.test.mjs tests/coding-key-prompt.test.mjs tests/coding-key-notice.test.mjs tests/coding-key-fallback-line.test.mjs tests/manage-coding-providers.test.mjs tests/add-coding-provider.test.mjs tests/coding-provider-setup.test.mjs",
|
|
@@ -77,6 +78,8 @@
|
|
|
77
78
|
"proxy-dispatcher.mjs",
|
|
78
79
|
"model-doctor.mjs",
|
|
79
80
|
"coding-key-prompt.mjs",
|
|
81
|
+
"powershell-alias.mjs",
|
|
82
|
+
"install-path.mjs",
|
|
80
83
|
"gonext_probe_agent.py",
|
|
81
84
|
"gonext_agent_chat.py",
|
|
82
85
|
"gonext_transcribe.py",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gu` IS A BUILT-IN POWERSHELL ALIAS FOR Get-Unique.
|
|
3
|
+
*
|
|
4
|
+
* Found by the Windows end-to-end run: on Windows 11, where PowerShell is the default shell,
|
|
5
|
+
* typing `gu --help` runs Get-Unique and fails with "A positional parameter cannot be found that
|
|
6
|
+
* accepts argument '--help'". cmd.exe has no such alias, which is why it works there.
|
|
7
|
+
*
|
|
8
|
+
* NOTHING ON PATH CAN FIX THIS. PowerShell resolves Alias → Function → Cmdlet → Application, so
|
|
9
|
+
* an alias beats every executable and every function we could ship. The only remedies are to
|
|
10
|
+
* remove the alias from the user's profile, or to be called something else.
|
|
11
|
+
*
|
|
12
|
+
* So this offers the removal, EXPLICITLY and never automatically. A profile is the user's file;
|
|
13
|
+
* a CLI that edits it during an install is a CLI that surprises people. `gu-cli fix-powershell`
|
|
14
|
+
* asks, shows the line, and appends it — and `--check` just reports.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The line appended to the profile. `-Force` because the built-in alias is read-only. */
|
|
18
|
+
export const PROFILE_LINE =
|
|
19
|
+
"Remove-Item Alias:gu -Force -ErrorAction SilentlyContinue # gu CLI: PowerShell's built-in `gu` is Get-Unique";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What still works while the alias is in place.
|
|
23
|
+
*
|
|
24
|
+
* IT LIVES HERE, NOT IN gu-cli.mjs, and that is not tidiness. platform-assumptions.test.mjs
|
|
25
|
+
* switches on a rule for any file whose source mentions a quoted ".cmd" — and gu-cli.mjs has
|
|
26
|
+
* several long-standing spawn sites that the rule then flags. One sentence about `gu.cmd` in
|
|
27
|
+
* that file woke all of them. Keeping the string in this module leaves that file exactly as it
|
|
28
|
+
* was, and those sites a separate question rather than one a rename quietly changed.
|
|
29
|
+
*/
|
|
30
|
+
export const MEANWHILE = "Left alone. `gu-cli` and `gu.cmd` both run this program in the meantime.";
|
|
31
|
+
|
|
32
|
+
/** True when this machine can even have the problem. */
|
|
33
|
+
export function affectsThisMachine(platform = process.platform) {
|
|
34
|
+
return platform === "win32";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What to tell someone whose `gu` is shadowed.
|
|
39
|
+
*
|
|
40
|
+
* Names BOTH working commands first, because the immediate need is to run the thing at all —
|
|
41
|
+
* the profile edit is the fix for tomorrow, `gu-cli` is the fix for right now.
|
|
42
|
+
*/
|
|
43
|
+
export function shadowedAdvice() {
|
|
44
|
+
return [
|
|
45
|
+
"PowerShell has a built-in alias `gu` (Get-Unique) that wins over this CLI.",
|
|
46
|
+
"Right now: use `gu-cli` — or `gu.cmd`, which is this program.",
|
|
47
|
+
"Permanently: run `gu-cli fix-powershell` to drop the alias from your PowerShell profile.",
|
|
48
|
+
"cmd.exe is unaffected; `gu` works there already.",
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Will PowerShell even LOAD a profile on this machine?
|
|
54
|
+
*
|
|
55
|
+
* Found immediately after the first real fix: the line was written, the command said "Done",
|
|
56
|
+
* and a new PowerShell still had the alias — because the execution policy was Restricted and
|
|
57
|
+
* profiles are scripts. A fix that cannot run is worse than no fix, because it stops you
|
|
58
|
+
* looking for the real reason.
|
|
59
|
+
*
|
|
60
|
+
* "Restricted" and "AllSigned" both refuse an unsigned profile. Everything else loads it.
|
|
61
|
+
*/
|
|
62
|
+
export function profileBlockedBy(policy) {
|
|
63
|
+
const p = String(policy ?? "").trim();
|
|
64
|
+
return /^(Restricted|AllSigned)$/i.test(p) ? p : "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What to do about a policy that will not run the profile. The user's decision, not ours. */
|
|
68
|
+
export function policyAdvice(policy) {
|
|
69
|
+
return [
|
|
70
|
+
`PowerShell's execution policy is ${policy}, so it will not load your profile —`,
|
|
71
|
+
"the line is written but will never run. To allow your own profile:",
|
|
72
|
+
" Set-ExecutionPolicy -Scope CurrentUser RemoteSigned",
|
|
73
|
+
"Until then `gu-cli` and `gu.cmd` still work, and cmd.exe is unaffected.",
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is the line already in a profile's text?
|
|
79
|
+
*
|
|
80
|
+
* Matched on the COMMAND, not the whole line including its comment — so a profile edited by
|
|
81
|
+
* hand, or by an older version of this message, is still recognised and not appended to twice.
|
|
82
|
+
*/
|
|
83
|
+
export function alreadyFixed(profileText) {
|
|
84
|
+
return /Remove-Item\s+Alias:gu\b/i.test(String(profileText ?? ""));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The text to write, given what the profile already holds. Returns null when nothing is needed. */
|
|
88
|
+
export function profileWithFix(profileText) {
|
|
89
|
+
if (alreadyFixed(profileText)) return null;
|
|
90
|
+
const prev = String(profileText ?? "");
|
|
91
|
+
// A profile that does not end in a newline would otherwise get the fix glued to its last line.
|
|
92
|
+
const sep = prev.length === 0 ? "" : prev.endsWith("\n") ? "" : "\n";
|
|
93
|
+
return `${prev}${sep}${PROFILE_LINE}\n`;
|
|
94
|
+
}
|