@tiens.nguyen/gu-cli 1.0.891 → 1.0.893

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/README.md CHANGED
@@ -1,17 +1,17 @@
1
1
  <h1 align="center">gu — the coding agent for your own models</h1>
2
2
 
3
3
  <p align="center">
4
- <strong>A coding agent in your terminal that runs on Ollama, MLX or any OpenAI-compatible API.</strong><br>
5
- It reads your repo, makes the change, runs the tests and shows you what it did. Your code stays in your folder.<br>
6
- <strong>Free for 30 days. No account, no sign-in, no card.</strong>
4
+ <strong>A coding agent in your terminal that runs on Ollama or any OpenAI-compatible API.</strong><br>
5
+ It reads your repo, makes the change, runs the tests and shows you what it did.<br>
6
+ <strong>Free installs run entirely on your machine. Free for 30 days: no account, no sign-in, no card.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@tiens.nguyen/gu-cli"><img alt="npm" src="https://img.shields.io/npm/v/@tiens.nguyen/gu-cli?color=6366f1&label=npm"></a>
11
11
  <a href="https://www.npmjs.com/package/@tiens.nguyen/gu-cli"><img alt="downloads" src="https://img.shields.io/npm/dm/@tiens.nguyen/gu-cli?color=6366f1"></a>
12
- <img alt="node" src="https://img.shields.io/node/v/@tiens.nguyen/gu-cli?color=6366f1">
12
+ <img alt="node 20.19+" src="https://img.shields.io/badge/node-20.19%2B-6366f1">
13
13
  <img alt="platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Linux%20%C2%B7%20Windows-supported-6366f1">
14
- <a href="./LICENSE"><img alt="license" src="https://img.shields.io/npm/l/@tiens.nguyen/gu-cli?color=6366f1"></a>
14
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-6366f1">
15
15
  </p>
16
16
 
17
17
  <p align="center">
@@ -37,18 +37,21 @@ gu
37
37
  ```
38
38
 
39
39
  **Free to start, no account needed.** The first run creates a free 30-day token for this
40
- computer automatically. Press Enter to use it. It also asks how this machine runs and which model
41
- provider to use. Then type what you want done.
40
+ computer. Press Enter to use it, pick a model provider, then type what you want done.
41
+
42
+ A free install is **private**: gu's API and its database (MongoDB) run on this computer, next to
43
+ your code. The first run shows what it will install and asks before installing anything.
44
+ Needs **Node.js 20.19+**.
42
45
 
43
46
  > **`gu: command not found`?** Run `npx @tiens.nguyen/gu-cli fix-path`. See [Troubleshooting](#troubleshooting).
44
47
 
45
48
  ## Why gu
46
49
 
47
- - **Your models, your choice.** Run [Ollama](https://ollama.com) or MLX on your own machine or a GPU
48
- box on your network, or bring any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter…). Use
50
+ - **Your models, your choice.** Run [Ollama](https://ollama.com) on your own machine or a GPU box
51
+ on your network, or bring any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter…). Use
49
52
  one model to plan and another to code; switch any time with `/model`.
50
- - **Private by default.** Logs hold no prompts, code or keys unless you run `gu --log`. Need
51
- everything in-house? Private mode runs the API and database on your own servers.
53
+ - **Private by default.** A free install keeps the agent, its API and its database on your
54
+ computer. Logs hold no prompts, code or keys unless you run `gu --log`.
52
55
  - **It proves it works.** gu runs the build and the tests and fixes what fails. For web pages, the
53
56
  `browser` tool drives a real Chrome and checks the result before gu says it is done.
54
57
  - **It stays in the repo.** Every edit is a file you can read, diff and revert; `/undo` puts back
@@ -65,16 +68,14 @@ provider to use. Then type what you want done.
65
68
  cloud, work on remote Linux boxes over SSH, or want a coding agent that costs $4 a month instead
66
69
  of a model subscription.
67
70
 
68
- **Probably not if** you want the strongest frontier model with zero setup and don't mind where your
69
- code goes: the model vendors' own agents are simpler for that. gu can still use those models
70
- through an OpenAI-compatible API, but its strength is everything else.
71
+ **Want a frontier model?** gu runs those too, through any OpenAI-compatible API, and keeps
72
+ everything else: the edits in your repo, the tests, the browser check and `/undo`.
71
73
 
72
74
  ## Quick start
73
75
 
74
76
  1. **Start gu.** Run `gu` in a project folder. Your free 30-day token is created on the spot:
75
77
  no sign-up, no browser, no card.
76
78
  2. **Check your models.** `gu doctor` sets up and tests the models on this machine.
77
- (`gu doctor --client` installs only the agent's dependencies, when the models run elsewhere.)
78
79
  3. **Ask.** Type what you want done. Conversations are saved per folder and resumed next time;
79
80
  `/new` starts a fresh one.
80
81
 
@@ -86,9 +87,10 @@ gu uses up to three models, each with its own command:
86
87
  | --- | --- | --- |
87
88
  | `/model` | **coding**: writes the code | this session (`/quick-models` keeps 2 to flip with ← / →) |
88
89
  | `/agent-model` | **agent**: talks to you and plans | this machine |
89
- | `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine; re-indexes what was indexed |
90
+ | `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine |
90
91
 
91
- Run `/doctor` any time to check that each one answers.
92
+ Run `/doctor` any time to check that each one answers. Coding works best with a strong model:
93
+ small local models often fail at multi-step changes.
92
94
 
93
95
  ## Using gu
94
96
 
@@ -102,10 +104,23 @@ gu [-v|--verbose] [--log] [--color] [--name <text>] [--license <pro token>]
102
104
  | `--log` | log this run in detail to `~/.gonext/worker.log` (default: starts, stops and errors only) |
103
105
  | `--color` | colour the live thought and the thinking spinner |
104
106
  | `--name <text>` | show `<text>` instead of the gu logo in the banner (this run only) |
105
- | `--license <token>` | choose a Pro licence on first install |
107
+ | `--license <token>` | use a Pro licence on first install |
106
108
 
107
109
  Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab completes.
108
110
 
111
+ **The commands you'll use most**
112
+
113
+ | Command | What it does |
114
+ | --- | --- |
115
+ | `/model` | switch the coding model |
116
+ | `/new` | start a new conversation in this folder |
117
+ | `/undo` | put back the files the last stopped turn changed (asks first) |
118
+ | `/test-auto` | for this folder: verify and fix until the tests pass |
119
+ | `/browser` | let the agent check web pages in a real Chrome |
120
+ | `/mcp` | add MCP servers, and choose which tools run without asking |
121
+ | `/doctor` | check that every model answers |
122
+ | `/support` | send a request with gu's logs attached (secrets masked; shows you first) |
123
+
109
124
  <details>
110
125
  <summary><strong>All slash commands</strong></summary>
111
126
 
@@ -117,28 +132,24 @@ Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab
117
132
  | `/new` | start a new conversation in this folder (the old one is archived) |
118
133
  | `/undo [runId]` | put back the files the last stopped turn changed, from its backups (asks first) |
119
134
  | `/test-auto` | toggle auto-test for this folder: verify and fix until it passes |
120
- | `/browser [allow\|forget\|install\|cleanup]` | the agent's web browser, through the gu Chrome extension or gu's own Chrome window ([guide](browser-extension/README.md)) |
135
+ | `/browser [allow\|forget\|install\|cleanup]` | the agent's web browser, through the gu Chrome extension or gu's own Chrome window |
121
136
  | `/shells` | long commands the agent left running in the background: status, output, stop one |
122
- | `/rag` | where this folder's RAG index lives: local, cloud or both |
137
+ | `/rag` | where this folder's RAG index lives |
123
138
  | `/tokens` | break down the last prompt sent to the coding model, including how much was RAG |
124
139
  | `/prices [update]` | price tables, and what the last turn and this session cost |
125
140
  | `/budget` | your API provider budgets and balances (Pro) |
126
141
  | `/mcp [list\|tools\|add\|remove\|enable\|disable\|approve\|allow\|disallow\|reconnect]` | MCP servers, and which of their tools run without asking |
127
142
  | `/hooks [list\|approve\|unapprove]` | commands gu runs on `onStart`, `onAsk`, `onToolDone`, `onTurnEnd` |
128
143
  | `/switch-skill [claude\|gu]` | use Claude Code's (`.claude`) or gu's (`.gonext`) skills, MCP servers and hooks in this folder |
129
- | `/skill-pack <name> [--out <file>] [--runtime <dir>]` | pack a skill with its MCP servers, hooks and code into one zip for another machine (no venvs, databases or secrets) |
130
- | `/skill-install <zip\|folder> [--gu] [--claude] [--dry-run]` | install a packed skill for gu and Claude Code; without gu: `node <name>/install.mjs` |
131
- | `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's: shows its plan, last command and next step, then asks (needs the memory skill) |
132
- | `/skills-copy-to-claude [--dry-run] [name…]` | copy gu's skills into Claude Code's folders |
144
+ | `/skill-pack <name>`, `/skill-install <zip\|folder>` | pack a skill with its MCP servers and hooks into one zip, and install it on another machine (no secrets) |
145
+ | `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's |
133
146
  | `/proxy [show\|set\|on\|off\|clear\|test]` | route model calls through an HTTP proxy (local calls go direct) |
134
147
  | `/s3 [test]` | use your own S3 bucket for the RAG index and generated files |
135
- | `/server` | pick a deployment server (host/user) for this session |
136
- | `/host-mode` | undo client mode: run agent turns on this machine again |
137
148
  | `/save-settings`, `/load-settings` | move providers, models and key *names* (no secrets) to another machine |
138
149
  | `/save-preset`, `/open-presets`, `/use-presets` | your whole setup, keys included, to copy, edit and apply (backs up first) |
139
- | `/clear-logs` | show what logs, RAG indexes and old API versions take on disk, then ask what to clear |
140
- | `/voucher CODE` | use a voucher code: adds free days to this computer's trial or your free account |
141
- | `/support` | send the support team a request with gu's logs attached (secrets masked; shows what goes first) |
150
+ | `/clear-logs` | show what logs and indexes take on disk, then ask what to clear |
151
+ | `/voucher CODE` | add free days to this computer's trial or your free account |
152
+ | `/support` | send the support team a request with gu's logs attached |
142
153
  | `/help`, `/exit` | list commands / quit |
143
154
 
144
155
  Each skill in `~/.gonext/skills/<name>/SKILL.md` (or the folder's `.gonext/skills`) is a slash command too.
@@ -149,32 +160,25 @@ Each skill in `~/.gonext/skills/<name>/SKILL.md` (or the folder's `.gonext/skill
149
160
  <summary><strong>Shell commands</strong></summary>
150
161
 
151
162
  ```
152
- gu status worker status and the running model servers
153
- gu start | stop | restart the background worker (stop --models: model servers too)
154
- gu logs [--lines N] [-f] tail the worker log; gu logs <port|pid|chat|embed> for a model server
155
- gu doctor [--client] set up and check models on this machine
156
- gu rag [plan [path] | --test] test the embedder, list knowledge bases, estimate index time
157
- gu api <start|stop|restart|status|upgrade> the local API (private machines)
163
+ gu status is gu running, and which model servers
164
+ gu start | stop | restart the background worker
165
+ gu logs [--lines N] [-f] follow gu's log
166
+ gu doctor set up and check models on this machine
167
+ gu rag [plan [path] | --test] test the embedder, estimate index time
158
168
  gu workspace revert [runId] undo an agent run's file edits from its backups
159
169
  gu support send a support request with masked logs
160
- gu test-proxy check model calls through a proxy
161
- gu mongo migrate-from-docker macOS: move a Docker MongoDB to Homebrew
162
- gu clear [--scratch|--keep-history] [--yes] back to a fresh machine (asks first)
170
+ gu clear [--yes] back to a fresh machine (asks first)
163
171
  gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
164
172
  ```
165
173
 
166
- `gu-cli` takes the same commands, plus `login [--api-base <url>]`, `set <workerKey>`,
167
- `token [<token>]`, `ws-ping-test` and `simulate-chat [text…]`.
168
-
169
174
  </details>
170
175
 
171
176
  ## Where gu keeps things
172
177
 
173
178
  | Path | Contents |
174
179
  | --- | --- |
175
- | `~/.gonext/worker.env` | sign-in: worker key and API address |
176
180
  | `~/.gonext/sessions/` | conversation history per folder (`archive/` holds old ones) |
177
- | `~/.gonext/worker.log`, `~/.gonext/local-api.log` | logs |
181
+ | `~/.gonext/worker.log` | log |
178
182
  | `~/.gonext/mcp.json` | MCP servers and their allow rules |
179
183
  | `~/.gonext/skills/`, `<folder>/.gonext/skills/` | skills: everywhere / this folder only |
180
184
 
@@ -182,57 +186,35 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
182
186
 
183
187
  | | How long | What you need |
184
188
  | --- | --- | --- |
185
- | **Free trial** | 30 days, starts on the first run | nothing: the token is created for you |
189
+ | **Free trial** | 30 days, from the first run | nothing: the token is created for you |
186
190
  | **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
187
- | **Pro** | while subscribed: **$4 a month** (110.000 ₫) + VAT, or $40 a year | the hosted agent and coder (no model server to run), up to 5 computers, provider budgets |
191
+ | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), up to 5 computers, provider budgets |
192
+
193
+ On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
194
+ send through them go there. A free install keeps everything on your computer.
188
195
 
189
196
  Got a **voucher code**? Type `/voucher CODE` in gu (or enter it on your
190
- [account page](https://gucli.com/account)) for extra free days on top.
197
+ [account page](https://gucli.com/account)) for extra free days.
191
198
 
192
199
  Free time belongs to the computer: reinstalling doesn't restart it. Already have a token? Paste
193
- it at the first-run question instead, or later with `gu-cli token <token>`. Current prices: [gucli.com/pricing](https://gucli.com/pricing).
200
+ it at the first-run question. Current prices: [gucli.com/pricing](https://gucli.com/pricing).
194
201
 
195
202
  ## Troubleshooting
196
203
 
197
204
  **`gu: command not found` after install.** npm 11 skips install scripts it wasn't told to allow,
198
205
  so the step that adds npm's bin folder to your PATH may not have run. Run
199
206
  `npx @tiens.nguyen/gu-cli fix-path`. It also finds what an old install left behind (broken links,
200
- a second gu from another npm, a PATH line pinned to an old Homebrew folder) and asks before
201
- fixing each one. `--check` only reports.
207
+ a second gu from another npm, a stale PATH line) and asks before fixing each one. `--check` only reports.
202
208
 
203
209
  **Windows: `gu` runs `Get-Unique`.** PowerShell has a built-in `gu` alias. Run `gu-cli fix-powershell`.
204
210
 
205
- **Node version.** Node.js 18+ runs the CLI. A private machine (API and database on this
206
- computer) needs **Node.js 20.19+**.
211
+ **Node version.** gu needs **Node.js 20.19+** (a Pro install using only hosted models runs on 18+).
207
212
 
208
213
  **A change in a model or setting seems ignored.** `gu doctor` shows the model and URL actually in use.
209
214
 
210
- **Still stuck?** Run `gu support` (or `/support` in gu, or click **? support** in the footer). It
211
- sends the team your request with gu's logs attached, secrets masked, and shows you what goes
212
- before sending.
213
-
214
- <details>
215
- <summary><strong>Advanced: worker jobs and environment variables</strong></summary>
216
-
217
- The background worker also runs jobs queued from the [web app](https://gucli.com) on this machine:
218
-
219
- - **Agent chat**: choosing **Agent** in the web app runs a
220
- [smolagents](https://github.com/huggingface/smolagents) agent on your local model
221
- (`pip install smolagents certifi`). Cloud models are not allowed here; pick a local MLX or Ollama model.
222
- - **API checks**: the worker makes the HTTP request itself, measures status, latency and body,
223
- and a local model writes a one-line summary.
224
-
225
- | Variable | Meaning |
226
- | --- | --- |
227
- | `GONEXT_API_BASE`, `GONEXT_WORKER_KEY` | API address and worker key (normally from `~/.gonext/worker.env`) |
228
- | `GONEXT_PROBE_PYTHON` | Python for agent scripts (default: `GONEXT_MLX_LM_PYTHON` or `python3`) |
229
- | `GONEXT_MLX_LM_PYTHON` | Python for the MLX probe (default `python3`) |
230
- | `GONEXT_OCR_MODEL_PATH` | local GLM-OCR directory (default `~/mlx-models/GLM-OCR-bf16`) |
231
- | `GONEXT_OCR_DEBUG=1` | print raw OCR output |
232
- | `GONEXT_SIMULATE_TEXT` | default reply for `simulate-chat` |
233
-
234
- </details>
215
+ **Still stuck?** Run `gu support` (or `/support` in gu). It sends the team your request with gu's
216
+ logs attached, secrets masked, and shows you what goes before sending.
235
217
 
236
218
  ## License
237
219
 
238
- [MIT](./LICENSE) © Tien Nguyen Song
220
+ MIT © Tien Nguyen Song
@@ -0,0 +1,96 @@
1
+ /**
2
+ * THE AGENT'S PYTHON PACKAGES, BEFORE THE TERMINAL EXISTS (GH #308).
3
+ *
4
+ * `gu` used to find out that smolagents + openai were missing only once its full-screen terminal was up (raw
5
+ * keyboard, pinned input bar, background worker), and ran pip from inside it. On a brand-new Windows 11 machine
6
+ * that never came back: the pip child was started and gu's whole event loop stopped — no output, no timeout, a
7
+ * fifteen-minute hang — while the very same install, run in a plain process, finished in under a minute.
8
+ *
9
+ * So the install happens FIRST, in a plain process: no readline, no raw mode, no worker, nothing else running.
10
+ * One question (default yes) with an ordinary line prompt, then the installer with its own heartbeat, log file
11
+ * and hard timeout. A failure here never blocks the terminal — it says what to run and carries on.
12
+ */
13
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export const DECLINED_FILE = join(homedir(), ".gonext", "agent-deps-declined");
18
+
19
+ /** Is this start one that should check? Interactive terminals only, and not when a test or script opts out. Pure. */
20
+ export function shouldPreflight({ env = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY), declined = existsSync(DECLINED_FILE) } = {}) {
21
+ if (!stdinTTY || !stdoutTTY) return false;
22
+ if (env.GU_NO_AGENT_DEPS_PROMPT === "1") return false;
23
+ if (declined) return false;
24
+ return true;
25
+ }
26
+
27
+ /** "y"/"" → true, anything else false. Pure. */
28
+ export function yesAnswer(typed) {
29
+ const t = String(typed ?? "").trim().toLowerCase();
30
+ return t === "" || t === "y" || t === "yes";
31
+ }
32
+
33
+ /** A plain one-line question: its own readline, closed before the real terminal creates its own. */
34
+ export async function askLine(question, { input = process.stdin, output = process.stdout } = {}) {
35
+ const { createInterface } = await import("node:readline/promises");
36
+ const rl = createInterface({ input, output, terminal: false });
37
+ try {
38
+ return await rl.question(question);
39
+ } finally {
40
+ rl.close();
41
+ input.pause?.();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * → "present" | "installed" | "declined" | "failed" | "skipped". Every dependency is injected, so the whole decision
47
+ * is testable without Python, a terminal or a network.
48
+ */
49
+ export async function agentDepsPreflight({
50
+ shouldRun = shouldPreflight(),
51
+ hasLibs,
52
+ install,
53
+ ask = askLine,
54
+ markDeclined = () => {},
55
+ log = (s) => console.log(s),
56
+ } = {}) {
57
+ if (!shouldRun) return "skipped";
58
+ let present = false;
59
+ try {
60
+ present = await hasLibs();
61
+ } catch {
62
+ present = false;
63
+ }
64
+ if (present) return "present";
65
+ log("\ngu: first start — the agent framework (smolagents + openai) isn't installed here, and every turn needs it.");
66
+ log(" It is a small pip install into gu's own Python environment; no models, nothing system-wide.");
67
+ const answer = await ask(" Install it now? [Y/n]: ");
68
+ if (!yesAnswer(answer)) {
69
+ markDeclined();
70
+ log(" Skipped — install later with `gu-cli deps` (gu will not ask again at start).");
71
+ return "declined";
72
+ }
73
+ try {
74
+ const out = await install();
75
+ const ok = out && (out.agentLibs === "installed" || out.agentLibs === "present");
76
+ if (!ok) {
77
+ log(" ⚠ the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway.");
78
+ return "failed";
79
+ }
80
+ log(" ✓ the agent framework is installed.\n");
81
+ return "installed";
82
+ } catch (e) {
83
+ log(` ⚠ ${e instanceof Error ? e.message : e}\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
84
+ return "failed";
85
+ }
86
+ }
87
+
88
+ /** Remember "no" so the question is not asked at every start. */
89
+ export function markDeclinedOnDisk() {
90
+ try {
91
+ mkdirSync(dirname(DECLINED_FILE), { recursive: true });
92
+ writeFileSync(DECLINED_FILE, new Date().toISOString() + "\n");
93
+ } catch {
94
+ /* asking again next time is the safe failure */
95
+ }
96
+ }
package/gu-cli.mjs CHANGED
@@ -126,6 +126,7 @@ Usage:
126
126
  gu-cli stop [--models] # stop the daemon (--models: the model servers too)
127
127
  gu-cli logs [--lines N] [-f] # tail the background daemon log
128
128
  gu-cli logs <port|pid|chat|embed> [-f] # tail a MODEL server's log (e.g. logs 8090 -f)
129
+ gu-cli deps # install the agent's Python packages (smolagents + openai)
129
130
  gu-cli doctor [--client] # set up models here (--client: agent deps only,
130
131
  # models stay on another machine)
131
132
  gu-cli api <start|stop|restart|status|upgrade> # the LOCAL API (Local/Client Expert only);
@@ -1818,6 +1819,16 @@ async function guServiceLines() {
1818
1819
  return lines;
1819
1820
  }
1820
1821
 
1822
+ if (args[0] === "deps") {
1823
+ // The agent's Python packages (smolagents + openai), installed in a plain process — what the installer and the
1824
+ // first start run, and what to run by hand when either said it could not (GH #308).
1825
+ const md = await import("./model-doctor.mjs");
1826
+ const out = await md.ensureAgentDeps({ log: (l) => console.log(l), confirm: async () => true, modelHost: false, agreed: ["agentLibs"] });
1827
+ const ok = out.agentLibs === "installed" || out.agentLibs === "present";
1828
+ console.log(ok ? "\n ✓ the agent framework is ready." : "\n ✗ the agent framework is not installed — see the message above and " + md.PIP_LOG_FILE);
1829
+ process.exit(ok ? 0 : 1);
1830
+ }
1831
+
1821
1832
  if (args[0] === "doctor") {
1822
1833
  // Task #304: a Mac whose MongoDB runs in Docker is told the native option, once, up front —
1823
1834
  // a working Docker MongoDB is left alone (it is a choice), a silent one is named as stuck.
package/gu-repl.mjs CHANGED
@@ -610,7 +610,7 @@ const argv = process.argv.slice(2);
610
610
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
611
611
  const CLI_SUBCOMMANDS = [
612
612
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
613
- "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks", "support",
613
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks", "support", "deps",
614
614
  ];
615
615
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
616
616
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -700,6 +700,20 @@ if (REPL_LOG) {
700
700
  process.on("exit", () => ll.releaseLeaseSync());
701
701
  console.log(dim(" --log: detailed logging is on for this run (~/.gonext/worker.log); back to minimal when gu closes."));
702
702
  }
703
+
704
+ // THE AGENT'S PYTHON PACKAGES COME FIRST (GH #308), before the first-run setup and the terminal below create their readline and
705
+ // raw keyboard — and before stdout is hooked for the pinned input bar (whose state does not exist yet here): the install ran from inside the live terminal and froze it on a new Windows 11 machine.
706
+ {
707
+ const pre = await import("./agent-deps-preflight.mjs");
708
+ if (pre.shouldPreflight()) {
709
+ const md0 = await import("./model-doctor.mjs");
710
+ await pre.agentDepsPreflight({
711
+ hasLibs: () => md0.hasAgentLibs(),
712
+ install: () => md0.ensureAgentDeps({ log: (l) => console.log(l), confirm: async () => true, modelHost: false, agreed: ["agentLibs"] }),
713
+ markDeclined: pre.markDeclinedOnDisk,
714
+ });
715
+ }
716
+ }
703
717
  if (REPL_VERBOSE) {
704
718
  process.env.GONEXT_REPL_VERBOSE = "1";
705
719
  }
package/model-doctor.mjs CHANGED
@@ -8,11 +8,12 @@
8
8
  // remote box). Downloads happen only when the model dir is genuinely absent AND the user
9
9
  // opts in.
10
10
  import { spawn, execFile } from "node:child_process";
11
+ import { existsSync } from "node:fs";
11
12
  import { open, mkdir, readdir, writeFile } from "node:fs/promises";
12
13
  import { arch, homedir, platform, totalmem } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { ensureSupportedPython, isBuildToolsMissing, buildFailureMessage } from "./platform-tools.mjs";
16
+ import { ensureSupportedPython, isBuildToolsMissing, buildFailureMessage, ownVenvPython } from "./platform-tools.mjs";
16
17
 
17
18
  // Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
18
19
  const MIN_CHAT_RAM_GB = 16;
@@ -40,9 +41,15 @@ const EMBED_SCRIPT = fileURLToPath(new URL("./gonext_mlx_embed.py", import.meta.
40
41
  function python3Bin() {
41
42
  const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
42
43
  if (explicit) return explicit;
44
+ // gu's OWN environment, when it has one (~/.gonext/venv): the agent's packages were installed INTO it, so a new
45
+ // process must look there, not in the system Python — or it asks to install them at every start (GH #308).
46
+ const own = ownVenvPython();
47
+ if (own) return own;
43
48
  return process.platform === "win32" ? "py" : "python3";
44
49
  }
45
50
 
51
+ export { ownVenvPython };
52
+
46
53
  /**
47
54
  * How to install Python HERE. The advice used to say "Xcode Command Line Tools" on every
48
55
  * platform, which a Windows user cannot act on at all.
@@ -142,24 +149,66 @@ export function isPipMissing(output) {
142
149
  return /No module named pip\b/i.test(String(output ?? ""));
143
150
  }
144
151
 
145
- /** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
146
- function runPip(python, pkgs, extraArgs = []) {
152
+ /** How long one pip run may take before gu stops waiting (GU_PIP_TIMEOUT_MS overrides; a slow line needs more). */
153
+ export const PIP_TIMEOUT_MS = Number(process.env.GU_PIP_TIMEOUT_MS ?? "") || 600_000;
154
+ /** Where a pip run's whole output is kept, so a failure can be read afterwards. */
155
+ export const PIP_LOG_FILE = join(homedir(), ".gonext", "pip-install.log");
156
+
157
+ /**
158
+ * Run pip once. Resolves { code, err, timedOut } — never rejects, so the caller owns the policy.
159
+ *
160
+ * NEVER SILENT, NEVER FOREVER (found on a new Windows 11 box: the install sat at "installing …" for fifteen
161
+ * minutes with no output and no way to tell why). So: a line every 15 s says it is still working, the whole
162
+ * output goes to a log file, and a hard timeout kills the process tree and reports it as a failure that says
163
+ * what to do. stdout is piped and relayed rather than inherited, so nothing about the terminal can stall it.
164
+ */
165
+ function runPip(python, pkgs, extraArgs = [], { log = () => {}, timeoutMs = PIP_TIMEOUT_MS } = {}) {
147
166
  return new Promise((resolve) => {
167
+ const keep = async (d) => {
168
+ try {
169
+ const { mkdirSync, appendFileSync } = await import("node:fs");
170
+ mkdirSync(DIR, { recursive: true });
171
+ appendFileSync(PIP_LOG_FILE, d);
172
+ } catch { /* the log is a convenience */ }
173
+ };
148
174
  // --prefer-binary: take an older wheel over a newer source build (a compiler is not a given, #225).
149
175
  const child = spawn(python, ["-m", "pip", "install", "-U", "--prefer-binary", ...extraArgs, ...pkgs], {
150
176
  // stderr is PIPED so the PEP 668 marker can be recognised, and echoed as it arrives so
151
177
  // the user still watches a real install rather than a frozen line.
152
- stdio: ["ignore", "inherit", "pipe"],
178
+ stdio: ["ignore", "pipe", "pipe"],
153
179
  env: process.env,
180
+ windowsHide: true,
154
181
  });
182
+ keep(`\n--- ${new Date().toISOString()} ${python} -m pip install -U --prefer-binary ${pkgs.join(" ")}\n`);
155
183
  let err = "";
184
+ let done = false;
185
+ const started = Date.now();
186
+ const finish = (r) => {
187
+ if (done) return;
188
+ done = true;
189
+ clearInterval(beat);
190
+ clearTimeout(limit);
191
+ resolve(r);
192
+ };
193
+ child.stdout?.on("data", (d) => { keep(d); process.stdout.write(d); });
156
194
  child.stderr?.on("data", (d) => {
157
195
  const s = String(d);
158
196
  err += s;
197
+ keep(s);
159
198
  process.stderr.write(s);
160
199
  });
161
- child.on("exit", (code) => resolve({ code: code ?? 1, err }));
162
- child.on("error", (e) => resolve({ code: -1, err: String(e?.message ?? e) }));
200
+ const beat = setInterval(() => log(` … still installing (${Math.round((Date.now() - started) / 1000)} s)`), 15_000);
201
+ const limit = setTimeout(() => {
202
+ // The whole tree: py.exe starts python.exe, which starts the build/download helpers.
203
+ if (process.platform === "win32" && child.pid) {
204
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
205
+ } else {
206
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
207
+ }
208
+ finish({ code: 124, err: `pip did not finish within ${Math.round(timeoutMs / 60000)} minutes`, timedOut: true });
209
+ }, timeoutMs);
210
+ child.on("exit", (code) => finish({ code: code ?? 1, err }));
211
+ child.on("error", (e) => finish({ code: -1, err: String(e?.message ?? e) }));
163
212
  });
164
213
  }
165
214
 
@@ -204,9 +253,42 @@ async function pipInstall(pkgs, log) {
204
253
  const sp = await ensureSupportedPython({ log });
205
254
  if (!sp.ok) throw new Error(sp.message);
206
255
  if (sp.switched) await pinPythonForDaemon({ log });
256
+
257
+ const stalled = (r) =>
258
+ new Error(
259
+ `${r.err}. The output is in ${PIP_LOG_FILE}. Install them yourself and start gu again: ` +
260
+ `${python3Bin()} -m pip install -U ${pkgs.join(" ")}`
261
+ );
262
+ const intoVenv = async () => {
263
+ const venvPy = await ensureVenv(log);
264
+ if (!venvPy) return null;
265
+ log(` installing ${pkgs.join(" ")} into gu's own Python environment … (this can take a minute)`);
266
+ const r = await runPip(venvPy, pkgs, [], { log });
267
+ if (r.timedOut) throw stalled(r);
268
+ if (r.code !== 0) {
269
+ if (isBuildToolsMissing(r.err)) throw new Error(buildFailureMessage(r.err));
270
+ return null;
271
+ }
272
+ // Pin it NOW, not at the end of doctor: the packages live in this interpreter and nothing else can
273
+ // import them, so a daemon started meanwhile would still see none.
274
+ process.env.GONEXT_PROBE_PYTHON = venvPy;
275
+ await pinPythonForDaemon({ log });
276
+ log(` installed into the virtualenv and pinned the agent's python to ${venvPy}`);
277
+ return venvPy;
278
+ };
279
+
280
+ // WINDOWS: gu's OWN environment (~/.gonext/venv) from the start. The system Python is somebody else's: writing
281
+ // into it needs an administrator, can clash with their packages, and cannot be undone with one folder delete.
282
+ // (GU_PIP_SYSTEM=1 keeps the old behaviour.)
283
+ if (process.platform === "win32" && process.env.GU_PIP_SYSTEM !== "1") {
284
+ if (await intoVenv()) return;
285
+ log(" ⚠ could not use a private environment — trying the system Python instead");
286
+ }
287
+
207
288
  log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
208
- let r = await runPip(python3Bin(), pkgs);
289
+ let r = await runPip(python3Bin(), pkgs, [], { log });
209
290
  if (r.code === 0) return;
291
+ if (r.timedOut) throw stalled(r);
210
292
  if (isBuildToolsMissing(r.err)) throw new Error(buildFailureMessage(r.err));
211
293
  const noPip = isPipMissing(r.err);
212
294
  if (!noPip && !isExternallyManaged(r.err)) {
@@ -218,18 +300,7 @@ async function pipInstall(pkgs, log) {
218
300
  ? " this python has no pip — using a private virtualenv, which brings its own."
219
301
  : " this python is managed by your OS (PEP 668) — it will not accept packages directly."
220
302
  );
221
- const venvPy = await ensureVenv(log);
222
- if (venvPy) {
223
- r = await runPip(venvPy, pkgs);
224
- if (r.code === 0) {
225
- // Pin it NOW, not at the end of doctor: the packages live in this interpreter and
226
- // nothing else can import them, so a daemon started meanwhile would still see none.
227
- process.env.GONEXT_PROBE_PYTHON = venvPy;
228
- await pinPythonForDaemon({ log });
229
- log(` installed into the virtualenv and pinned the agent's python to ${venvPy}`);
230
- return;
231
- }
232
- }
303
+ if (process.platform !== "win32" && (await intoVenv())) return;
233
304
 
234
305
  // Without pip AND without venv nothing here can install anything; --break-system-packages
235
306
  // would only fail again with the same "No module named pip". Say what to install instead.
@@ -239,7 +310,8 @@ async function pipInstall(pkgs, log) {
239
310
  );
240
311
  }
241
312
  log(" falling back to --break-system-packages (no virtualenv available)");
242
- r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
313
+ r = await runPip(python3Bin(), pkgs, ["--break-system-packages"], { log });
314
+ if (r.timedOut) throw stalled(r);
243
315
  if (r.code === 0) return;
244
316
  throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
245
317
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.891",
4
- "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama, MLX or any OpenAI-compatible provider). macOS, Linux and Windows.",
3
+ "version": "1.0.893",
4
+ "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "coding-agent",
@@ -9,7 +9,6 @@
9
9
  "terminal",
10
10
  "llm",
11
11
  "ollama",
12
- "mlx",
13
12
  "openai",
14
13
  "agent",
15
14
  "mcp",
@@ -63,6 +62,7 @@
63
62
  },
64
63
  "files": [
65
64
  "accent.mjs",
65
+ "agent-deps-preflight.mjs",
66
66
  "edit-card-render.mjs",
67
67
  "diff-syntax.mjs",
68
68
  "theme.mjs",
@@ -15,6 +15,9 @@
15
15
  * been executed on Windows, and a parser with tests is not a working port.
16
16
  */
17
17
  import { execFile } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
18
21
 
19
22
  const IS_WINDOWS = process.platform === "win32";
20
23
 
@@ -214,9 +217,22 @@ let _python = null;
214
217
  * An explicit GONEXT_PROBE_PYTHON still wins, and is passed through untouched.
215
218
  * Cached: otherwise this probes a subprocess on every job.
216
219
  */
220
+ /**
221
+ * The interpreter of gu's own private virtualenv (~/.gonext/venv), or "" when there is none. Pure apart from one
222
+ * file check. The agent's packages are installed INTO it on Windows (GH #308), so everything that runs the agent —
223
+ * the terminal's checks AND the background worker — must look there, whatever worker.env did or did not pin.
224
+ */
225
+ export function ownVenvPython(home = homedir(), exists = existsSync, platform = process.platform) {
226
+ const win = platform === "win32";
227
+ const py = join(home, ".gonext", "venv", win ? "Scripts" : "bin", win ? "python.exe" : "python");
228
+ return exists(py) ? py : "";
229
+ }
230
+
217
231
  export async function resolvePython() {
218
232
  const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
219
233
  if (explicit) return { cmd: explicit, args: [] };
234
+ const own = ownVenvPython();
235
+ if (own) return { cmd: own, args: [] };
220
236
  if (_python) return _python;
221
237
  for (const candidate of pythonCandidates()) {
222
238
  const probe = pythonVersionProbe(candidate);
@@ -474,6 +490,39 @@ export function isElevatedGroups(whoamiGroups) {
474
490
  return /S-1-16-(12288|16384)\b/.test(String(whoamiGroups ?? ""));
475
491
  }
476
492
 
493
+ /**
494
+ * DID THE SCHEDULED TASK NEVER START? — from `schtasks /query /v /fo list`.
495
+ *
496
+ * A task runs only while its user has a logon session. Over SSH to an administrator who is not
497
+ * logged on at the console (a test box, a server), `schtasks /run` answers SUCCESS and the task
498
+ * stays "Ready" with Last Result 267011 (0x41303, "has not yet run") — nothing starts, and setup
499
+ * then stops at "The API started but is not usable: not up yet" with no log at all. Pure.
500
+ */
501
+ export function taskNeverRan(queryOutput) {
502
+ const text = String(queryOutput ?? "");
503
+ return /Last Result:\s*267011\b/i.test(text) || /Last Run Time:\s*(N\/A|11\/30\/1999)/i.test(text);
504
+ }
505
+
506
+ /**
507
+ * START A PROCESS THAT OUTLIVES AN SSH SESSION, without a logon session: WMI's Win32_Process.Create.
508
+ *
509
+ * The process is created by the WMI service, so it is neither in the SSH session's job object (which
510
+ * kills its children at logoff) nor dependent on the user being logged on (which a scheduled task is).
511
+ * Needs an administrator token; measured on a Windows 11 box: a loop started this way kept running
512
+ * after the ssh session that started it had ended. → the pid, or null when WMI refused.
513
+ */
514
+ export async function startViaWmi(commandLine, cwd = "") {
515
+ const script =
516
+ "$a = @{ CommandLine = $env:GU_WMI_CMD }; if ($env:GU_WMI_CWD) { $a.CurrentDirectory = $env:GU_WMI_CWD }; " +
517
+ "$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a; " +
518
+ 'Write-Output "WMI $($r.ReturnValue) $($r.ProcessId)"';
519
+ const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
520
+ env: { ...process.env, GU_WMI_CMD: commandLine, GU_WMI_CWD: cwd },
521
+ });
522
+ const m = /WMI 0 (\d+)/.exec(String(out ?? ""));
523
+ return m ? Number(m[1]) : null;
524
+ }
525
+
477
526
  /**
478
527
  * Which pid owns a listening port, from `netstat -ano`.
479
528
  *
@@ -564,6 +613,27 @@ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, t
564
613
  // generous: this runs once per start, and being slow here is free.
565
614
  await new Promise((r) => setTimeout(r, TASK_SETTLE_MS));
566
615
 
616
+ // THE TASK MAY NEVER HAVE STARTED (no logon session for this user — an SSH-only administrator).
617
+ // Then start it as a plain child of this terminal: it lives as long as the session does, which
618
+ // is what an SSH user gets everywhere else, and far better than "not up yet".
619
+ if (taskNeverRan(await run("schtasks.exe", ["/query", "/tn", taskName, "/v", "/fo", "list"]))) {
620
+ await run(t.remove.cmd, t.remove.args);
621
+ // Best first: WMI starts it outside the SSH session's job, so it survives the terminal closing.
622
+ const wmiPid = await startViaWmi(hiddenTaskAction(script, process.env.SystemRoot), cwd || "");
623
+ if (wmiPid) return { ok: true, pid: wmiPid, how: "WMI (the scheduled task never started — no logon session)" };
624
+ await rm(script).catch(() => {});
625
+ const { spawn } = await import("node:child_process");
626
+ const { open } = await import("node:fs/promises");
627
+ const log = logFile ? await open(logFile, "a") : null;
628
+ const child = spawn(cmd, args, {
629
+ cwd, env: { ...process.env, ...env }, stdio: ["ignore", log ? log.fd : "ignore", log ? log.fd : "ignore"],
630
+ detached: true, windowsHide: true,
631
+ });
632
+ child.unref();
633
+ await log?.close().catch(() => {});
634
+ return { ok: true, pid: child.pid ?? null, how: "detached (the scheduled task never started — no logon session)" };
635
+ }
636
+
567
637
  // Then deregister: nothing should be left scheduled on the user's machine. `/sc once` with a
568
638
  // start time in the past would otherwise re-fire, and a task named after gu sitting in
569
639
  // Task Scheduler forever is not something anyone asked for.