@shubhamcodess/whatcmd 0.0.0-stage → 0.1.0

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/dist/doctor.js ADDED
@@ -0,0 +1,224 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { cacheDir, DEFAULT_MODEL, loadConfig, saveStored } from "./config.js";
5
+ import { detectProfile, profileHeadline } from "./env.js";
6
+ import { generate } from "./generate.js";
7
+ import { docFor } from "./ground.js";
8
+ import { getVersion, hasModel, isUp, listModels, pull } from "./ollama.js";
9
+ import { bashTooOld, installHook, isHookInstalled, isHookShell, rcFile } from "./shell.js";
10
+ import { ask, bold, canPrompt, confirm, cyan, dim, err, fail, formatBytes, green, info, ok, warn } from "./ui.js";
11
+ import { clearWhichCache, which } from "./util.js";
12
+ export const RECOMMENDED = [
13
+ { name: DEFAULT_MODEL, size: "~1.0 GB", note: "recommended: most accurate in our tests, ~0.5 s per command on a laptop CPU" },
14
+ { name: "qwen2.5-coder:0.5b", size: "~0.4 GB", note: "fastest, but much less accurate (62% vs 85% in our 500-case test); not recommended" },
15
+ { name: "qwen2.5-coder:3b", size: "~1.9 GB", note: "for a GPU or fast CPU; slower, not benchmarked yet" },
16
+ ];
17
+ const BASH_TOO_OLD = "Your bash is older than 4.0 (macOS ships 3.2), which can't host the /w shortcut. " +
18
+ "Use zsh (the macOS default), or install a newer bash: brew install bash. You can still run: whatcmd <request>";
19
+ function installCommand(p) {
20
+ if (p.os === "linux")
21
+ return "curl -fsSL https://ollama.com/install.sh | sh";
22
+ if (p.os === "macos" && which("brew"))
23
+ return "brew install ollama";
24
+ return null;
25
+ }
26
+ async function waitUp(host, ms) {
27
+ const end = Date.now() + ms;
28
+ while (Date.now() < end) {
29
+ if (await isUp(host))
30
+ return true;
31
+ await new Promise((r) => setTimeout(r, 300));
32
+ }
33
+ return false;
34
+ }
35
+ function startServer(bin) {
36
+ fs.mkdirSync(cacheDir(), { recursive: true });
37
+ const log = path.join(cacheDir(), "ollama.log");
38
+ const fd = fs.openSync(log, "a");
39
+ spawn(bin, ["serve"], { detached: true, stdio: ["ignore", fd, fd] }).unref();
40
+ return log;
41
+ }
42
+ async function chooseModel(installed, current) {
43
+ err(bold("\nChoose a model for whatcmd:"));
44
+ const options = [];
45
+ for (const r of RECOMMENDED) {
46
+ options.push(r.name);
47
+ const have = hasModel(installed, r.name) ? green("downloaded") : r.size;
48
+ err(` ${options.length}) ${cyan(r.name.padEnd(20))} ${have.padEnd(12)} ${dim(r.note)}`);
49
+ }
50
+ for (const m of installed) {
51
+ if (RECOMMENDED.some((r) => r.name === m.name) || /embed/i.test(m.name))
52
+ continue;
53
+ options.push(m.name);
54
+ err(` ${options.length}) ${cyan(m.name.padEnd(20))} ${green("downloaded")} ${dim(formatBytes(m.size))}`);
55
+ }
56
+ err(` ${options.length + 1}) Type another Ollama model name`);
57
+ const def = Math.max(1, options.indexOf(current) + 1);
58
+ for (;;) {
59
+ const a = await ask(`Choice [${def}]: `);
60
+ if (!a)
61
+ return options[def - 1];
62
+ const n = Number(a);
63
+ if (n >= 1 && n <= options.length)
64
+ return options[n - 1];
65
+ if (n === options.length + 1) {
66
+ const name = await ask("Model name (see https://ollama.com/library): ");
67
+ if (name)
68
+ return name;
69
+ }
70
+ else if (!/^\d+$/.test(a))
71
+ return a; // they typed a model name directly
72
+ }
73
+ }
74
+ export async function downloadModel(host, model) {
75
+ err(`Downloading ${cyan(model)} …`);
76
+ let last = "";
77
+ await pull(host, model, (status, completed, total) => {
78
+ const line = total && completed !== undefined ? ` ${status.slice(0, 30)} ${Math.floor((completed / total) * 100)}% of ${formatBytes(total)}` : ` ${status}`;
79
+ if (line === last)
80
+ return;
81
+ last = line;
82
+ if (process.stderr.isTTY)
83
+ process.stderr.write(`\r\x1b[K${line}`);
84
+ });
85
+ if (process.stderr.isTTY)
86
+ process.stderr.write("\r\x1b[K");
87
+ ok(`Downloaded ${model}`);
88
+ }
89
+ /** Returns true when everything needed to generate commands works. */
90
+ export async function doctor(opts) {
91
+ let cfg = loadConfig();
92
+ const firstRun = !cfg.profile; // first run: let the user pick a model even if the default exists
93
+ const interactive = canPrompt() && !opts.yes;
94
+ err(bold("whatcmd doctor\n"));
95
+ // 1. System profile: goes into every prompt so commands fit this OS and its installed tools.
96
+ let profile = cfg.profile;
97
+ if (!profile || opts.refreshProfile) {
98
+ profile = detectProfile();
99
+ saveStored({ profile });
100
+ }
101
+ ok(`System: ${profileHeadline(profile)}`);
102
+ info(`installed tools the model may use: ${profile.installed.join(", ") || "none detected"}`);
103
+ // 2. Ollama installed and running?
104
+ let up = await isUp(cfg.host);
105
+ let bin = which("ollama");
106
+ if (!up && !bin) {
107
+ fail("Ollama is not installed. whatcmd runs models locally through Ollama.");
108
+ const cmd = installCommand(profile);
109
+ if (cmd)
110
+ info(`install it with: ${cmd}`);
111
+ info("or download it from https://ollama.com/download");
112
+ if (cmd && interactive && (await confirm(`Run "${cmd}" now?`, false))) {
113
+ spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
114
+ clearWhichCache();
115
+ bin = which("ollama");
116
+ up = await waitUp(cfg.host, 8000);
117
+ }
118
+ if (!bin && !up) {
119
+ info("then run `whatcmd doctor` again.");
120
+ return false;
121
+ }
122
+ }
123
+ if (!up && bin) {
124
+ warn(`Ollama is installed but not running at ${cfg.host}`);
125
+ if (opts.yes || (interactive && (await confirm("Start it in the background now?", true)))) {
126
+ const log = startServer(bin);
127
+ up = await waitUp(cfg.host, 10000);
128
+ if (!up)
129
+ fail(`Ollama did not start; see ${log}`);
130
+ }
131
+ else
132
+ info("start it with: ollama serve");
133
+ if (!up)
134
+ return false;
135
+ }
136
+ ok(`Ollama ${await getVersion(cfg.host)} is running at ${cfg.host}`);
137
+ // 3. Model downloaded?
138
+ const installed = await listModels(cfg.host);
139
+ let model = opts.model ?? cfg.model;
140
+ if (!hasModel(installed, model) || (interactive && firstRun && !opts.model)) {
141
+ if (!hasModel(installed, model))
142
+ warn(`Model ${model} is not downloaded yet`);
143
+ if (interactive)
144
+ model = await chooseModel(installed, model);
145
+ else if (!opts.yes) {
146
+ info(`download it with: whatcmd use ${model} (or run \`whatcmd doctor\` in a terminal to pick one)`);
147
+ return false;
148
+ }
149
+ if (!hasModel(installed, model))
150
+ await downloadModel(cfg.host, model);
151
+ }
152
+ if (model !== cfg.model)
153
+ saveStored({ model });
154
+ cfg = loadConfig();
155
+ ok(`Model ${model} is ready`);
156
+ // Pre-load docs of installed tools so the first request never waits on a slow `--help`.
157
+ let docs = 0;
158
+ for (const tool of profile.installed) {
159
+ const p = which(tool);
160
+ if (p && docFor(tool, p))
161
+ docs++;
162
+ }
163
+ ok(`Cached local docs for ${docs} installed tools`);
164
+ // 4. Real test request (first one also loads the model into memory).
165
+ let t = performance.now();
166
+ const r = await generate("list files in this folder", cfg);
167
+ const loadMs = Math.round(performance.now() - t);
168
+ t = performance.now();
169
+ const r2 = await generate("show free disk space", cfg);
170
+ const ms = Math.round(performance.now() - t);
171
+ if (r.kind === "command" && r2.kind === "command") {
172
+ ok(`Test request answered in ${ms} ms ${dim(`(first call, incl. loading the model: ${loadMs} ms)`)}`);
173
+ info(`"show free disk space" → ${r2.command}`);
174
+ }
175
+ else
176
+ warn(`Model answered, but the test output was rejected (${r.kind === "rejected" ? r.reason : r2.kind === "rejected" ? r2.reason : ""}). Try another model with \`whatcmd use\`.`);
177
+ // 5. Shell shortcut.
178
+ if (opts.fromInit)
179
+ return true;
180
+ if (profile.shell === "bash" && bashTooOld())
181
+ warn(BASH_TOO_OLD);
182
+ else if (isHookShell(profile.shell)) {
183
+ if (isHookInstalled(profile.shell, profile.os))
184
+ ok(`Shell shortcut is set up: type ${cyan(`${cfg.prefix} <request>`)} at any prompt`);
185
+ else
186
+ warn(`Shell shortcut not set up yet. Run \`whatcmd init\` to enable ${cfg.prefix} <request>`);
187
+ }
188
+ else
189
+ warn(`${profile.shell} has no ${cfg.prefix} shortcut yet (bash and zsh do). Use: whatcmd <request>`);
190
+ return true;
191
+ }
192
+ /** First-time setup: doctor + shell shortcut. */
193
+ export async function init(opts) {
194
+ const ready = await doctor({ ...opts, refreshProfile: true, fromInit: true });
195
+ const cfg = loadConfig();
196
+ const profile = cfg.profile ?? detectProfile();
197
+ const shell = profile.shell;
198
+ err("");
199
+ if (!isHookShell(shell)) {
200
+ warn(`The ${cfg.prefix} shortcut supports bash and zsh; your shell is ${shell}. You can still run: whatcmd <request>`);
201
+ return ready;
202
+ }
203
+ if (shell === "bash" && bashTooOld()) {
204
+ warn(BASH_TOO_OLD);
205
+ return ready;
206
+ }
207
+ const rc = rcFile(shell, profile.os);
208
+ if (isHookInstalled(shell, profile.os)) {
209
+ installHook(shell, cfg.prefix, profile.os); // refresh the hook file
210
+ ok(`Shell shortcut already in ${rc}`);
211
+ }
212
+ else if (opts.yes || !canPrompt() || (await confirm(`Add the ${cfg.prefix} shortcut to ${rc}?`, true))) {
213
+ installHook(shell, cfg.prefix, profile.os);
214
+ ok(`Added the shortcut to ${rc}`);
215
+ }
216
+ else {
217
+ info(`Skipped. To add it yourself, put this in ${rc}: eval "$(whatcmd shell ${shell})"`);
218
+ return ready;
219
+ }
220
+ err(`\n${bold("Done.")} Open a new terminal (or run ${cyan(`source ${rc}`)}), then type:`);
221
+ err(` ${cyan(`${cfg.prefix} find files bigger than 100MB`)} and press Enter`);
222
+ err(dim(" The command appears on your prompt. Press Enter again to run it, or edit it first."));
223
+ return ready;
224
+ }
package/dist/env.js ADDED
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { run, which } from "./util.js";
5
+ /** Tools the model often reaches for; knowing which exist avoids "command not found". */
6
+ export const NOTABLE_TOOLS = [
7
+ "ss", "netstat", "lsof", "fuser", "ip", "ifconfig", "curl", "wget", "dig", "nc",
8
+ "rg", "fd", "fzf", "jq", "tree", "rsync", "zip", "unzip", "7z",
9
+ "git", "docker", "podman", "kubectl", "systemctl", "journalctl",
10
+ "python3", "node", "ffmpeg", "magick", "htop",
11
+ ];
12
+ const PACKAGE_MANAGERS = [
13
+ ["brew", "brew"], ["apt-get", "apt"], ["dnf", "dnf"], ["yum", "yum"], ["pacman", "pacman"],
14
+ ["zypper", "zypper"], ["apk", "apk"], ["nix-env", "nix"],
15
+ ];
16
+ export function parseOsRelease(text) {
17
+ const get = (k) => {
18
+ const m = new RegExp(`^${k}=(?:"([^"]*)"|(.*))$`, "m").exec(text);
19
+ return m ? (m[1] ?? m[2] ?? "").trim() : "";
20
+ };
21
+ return get("PRETTY_NAME") || [get("NAME"), get("VERSION_ID")].filter(Boolean).join(" ");
22
+ }
23
+ export function detectProfile() {
24
+ const plat = process.platform;
25
+ const osName = plat === "linux" ? "linux" : plat === "darwin" ? "macos" : plat === "win32" ? "windows" : "other";
26
+ let distro = "";
27
+ let wsl = false;
28
+ if (osName === "linux") {
29
+ try {
30
+ distro = parseOsRelease(fs.readFileSync("/etc/os-release", "utf8"));
31
+ }
32
+ catch { }
33
+ try {
34
+ wsl = /microsoft/i.test(fs.readFileSync("/proc/version", "utf8"));
35
+ }
36
+ catch { }
37
+ }
38
+ else if (osName === "macos") {
39
+ const r = run("sw_vers", ["-productVersion"], { timeoutMs: 1000 });
40
+ distro = r.ok ? `macOS ${r.stdout.trim()}` : "macOS";
41
+ }
42
+ let userland = "unknown";
43
+ if (osName === "macos")
44
+ userland = "bsd";
45
+ else if (osName === "linux") {
46
+ const ls = which("ls");
47
+ let real = "";
48
+ try {
49
+ real = ls ? fs.realpathSync(ls) : "";
50
+ }
51
+ catch { }
52
+ if (/busybox/.test(real))
53
+ userland = "busybox";
54
+ else if (/GNU/.test(run("ls", ["--version"], { timeoutMs: 1000 }).stdout))
55
+ userland = "gnu";
56
+ }
57
+ const packageManager = PACKAGE_MANAGERS.find(([bin]) => which(bin))?.[1] ?? null;
58
+ const installed = NOTABLE_TOOLS.filter((t) => which(t));
59
+ const missing = NOTABLE_TOOLS.filter((t) => !installed.includes(t));
60
+ return {
61
+ os: osName,
62
+ distro,
63
+ wsl,
64
+ arch: os.arch(),
65
+ shell: path.basename(process.env.SHELL || "") || "sh",
66
+ packageManager,
67
+ userland,
68
+ installed,
69
+ missing,
70
+ detectedAt: new Date().toISOString(),
71
+ };
72
+ }
73
+ export function profileHeadline(p) {
74
+ const name = p.distro || p.os;
75
+ return `${name}${p.wsl ? " (WSL)" : ""}, ${p.arch}, ${p.shell}${p.packageManager ? `, ${p.packageManager}` : ""}`;
76
+ }
77
+ /** The lines added to the model's system prompt. */
78
+ export function profilePrompt(p) {
79
+ const lines = [
80
+ `Target system: ${p.distro || p.os}${p.wsl ? " running in WSL" : ""} (${p.os}, ${p.arch}). Shell: ${p.shell}.` +
81
+ (p.packageManager ? ` Package manager: ${p.packageManager}.` : ""),
82
+ ];
83
+ if (p.userland === "bsd")
84
+ lines.push("Use macOS/BSD command flags (e.g. sed -i '', stat -f, date -v), not GNU-only flags.");
85
+ else if (p.userland === "busybox")
86
+ lines.push("Commands are BusyBox versions with limited flags.");
87
+ else if (p.userland === "gnu")
88
+ lines.push("Commands are GNU versions.");
89
+ if (p.installed.length)
90
+ lines.push(`Installed tools: ${p.installed.join(", ")}.`);
91
+ if (p.missing.length)
92
+ lines.push(`Not installed (avoid): ${p.missing.join(", ")}.`);
93
+ return lines.join("\n");
94
+ }
package/dist/gate.js ADDED
@@ -0,0 +1,179 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { which } from "./util.js";
4
+ /**
5
+ * The gate decides whether model output is a real, usable shell command.
6
+ * Anything that is not (a NONE answer, chat text, unparseable output) produces nothing.
7
+ */
8
+ export const BUILTINS = new Set(("alias bg bind break builtin caller cd command compgen complete continue declare dirs disown echo enable " +
9
+ "eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf " +
10
+ "pushd pwd read readarray readonly return set shift shopt source suspend test times trap true type typeset " +
11
+ "ulimit umask unalias unset wait . : [ [[ ]] if then else elif fi for while until do done case esac in " +
12
+ "function select time { } ! coproc").split(" "));
13
+ /** Prefixes that run another command; we skip them to find the real one. */
14
+ const WRAPPERS = new Set(["sudo", "doas", "env", "nohup", "nice", "ionice", "command", "exec", "builtin", "time"]);
15
+ /** Wrapper options that take a value, e.g. `nice -n 10 cmd`, `sudo -u bob cmd`. */
16
+ const WRAPPER_FLAGS_WITH_ARG = {
17
+ sudo: new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-U", "-r", "-t"]),
18
+ doas: new Set(["-u", "-C"]),
19
+ nice: new Set(["-n", "--adjustment"]),
20
+ ionice: new Set(["-c", "-n", "-p", "-t"]),
21
+ env: new Set(["-u", "--unset", "-C", "--chdir", "-S"]),
22
+ time: new Set(["-f", "-o", "--format", "--output"]),
23
+ exec: new Set(["-a"]),
24
+ };
25
+ /** Pull the command out of model output: drop code fences, "$ " prompts and stray backticks. */
26
+ export function extractCommand(raw) {
27
+ const line = raw
28
+ .split("\n")
29
+ .map((s) => s.trim())
30
+ .find((s) => s && !s.startsWith("```"));
31
+ if (!line)
32
+ return "";
33
+ return line
34
+ .replace(/^(command|cmd)\s*:\s*/i, "")
35
+ .replace(/^\$\s+/, "")
36
+ .replace(/^`+|`+$/g, "")
37
+ .trim();
38
+ }
39
+ /** Split a command line into pipeline/list segments, respecting quotes. */
40
+ export function splitSegments(cmd) {
41
+ const out = [];
42
+ let cur = "";
43
+ let quote = null;
44
+ for (let i = 0; i < cmd.length; i++) {
45
+ const ch = cmd[i];
46
+ if (quote) {
47
+ cur += ch;
48
+ if (ch === "\\" && quote === '"' && i + 1 < cmd.length)
49
+ cur += cmd[++i];
50
+ else if (ch === quote)
51
+ quote = null;
52
+ continue;
53
+ }
54
+ if (ch === "'" || ch === '"') {
55
+ quote = ch;
56
+ cur += ch;
57
+ continue;
58
+ }
59
+ if (ch === "\\" && i + 1 < cmd.length) {
60
+ cur += ch + cmd[++i];
61
+ continue;
62
+ }
63
+ if (ch === "|" || ch === ";" || ch === "&" || ch === "\n") {
64
+ // keep redirections like 2>&1, >&2, &> intact
65
+ if (ch === "&" && (/[<>]$/.test(cur) || cmd[i + 1] === ">")) {
66
+ cur += ch;
67
+ continue;
68
+ }
69
+ out.push(cur);
70
+ cur = "";
71
+ if (cmd[i + 1] === ch || (ch === "|" && cmd[i + 1] === "&"))
72
+ i++;
73
+ continue;
74
+ }
75
+ cur += ch;
76
+ }
77
+ out.push(cur);
78
+ return out.map((s) => s.trim()).filter(Boolean);
79
+ }
80
+ function tokenize(segment) {
81
+ return segment.match(/(?:[^\s'"]+|'[^']*'|"(?:\\.|[^"\\])*")+/g) ?? [];
82
+ }
83
+ /** The program each segment runs (after skipping sudo/env/VAR=x and similar). */
84
+ export function commandWords(cmd) {
85
+ const words = [];
86
+ for (const seg of splitSegments(cmd)) {
87
+ const toks = tokenize(seg.replace(/^[({!\s]+/, ""));
88
+ let i = 0;
89
+ while (i < toks.length) {
90
+ const t = toks[i];
91
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
92
+ i++;
93
+ continue;
94
+ }
95
+ if (WRAPPERS.has(t)) {
96
+ words.push(t);
97
+ i++;
98
+ while (i < toks.length && (toks[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]))) {
99
+ if (WRAPPER_FLAGS_WITH_ARG[t]?.has(toks[i]))
100
+ i++;
101
+ i++;
102
+ }
103
+ continue;
104
+ }
105
+ words.push(t.replace(/^['"]|['"]$/g, ""));
106
+ break; // for xargs/timeout/watch only the wrapper itself is checked
107
+ }
108
+ }
109
+ return words;
110
+ }
111
+ export function parses(cmd) {
112
+ const sh = which("bash") ? "bash" : which("sh") ? "sh" : null;
113
+ if (!sh)
114
+ return true;
115
+ const r = spawnSync(sh, ["-n", "-c", cmd], { stdio: "ignore", timeout: 1000 });
116
+ return r.status === 0;
117
+ }
118
+ export function commandExists(word) {
119
+ if (BUILTINS.has(word))
120
+ return true;
121
+ if (word.startsWith("~"))
122
+ return true; // user path; can't resolve reliably
123
+ if (word.includes("/")) {
124
+ try {
125
+ fs.accessSync(word);
126
+ return true;
127
+ }
128
+ catch {
129
+ return word.startsWith("./") || word.startsWith("../"); // a script the user may be about to create
130
+ }
131
+ }
132
+ return which(word) !== null;
133
+ }
134
+ const RISKS = [
135
+ [/\brm\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*[rR][a-zA-Z]*\s+(-[a-zA-Z]*\s+)*(\/|~|\$HOME|\/\*)(\s|$)/, "deletes your whole system or home folder"],
136
+ [/\brm\s+(-[a-zA-Z]*\s+)*(-[a-zA-Z]*[rR]|--recursive)/, "deletes files recursively"],
137
+ [/\bdd\b[^|;&]*\bof=/, "dd overwrites the target file or disk"],
138
+ [/\bmkfs(\.\w+)?\b|\bwipefs\b|\bfdisk\b|\bparted\b/, "formats or repartitions a disk"],
139
+ [/>\s*\/dev\/(sd|nvme|hd|disk|mmcblk)/, "writes directly to a disk device"],
140
+ [/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, "fork bomb: will freeze the machine"],
141
+ [/\bchmod\s+(-[a-zA-Z]+\s+)*([0-7]?[0-7]{2}[2367]\b|[ugoa]*[ao][ugoa]*\+[rwxXst]*w)/, "makes files writable by everyone"],
142
+ [/\bch(own|mod)\s+(-[a-zA-Z]*R|--recursive)/, "changes ownership/permissions recursively"],
143
+ [/\b(shutdown|reboot|halt|poweroff)\b/, "shuts down or restarts the machine"],
144
+ [/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b|\b(ba|z|da)?sh\s+-c\s+["']?\$\(\s*(curl|wget)\b|(\b(ba|z|da)?sh|\bsource|^\.)\s+<\(\s*(curl|wget)\b/, "runs a script downloaded from the internet"],
145
+ [/\bgit\s+(push\b[^|;&]*(--force|-f)\b|reset\s+--hard|clean\s+-[a-z]*f)/, "discards git history or local changes"],
146
+ [/\bkill(all)?\s+(-9|-KILL)\s+-1\b/, "kills all your processes"],
147
+ [/\bsudo\b/, "runs as root"],
148
+ [/\/path\/to\b|<[a-z_-]+>|\b(YOUR|your)_[A-Za-z_]+|\bPID\b|\byour-[a-z-]+/, "contains a placeholder: edit it before running"],
149
+ ];
150
+ export function riskWarnings(cmd) {
151
+ const out = [];
152
+ for (const [re, msg] of RISKS) {
153
+ if (!re.test(cmd))
154
+ continue;
155
+ if (msg === "deletes files recursively" && out.includes("deletes your whole system or home folder"))
156
+ continue;
157
+ out.push(msg);
158
+ }
159
+ return out;
160
+ }
161
+ export function check(raw, exists = commandExists) {
162
+ const command = extractCommand(raw);
163
+ if (!command)
164
+ return { ok: false, reason: "empty" };
165
+ if (/^NONE\b/i.test(command))
166
+ return { ok: false, reason: "not-a-shell-task" };
167
+ if (!parses(command))
168
+ return { ok: false, reason: "unparseable" };
169
+ const words = commandWords(command);
170
+ const segments = splitSegments(command);
171
+ if (segments.length === 1 && /^(echo|printf)$/.test(words[0] ?? "") && !/[$`>]/.test(command))
172
+ return { ok: false, reason: "chat-reply" };
173
+ const missing = [...new Set(words.filter((w) => !exists(w)))];
174
+ return { ok: true, command, missing, risks: riskWarnings(command) };
175
+ }
176
+ /** A missing first word that doesn't even look like a program name is prose, not a command. */
177
+ export function looksLikeProgramName(word) {
178
+ return /^[a-z][a-z0-9._+-]*$/.test(word);
179
+ }
@@ -0,0 +1,120 @@
1
+ import { detectProfile } from "./env.js";
2
+ import { check, looksLikeProgramName } from "./gate.js";
3
+ import { ground } from "./ground.js";
4
+ import { alternativeFor, autofix, isCalculatorAnswer, isDegenerate, isInjection, lint, looksLikeShellTask } from "./lint.js";
5
+ import { chat } from "./ollama.js";
6
+ import { buildMessages, copiesExample, missingHint } from "./prompt.js";
7
+ const MAX_CALLS = 3;
8
+ const since = (t) => Math.round(performance.now() - t);
9
+ const normWords = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
10
+ /** The model sometimes just repeats the request ("zorbctl set the level to 5"). */
11
+ export function echoesRequest(cmd, ask) {
12
+ if (normWords(cmd) === normWords(ask))
13
+ return true;
14
+ if (/[-./|>$=<'"`*]/.test(cmd))
15
+ return false;
16
+ const words = normWords(cmd).split(" ");
17
+ if (words.length < 4)
18
+ return false;
19
+ const askWords = new Set(normWords(ask).split(" "));
20
+ return words.filter((w) => askWords.has(w)).length / words.length >= 0.8;
21
+ }
22
+ function evaluate(raw, ask, profile) {
23
+ const verdict = check(raw);
24
+ if (!verdict.ok)
25
+ return { verdict, issues: [], echoed: false };
26
+ if (isDegenerate(verdict.command))
27
+ return { verdict: { ok: false, reason: "unparseable" }, issues: [], echoed: false };
28
+ if (isCalculatorAnswer(verdict.command, ask))
29
+ return { verdict: { ok: false, reason: "chat-reply" }, issues: [], echoed: false };
30
+ const command = autofix(verdict.command, profile);
31
+ const fixed = command === verdict.command ? verdict : { ...verdict, command };
32
+ const echoed = echoesRequest(command, ask) || copiesExample(command, ask);
33
+ return { verdict: fixed, issues: lint(command, profile), echoed };
34
+ }
35
+ /** Lower is better: used to keep the better of two attempts. */
36
+ function badness(c) {
37
+ if (!c.verdict.ok)
38
+ return 100;
39
+ return (c.echoed ? 10 : 0) + c.verdict.missing.length * 2 + c.issues.length;
40
+ }
41
+ function fixHint(c, profile) {
42
+ if (!c.verdict.ok)
43
+ return "";
44
+ const parts = [];
45
+ if (c.echoed)
46
+ parts.push("That repeats the request instead of giving a command. Use the program's real options (see the documentation above if given).");
47
+ if (c.verdict.missing.length) {
48
+ const alts = c.verdict.missing.map((m) => [m, alternativeFor(m, profile)]).filter(([, a]) => a);
49
+ parts.push(missingHint(c.verdict.missing, profile));
50
+ for (const [m, a] of alts)
51
+ parts.push(`Instead of ${m}, use: ${a}.`);
52
+ }
53
+ for (const i of c.issues)
54
+ parts.push(i.hint + ".");
55
+ return parts.join(" ");
56
+ }
57
+ export async function generate(ask, cfg) {
58
+ const debug = { refs: [], groundNotes: [], outputs: [], retries: [], timings: {}, messages: [] };
59
+ if (!/[a-z0-9]/i.test(ask))
60
+ return { kind: "rejected", reason: "empty", debug };
61
+ if (isInjection(ask))
62
+ return { kind: "rejected", reason: "not-a-shell-task", debug };
63
+ const profile = cfg.profile ?? detectProfile();
64
+ const t = performance.now();
65
+ debug.refs = cfg.grounding ? ground(ask, profile, 2, debug.groundNotes) : [];
66
+ debug.timings.groundMs = since(t);
67
+ let messages = buildMessages(ask, profile, debug.refs, cfg.notes);
68
+ debug.messages = messages;
69
+ const call = async () => {
70
+ const t0 = performance.now();
71
+ const out = await chat(cfg.host, { model: cfg.model, messages, temperature: cfg.temperature, keepAlive: cfg.keepAlive, timeoutMs: cfg.timeoutMs });
72
+ debug.timings[`llm${debug.outputs.length + 1}Ms`] = since(t0);
73
+ debug.outputs.push(out);
74
+ return out;
75
+ };
76
+ const retry = async (prev, hint, why) => {
77
+ debug.retries.push(why);
78
+ messages.push({ role: "assistant", content: prev }, { role: "user", content: hint });
79
+ return evaluate(await call(), ask, profile);
80
+ };
81
+ let best = evaluate(await call(), ask, profile);
82
+ // Second chance for a NONE on a request that is clearly about this computer.
83
+ if (!best.verdict.ok && best.verdict.reason === "not-a-shell-task" && looksLikeShellTask(ask)) {
84
+ // A fresh prompt without the NONE option (small models refuse too eagerly when it's offered).
85
+ // Safe because looksLikeShellTask is narrow and calculator/injection answers are filtered.
86
+ debug.retries.push("second-chance");
87
+ messages = buildMessages(ask, profile, debug.refs, cfg.notes, true);
88
+ debug.messages = messages;
89
+ const again = evaluate(await call(), ask, profile);
90
+ if (again.verdict.ok && !again.echoed)
91
+ best = again;
92
+ }
93
+ // One fix-up retry for missing tools, OS-specific mistakes or an echoed request.
94
+ if (best.verdict.ok && badness(best) > 0 && debug.outputs.length < MAX_CALLS) {
95
+ const prev = best.verdict.command;
96
+ const again = await retry(prev, fixHint(best, profile), "fix");
97
+ if (again.verdict.ok) {
98
+ if (badness(again) <= badness(best))
99
+ best = again;
100
+ }
101
+ else if (!/\s/.test(prev)) {
102
+ // A bare word like "hi" that the model then calls NONE was a chat reply, not a command.
103
+ return { kind: "rejected", reason: again.verdict.reason, debug };
104
+ }
105
+ }
106
+ const v = best.verdict;
107
+ if (!v.ok)
108
+ return { kind: "rejected", reason: v.reason, debug };
109
+ if (best.echoed)
110
+ return { kind: "rejected", reason: "chat-reply", debug };
111
+ const first = v.missing[0];
112
+ if (first && !looksLikeProgramName(first))
113
+ return { kind: "rejected", reason: "chat-reply", debug };
114
+ const warnings = [
115
+ ...v.missing.map((m) => `${m} is not installed on this system`),
116
+ ...best.issues.map((i) => i.warning),
117
+ ...v.risks,
118
+ ];
119
+ return { kind: "command", command: v.command, warnings, debug };
120
+ }