@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/ground.js ADDED
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { cacheDir } from "./config.js";
4
+ import { run, which } from "./util.js";
5
+ // Words that are ordinary English in requests (some are also program names).
6
+ const STOP = new Set(("a an the and or but if of to in on at by for with from into onto out up down over under about than then " +
7
+ "this that these those it its is are was were be been do does did done can could should would will shall " +
8
+ "i me my mine you your we our they them their he she his her what which who whom whose where when why how " +
9
+ "all any each every some many much more most less least few no not only just also very too so here there " +
10
+ "please show list display print get give find search look see check tell make create new delete remove " +
11
+ "count number size sizes big bigger biggest large larger largest small smaller latest last first lines line " +
12
+ "file files folder folders directory directories dir dirs current recursively recursive inside within " +
13
+ "named called using use used run running start stop restart open close install installed update upgrade " +
14
+ "version info details process processes port ports memory disk space usage free used total time today " +
15
+ "week day days hour hours minute minutes ago older newer old word words text string contains containing " +
16
+ "between same different every everything something nothing all one two three ten hidden human readable " +
17
+ "format convert change rename move copy kill want need like way ok thanks hi hello mb gb kb").split(" "));
18
+ // Tools every model knows well; grounding them costs prompt time for no gain.
19
+ const COMMON = new Set(("ls cd cp mv rm mkdir rmdir find grep sed awk cat head tail sort uniq wc cut tr xargs tar gzip gunzip zip " +
20
+ "unzip chmod chown ln touch echo printf ps kill pkill killall top free df du date ssh scp curl wget git " +
21
+ "ping which whoami id env export less more diff stat mount umount sudo apt apt-get yum dnf brew pacman " +
22
+ "man history clear sh bash zsh").split(" "));
23
+ // Never execute these with --help (some ignore it and act). Their man pages are still used.
24
+ const NO_EXEC = new Set(("reboot shutdown halt poweroff init telinit rm dd shred wipefs kill killall pkill fdisk parted sudo su " +
25
+ "login passwd yes cat tee sh bash zsh fish dash python python3 node perl ruby nc telnet ssh").split(" "));
26
+ export function candidates(ask) {
27
+ const toks = ask.toLowerCase().match(/[a-z0-9][a-z0-9._+-]*/g) ?? [];
28
+ const out = [];
29
+ for (let t of toks) {
30
+ t = t.replace(/\.+$/, "");
31
+ if (t.length < 2 || STOP.has(t) || COMMON.has(t) || /^\d+[a-z]{0,2}$/.test(t))
32
+ continue;
33
+ if (!out.includes(t))
34
+ out.push(t);
35
+ }
36
+ return out;
37
+ }
38
+ function stems(text) {
39
+ const s = new Set();
40
+ for (const w of text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? [])
41
+ if (!STOP.has(w))
42
+ s.add(w.slice(0, 5));
43
+ return s;
44
+ }
45
+ /** Keep the description, the usage line, and the option lines that match the request. */
46
+ export function relevantExcerpt(doc, ask, maxChars = 900) {
47
+ const lines = doc.replace(/.\x08/g, "").split("\n");
48
+ const after = (re) => {
49
+ const i = lines.findIndex((l) => re.test(l.trim()));
50
+ return i >= 0 ? (lines.slice(i + 1).find((l) => l.trim()) ?? "").trim() : "";
51
+ };
52
+ const description = after(/^NAME$/);
53
+ const usage = after(/^SYNOPSIS$/) || (lines.find((l) => /^\s*usage:/i.test(l)) ?? "").trim();
54
+ const chunks = [];
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const l = lines[i];
57
+ const opt = /^(\s*)-{1,2}[A-Za-z0-9]/.exec(l);
58
+ const sub = /^(\s{2,})[a-z][a-z0-9-]+\s{2,}\S/.exec(l); // " ps List containers"
59
+ const m = opt ?? sub;
60
+ if (!m)
61
+ continue;
62
+ const indent = m[1].length;
63
+ let text = l.trim();
64
+ for (let j = i + 1; j < lines.length && j <= i + 3; j++) {
65
+ const n = lines[j];
66
+ if (!n.trim() || /^\s*-{1,2}[A-Za-z0-9]/.test(n))
67
+ break;
68
+ if (n.length - n.trimStart().length <= indent)
69
+ break;
70
+ text += " " + n.trim();
71
+ }
72
+ chunks.push({ i, text: text.replace(/\s+/g, " ") });
73
+ }
74
+ const want = stems(ask);
75
+ const scored = chunks
76
+ .map((c) => ({ ...c, score: [...stems(c.text)].filter((s) => want.has(s)).length }))
77
+ .filter((c) => c.score > 0)
78
+ .sort((a, b) => b.score - a.score || a.i - b.i)
79
+ .slice(0, 8)
80
+ .sort((a, b) => a.i - b.i);
81
+ const picked = scored.length ? scored : chunks.slice(0, 6);
82
+ const parts = [description || (usage ? "" : (lines.find((l) => l.trim()) ?? "").trim()), usage && `Usage: ${usage.replace(/^usage:\s*/i, "")}`]
83
+ .filter(Boolean)
84
+ .concat(picked.map((c) => c.text));
85
+ let out = "";
86
+ for (const p of parts) {
87
+ if (out.length + p.length + 1 > maxChars)
88
+ break;
89
+ out += (out ? "\n" : "") + p;
90
+ }
91
+ return out;
92
+ }
93
+ function cacheFile(name, binPath) {
94
+ let key = `v2-${name}`; // bump the version to invalidate caches when doc extraction changes
95
+ try {
96
+ key += "-" + Math.floor(fs.statSync(binPath).mtimeMs);
97
+ }
98
+ catch { }
99
+ return path.join(cacheDir(), "docs", key.replace(/[^a-zA-Z0-9._+-]/g, "_") + ".txt");
100
+ }
101
+ /** Local docs for a program: man page first (no execution), else `--help`. Cached per binary version. */
102
+ export function docFor(name, binPath, notes = []) {
103
+ const file = cacheFile(name, binPath);
104
+ try {
105
+ const cached = fs.readFileSync(file, "utf8");
106
+ if (!cached) {
107
+ notes.push(`${name}: cached as "no docs" (${file})`);
108
+ return null;
109
+ }
110
+ const nl = cached.indexOf("\n");
111
+ return { source: cached.slice(0, nl), text: cached.slice(nl + 1) };
112
+ }
113
+ catch { }
114
+ let found = null;
115
+ let timedOut = false;
116
+ if (which("man")) {
117
+ const r = run("man", ["-P", "cat", name], { timeoutMs: 3000, env: { MANWIDTH: "100", LANG: "C", MAN_KEEP_FORMATTING: "" } });
118
+ // Minimized distro images print a stub instead of the page, so require real man sections.
119
+ if (r.ok && /^(NAME|SYNOPSIS|DESCRIPTION)\s*$/m.test(r.stdout.replace(/.\x08/g, "")))
120
+ found = { source: "man", text: r.stdout };
121
+ }
122
+ if (!found && !NO_EXEC.has(name) && !/^mkfs/.test(name)) {
123
+ for (const flag of ["--help", "-h"]) {
124
+ // First runs of big binaries can be slow (macOS verifies them), so allow 3 s.
125
+ const r = run(binPath, [flag], { timeoutMs: 3000, env: { LANG: "C", NO_COLOR: "1" } });
126
+ timedOut ||= r.timedOut;
127
+ if (r.timedOut)
128
+ notes.push(`${name}: ${flag} timed out after 3000 ms`);
129
+ const text = r.stdout.length > r.stderr.length ? r.stdout : r.stderr;
130
+ if (text.trim().length > 40) {
131
+ found = { source: "help", text };
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ // Never cache a timeout as "no docs": the next request should try again.
137
+ if (found || !timedOut) {
138
+ try {
139
+ fs.mkdirSync(path.dirname(file), { recursive: true });
140
+ fs.writeFileSync(file, found ? `${found.source}\n${found.text}` : "");
141
+ }
142
+ catch { }
143
+ }
144
+ return found;
145
+ }
146
+ const binsFrom = (list) => [...new Set(list.split("\n").filter((l) => /\/s?bin\/[^/]+$/.test(l.trim())).map((l) => path.basename(l.trim())))];
147
+ /** Metadata of an installed system package, or null if it isn't installed. */
148
+ export function packageInfo(name, pm) {
149
+ const t = { timeoutMs: 800 };
150
+ if (pm === "apt") {
151
+ const r = run("dpkg-query", ["-W", "-f=${Status}\t${Version}\t${binary:Summary}", name], t);
152
+ if (!r.ok || !r.stdout.includes("install ok installed"))
153
+ return null;
154
+ const [, version = "", summary = ""] = r.stdout.split("\t");
155
+ return { version, summary, bins: binsFrom(run("dpkg", ["-L", name], t).stdout) };
156
+ }
157
+ if (pm === "dnf" || pm === "yum" || pm === "zypper") {
158
+ const r = run("rpm", ["-q", "--qf", "%{VERSION}\t%{SUMMARY}", name], t);
159
+ if (!r.ok)
160
+ return null;
161
+ const [version = "", summary = ""] = r.stdout.split("\t");
162
+ return { version, summary, bins: binsFrom(run("rpm", ["-ql", name], t).stdout) };
163
+ }
164
+ if (pm === "pacman") {
165
+ const r = run("pacman", ["-Qi", name], t);
166
+ if (!r.ok)
167
+ return null;
168
+ const field = (k) => new RegExp(`^${k}\\s*:\\s*(.*)$`, "m").exec(r.stdout)?.[1]?.trim() ?? "";
169
+ return { version: field("Version"), summary: field("Description"), bins: binsFrom(run("pacman", ["-Qlq", name], t).stdout) };
170
+ }
171
+ if (pm === "apk") {
172
+ if (!run("apk", ["info", "-e", name], t).ok)
173
+ return null;
174
+ const d = run("apk", ["info", "-d", name], t).stdout.split("\n").slice(1).join(" ").trim();
175
+ return { version: "", summary: d, bins: binsFrom(run("apk", ["info", "-L", name], t).stdout) };
176
+ }
177
+ return null; // brew/nix: too slow to query on every request; installed commands are still grounded via docs
178
+ }
179
+ /** `notes` (optional) collects one line per candidate explaining what happened, for --debug. */
180
+ export function ground(ask, profile, maxRefs = 2, notes = []) {
181
+ const refs = [];
182
+ let lookups = 0;
183
+ for (const cand of candidates(ask)) {
184
+ if (refs.length >= maxRefs)
185
+ break;
186
+ const bin = which(cand);
187
+ if (bin) {
188
+ const t = performance.now();
189
+ const doc = docFor(cand, bin, notes);
190
+ if (doc)
191
+ refs.push({ name: cand, source: doc.source, text: relevantExcerpt(doc.text, ask) });
192
+ notes.push(`${cand}: ${bin} -> ${doc ? `docs from ${doc.source}` : "no docs"} (${Math.round(performance.now() - t)} ms)`);
193
+ continue;
194
+ }
195
+ if (!profile?.packageManager || lookups >= 4 || !/^[a-z][a-z0-9+-]+$/.test(cand)) {
196
+ notes.push(`${cand}: not on PATH`);
197
+ continue;
198
+ }
199
+ lookups++;
200
+ const pkg = packageInfo(cand, profile.packageManager);
201
+ if (!pkg) {
202
+ notes.push(`${cand}: not on PATH, not an installed ${profile.packageManager} package`);
203
+ continue;
204
+ }
205
+ let text = `Installed package ${cand}${pkg.version ? ` ${pkg.version}` : ""}: ${pkg.summary}`;
206
+ if (pkg.bins.length)
207
+ text += `\nProvides commands: ${pkg.bins.slice(0, 12).join(", ")}`;
208
+ const main = pkg.bins.includes(cand) ? cand : pkg.bins[0];
209
+ const mainBin = main ? which(main) : null;
210
+ if (main && mainBin) {
211
+ const doc = docFor(main, mainBin);
212
+ if (doc)
213
+ text += `\n${relevantExcerpt(doc.text, ask, 700)}`;
214
+ }
215
+ refs.push({ name: cand, source: "package", text });
216
+ }
217
+ return refs;
218
+ }
package/dist/lint.js ADDED
@@ -0,0 +1,122 @@
1
+ const BSD_RULES = [
2
+ [/\bdate\b[^|;&]*\s(-d|--date)\b/, { warning: "uses GNU date -d (macOS date uses -v)", hint: "macOS date has no -d; use -v, e.g. date -v-30d +%F" }],
3
+ [/\bstat\s+(-[a-zA-Z]+\s+)*(-c|--format)\b/, { warning: "uses GNU stat -c (macOS stat uses -f)", hint: "macOS stat has no -c; use stat -f, e.g. stat -f %z file" }],
4
+ [/\bgrep\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*P/, { warning: "uses grep -P (not on macOS)", hint: "macOS grep has no -P; use grep -E" }],
5
+ [/\bxargs\s+(-[a-zA-Z]*\s+)*-r\b/, { warning: "uses GNU xargs -r", hint: "macOS xargs has no -r; drop it" }],
6
+ [/\bfind\b[^|;&]*\s-printf\b/, { warning: "uses GNU find -printf", hint: "macOS find has no -printf; use -exec stat -f ... or -print" }],
7
+ [/\b(ls|du|df|sort)\b[^|;&]*\s--[a-z]/, { warning: "uses GNU long options", hint: "macOS ls/du/df/sort take short options only (no --long-options)" }],
8
+ ];
9
+ /** Equivalents to suggest when a tool is missing. */
10
+ const ALTERNATIVES = {
11
+ macos: {
12
+ free: "vm_stat, or: top -l 1 | grep PhysMem, or: sysctl hw.memsize",
13
+ lsblk: "diskutil list",
14
+ lscpu: "sysctl -n machdep.cpu.brand_string, sysctl hw.ncpu",
15
+ nproc: "sysctl -n hw.ncpu",
16
+ lsusb: "system_profiler SPUSBDataType",
17
+ systemctl: "brew services (list|start|stop|restart) NAME, or launchctl",
18
+ service: "brew services, or launchctl",
19
+ journalctl: "log show --last 1h",
20
+ ip: "ifconfig, or: route -n get default",
21
+ "xdg-open": "open",
22
+ xclip: "pbcopy / pbpaste",
23
+ xsel: "pbcopy / pbpaste",
24
+ pidof: "pgrep",
25
+ ss: "lsof -iTCP -sTCP:LISTEN -n -P",
26
+ netstat: "lsof -i -n -P",
27
+ sha256sum: "shasum -a 256",
28
+ md5sum: "md5",
29
+ tac: "tail -r",
30
+ apt: "brew",
31
+ "apt-get": "brew",
32
+ },
33
+ linux: {
34
+ netstat: "ss (e.g. ss -tulpn)",
35
+ ifconfig: "ip addr",
36
+ route: "ip route",
37
+ traceroute: "tracepath",
38
+ nslookup: "getent hosts, or dig",
39
+ open: "xdg-open",
40
+ pbcopy: "xclip -selection clipboard",
41
+ pbpaste: "xclip -selection clipboard -o",
42
+ sw_vers: "cat /etc/os-release",
43
+ diskutil: "lsblk",
44
+ "system_profiler": "lscpu / lsusb / lspci",
45
+ brew: "the system package manager",
46
+ },
47
+ };
48
+ export function lint(cmd, profile) {
49
+ if (profile?.userland !== "bsd")
50
+ return [];
51
+ return BSD_RULES.filter(([re]) => re.test(cmd)).map(([, issue]) => issue);
52
+ }
53
+ /** Safe, deterministic rewrites. Today: `sed -i 's/…'` → `sed -i '' 's/…'` on macOS. */
54
+ export function autofix(cmd, profile) {
55
+ if (profile?.userland !== "bsd")
56
+ return cmd;
57
+ return cmd.replace(/\bsed\s+-i\s+(?=['"]?(s|y)[^a-z]|-e\b|['"]?\d|['"]?\/)/g, "sed -i '' ");
58
+ }
59
+ export function alternativeFor(tool, profile) {
60
+ if (!profile)
61
+ return null;
62
+ return ALTERNATIVES[profile.os]?.[tool] ?? null;
63
+ }
64
+ /**
65
+ * Signals that a request is about this computer. When the model answers NONE to such a
66
+ * request, it gets a second chance; requests without these signals keep the NONE.
67
+ */
68
+ const SHELL_WORDS = new RegExp("\\b(" +
69
+ [
70
+ "files?", "folders?", "director(y|ies)", "disk space", "drives?", "partitions?", "mounted",
71
+ "pid", "ports?", "dns", "ip address", "hostname", "ping", "wifi", "network",
72
+ "cpu", "ram", "swap", "uptime", "kernel", "battery", "usb",
73
+ "permissions?", "logged in", "sudo", "user \\w+ (to|from|in) (the )?\\w+ group", "groups (am i|i'?m) in",
74
+ "password (of|for) (the )?user", "users on this", "owner of",
75
+ "git", "commits?", "branch(es)?", "repo(sitory)?", "stash", "docker", "containers?",
76
+ "cron", "daemon", "env(ironment)? var(iable)?s?",
77
+ "archive", "zip", "tar", "unzip",
78
+ "shell", "terminal", "bash", "zsh", "my (computer|machine|mac|laptop)", "this (computer|machine|mac|laptop)",
79
+ ].join("|") +
80
+ ")\\b", "i");
81
+ /** Prompt-injection phrasing: never treat as a task. */
82
+ export function isInjection(ask) {
83
+ return /\b(ignore|disregard|forget)\b.{0,40}\b(instructions?|prompt|rules|above)\b/i.test(ask);
84
+ }
85
+ /**
86
+ * Commands that only do arithmetic or transform literal text ("echo 60/1.5 | bc", "echo hello | tr …")
87
+ * are the model answering a question, unless the user asked for it in the shell.
88
+ */
89
+ export function isCalculatorAnswer(original, ask) {
90
+ let cmd = original;
91
+ if (/\b(shell|terminal|bash|zsh|command line|bc|awk|calculate in)\b/i.test(ask))
92
+ return false;
93
+ // Inline arithmetic like "$(echo 240*15/100 | bc)" or "$((2+2))" counts as literal text.
94
+ let prev = "";
95
+ while (prev !== cmd) {
96
+ prev = cmd;
97
+ cmd = cmd.replace(/\$\(\s*(echo|printf)\s+[^$()`|]*\|\s*bc(\s+-l)?\s*\)/g, "N").replace(/\$\(\([^()]*\)\)/g, "N");
98
+ }
99
+ if (/^(echo|printf)\s+[^|$`<>]*$/.test(cmd) && cmd !== original)
100
+ return true;
101
+ if (/^expr\s/.test(cmd))
102
+ return true;
103
+ // echo/printf of literal text (no variables or files) into calculators or text filters only
104
+ const m = /^(echo|printf)\s+([^|$`<>]*?)\s*((\|\s*(bc|bc -l|tr|sed|awk|rev|cut|wc|python3?|perl)\b[^|<>]*)+)$/.exec(cmd);
105
+ return Boolean(m && !/[$`]/.test(m[3]));
106
+ }
107
+ export function looksLikeShellTask(ask) {
108
+ if (isInjection(ask))
109
+ return false;
110
+ return (SHELL_WORDS.test(ask) ||
111
+ /\b[\w-]*[a-z][\w-]*\.[a-z][a-z0-9]{0,4}\b/i.test(ask) || // a filename like report.pdf (not "1.5")
112
+ /(^|\s)[~/.][\w./-]*/.test(ask) || // a path
113
+ /\b\d{1,3}(\.\d{1,3}){3}\b/.test(ask) || // an IP address
114
+ /https?:\/\//.test(ask));
115
+ }
116
+ /** Small models sometimes loop: "photos photos photos …" or "Rational::Rational::…". */
117
+ export function isDegenerate(cmd) {
118
+ const toks = cmd.split(/[^A-Za-z0-9_]+/).filter(Boolean);
119
+ if (toks.length < 16)
120
+ return false;
121
+ return new Set(toks).size / toks.length < 0.35;
122
+ }
package/dist/ollama.js ADDED
@@ -0,0 +1,92 @@
1
+ export class OllamaError extends Error {
2
+ code;
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.code = code;
6
+ }
7
+ }
8
+ async function request(host, p, init, timeoutMs) {
9
+ try {
10
+ return await fetch(host + p, {
11
+ ...init,
12
+ headers: { "content-type": "application/json" },
13
+ signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined,
14
+ });
15
+ }
16
+ catch (e) {
17
+ const name = e?.name;
18
+ if (name === "TimeoutError" || name === "AbortError")
19
+ throw new OllamaError(`Ollama did not answer within ${timeoutMs} ms (the model may still be loading from disk; try again in a moment)`, "timeout");
20
+ throw new OllamaError(`Cannot reach Ollama at ${host}`, "unreachable");
21
+ }
22
+ }
23
+ export async function getVersion(host, timeoutMs = 1500) {
24
+ const r = await request(host, "/api/version", {}, timeoutMs);
25
+ if (!r.ok)
26
+ throw new OllamaError(`Ollama answered HTTP ${r.status}`, "http");
27
+ return (await r.json()).version;
28
+ }
29
+ export async function isUp(host) {
30
+ try {
31
+ await getVersion(host);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export async function listModels(host) {
39
+ const r = await request(host, "/api/tags", {}, 5000);
40
+ if (!r.ok)
41
+ throw new OllamaError(`Ollama answered HTTP ${r.status}`, "http");
42
+ const j = (await r.json());
43
+ return (j.models ?? []).map((m) => ({ name: m.name, size: m.size }));
44
+ }
45
+ const norm = (m) => (m.includes(":") ? m : `${m}:latest`);
46
+ export const sameModel = (a, b) => norm(a) === norm(b);
47
+ export const hasModel = (models, name) => models.some((m) => sameModel(m.name, name));
48
+ function keepAliveValue(v) {
49
+ return /^-?\d+$/.test(v) ? Number(v) : v;
50
+ }
51
+ export async function chat(host, o) {
52
+ const r = await request(host, "/api/chat", {
53
+ method: "POST",
54
+ body: JSON.stringify({
55
+ model: o.model,
56
+ messages: o.messages,
57
+ stream: false,
58
+ keep_alive: keepAliveValue(o.keepAlive),
59
+ options: { temperature: o.temperature, num_predict: 96, stop: ["\n\n", "\n```\n"] },
60
+ }),
61
+ }, o.timeoutMs);
62
+ if (!r.ok) {
63
+ const body = await r.text();
64
+ if (r.status === 404 && /not found/i.test(body))
65
+ throw new OllamaError(`Model "${o.model}" is not downloaded`, "model_missing");
66
+ throw new OllamaError(`Ollama error ${r.status}: ${body.slice(0, 200)}`, "http");
67
+ }
68
+ const j = (await r.json());
69
+ return j.message?.content ?? "";
70
+ }
71
+ /** Download a model, reporting progress. */
72
+ export async function pull(host, model, onProgress) {
73
+ const r = await request(host, "/api/pull", { method: "POST", body: JSON.stringify({ model, stream: true }) });
74
+ if (!r.ok || !r.body)
75
+ throw new OllamaError(`Pull failed: HTTP ${r.status} ${await r.text()}`, "http");
76
+ const dec = new TextDecoder();
77
+ let buf = "";
78
+ for await (const chunk of r.body) {
79
+ buf += dec.decode(chunk, { stream: true });
80
+ let i;
81
+ while ((i = buf.indexOf("\n")) >= 0) {
82
+ const line = buf.slice(0, i).trim();
83
+ buf = buf.slice(i + 1);
84
+ if (!line)
85
+ continue;
86
+ const j = JSON.parse(line);
87
+ if (j.error)
88
+ throw new OllamaError(j.error, "http");
89
+ onProgress(j.status ?? "", j.completed, j.total);
90
+ }
91
+ }
92
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,67 @@
1
+ import { profilePrompt } from "./env.js";
2
+ const RULES = `You convert a request into ONE shell command for the user's system. Output only the command, no explanation, no backticks.
3
+ If the request is not a task that can be done with a shell command on this computer, output exactly: NONE`;
4
+ /** Few-shot turns. Includes NONE examples so the model learns to refuse non-shell requests. */
5
+ export const SHOTS = [
6
+ ["list all files including hidden ones", "ls -la"],
7
+ ["write a haiku about cats", "NONE"],
8
+ ["find python files modified in the last day", "find . -name '*.py' -mtime -1"],
9
+ ["what is the capital of Japan", "NONE"],
10
+ ["show disk usage of current folder in human readable form", "du -sh ."],
11
+ ];
12
+ /**
13
+ * Extra examples for the second-chance prompt, which has no NONE option. It only runs when
14
+ * looksLikeShellTask() says the request is about this computer; arithmetic/text answers and
15
+ * prompt injections are filtered afterwards, so non-shell questions still produce nothing.
16
+ */
17
+ const SECOND_SHOTS = [
18
+ ["how long has this machine been up", "uptime"],
19
+ ["show who changed each line of main.c", "git blame main.c"],
20
+ ["what are the name servers for example.org", "dig NS example.org"],
21
+ ];
22
+ const COMMON_WORDS = new Set("the and for with all show list this that from into what are how has who each your".split(" "));
23
+ const contentWords = (s) => new Set((s.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []).filter((w) => !COMMON_WORDS.has(w)));
24
+ /**
25
+ * Small models sometimes answer with an example's command verbatim ("ls -la" for anything).
26
+ * That's a copy, not an answer, when the request shares no content word with that example.
27
+ */
28
+ export function copiesExample(command, ask) {
29
+ const askWords = contentWords(ask);
30
+ return SHOTS.some(([q, a]) => a !== "NONE" && a === command && ![...contentWords(q)].some((w) => askWords.has(w)));
31
+ }
32
+ export function systemPrompt(profile, notes) {
33
+ const parts = [RULES];
34
+ if (profile)
35
+ parts.push(profilePrompt(profile));
36
+ if (notes.trim())
37
+ parts.push(`User preferences: ${notes.trim()}`);
38
+ return parts.join("\n\n");
39
+ }
40
+ /**
41
+ * System prompt + shots stay identical between requests so Ollama can reuse its prompt cache;
42
+ * per-request references go into the final user message only.
43
+ */
44
+ export function buildMessages(ask, profile, refs, notes, secondChance = false) {
45
+ const system = systemPrompt(profile, notes);
46
+ const messages = [
47
+ { role: "system", content: secondChance ? system.replace(/\nIf the request is not a task[^\n]*NONE/, "") : system },
48
+ ];
49
+ const shots = secondChance ? [...SHOTS.filter(([, a]) => a !== "NONE"), ...SECOND_SHOTS] : SHOTS;
50
+ for (const [u, a] of shots)
51
+ messages.push({ role: "user", content: u }, { role: "assistant", content: a });
52
+ let content = ask.trim();
53
+ if (refs.length) {
54
+ const docs = refs.map((r) => `[${r.name} — from this computer's ${r.source === "package" ? "package database" : r.source === "man" ? "man page" : "--help"}]\n${r.text}`);
55
+ content = `Local documentation:\n${docs.join("\n\n")}\n\nRequest: ${content}`;
56
+ }
57
+ messages.push({ role: "user", content });
58
+ return messages;
59
+ }
60
+ export function missingHint(missing, profile) {
61
+ const list = missing.join(", ");
62
+ const pm = profile?.packageManager;
63
+ return (`${list} ${missing.length > 1 ? "are" : "is"} not installed on this computer. ` +
64
+ `Answer again with a command that uses installed tools only` +
65
+ (pm ? `, or if the task truly needs it, the ${pm} command that installs it.` : ".") +
66
+ ` If the request is not a shell task, output exactly: NONE`);
67
+ }
package/dist/shell.js ADDED
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { configDir } from "./config.js";
5
+ import { run } from "./util.js";
6
+ export const isHookShell = (s) => s === "bash" || s === "zsh";
7
+ const START = "# >>> whatcmd >>>";
8
+ const END = "# <<< whatcmd <<<";
9
+ /**
10
+ * Bash: Enter is rebound to run our hook first. If the line starts with the prefix, the hook
11
+ * replaces it with the generated command and swallows that Enter, so nothing runs until
12
+ * the user presses Enter again. The raw line never reaches the shell parser, so
13
+ * apostrophes, ">" and "*" in the request are safe.
14
+ */
15
+ export function bashHook(prefix) {
16
+ return `# whatcmd shell hook for bash — generated by \`whatcmd shell bash\`
17
+ [[ $- == *i* ]] || return 0
18
+ __whatcmd_prefix='${prefix}'
19
+ __whatcmd_hook() {
20
+ if [[ $READLINE_LINE == "$__whatcmd_prefix "* ]]; then
21
+ local ask=\${READLINE_LINE#"$__whatcmd_prefix "} out
22
+ if ! command -v whatcmd >/dev/null 2>&1; then
23
+ printf 'whatcmd: not found on PATH\\n' >&2
24
+ else
25
+ out=$(command whatcmd --raw -- "$ask")
26
+ if [[ -n $out ]]; then
27
+ READLINE_LINE=$out
28
+ READLINE_POINT=\${#READLINE_LINE}
29
+ fi
30
+ fi
31
+ bind '"\\C-x\\C-j": ""'
32
+ else
33
+ bind '"\\C-x\\C-j": accept-line'
34
+ fi
35
+ }
36
+ for __whatcmd_map in emacs vi-insert; do
37
+ bind -m "$__whatcmd_map" -x '"\\C-x\\C-k": __whatcmd_hook'
38
+ bind -m "$__whatcmd_map" '"\\C-m": "\\C-x\\C-k\\C-x\\C-j"'
39
+ done
40
+ unset __whatcmd_map
41
+ `;
42
+ }
43
+ /** Zsh: wrap the accept-line widget. Messages are shown with `zle -M` under the prompt. */
44
+ export function zshHook(prefix) {
45
+ return `# whatcmd shell hook for zsh — generated by \`whatcmd shell zsh\`
46
+ [[ -o interactive ]] || return 0
47
+ typeset -g __whatcmd_prefix='${prefix}'
48
+ __whatcmd_accept_line() {
49
+ if [[ $BUFFER == "$__whatcmd_prefix "* ]]; then
50
+ local ask=\${BUFFER#"$__whatcmd_prefix "} out msg
51
+ local errf="\${TMPDIR:-/tmp}/w.$$.err"
52
+ if (( ! $+commands[whatcmd] )); then
53
+ zle -M "whatcmd: not found on PATH"
54
+ return 0
55
+ fi
56
+ out=$(command whatcmd --raw -- "$ask" 2>"$errf")
57
+ msg=$(<"$errf"); rm -f "$errf"
58
+ if [[ -n $out ]]; then
59
+ BUFFER=$out
60
+ CURSOR=\${#BUFFER}
61
+ fi
62
+ zle redisplay
63
+ [[ -n $msg ]] && zle -M "$msg"
64
+ return 0
65
+ fi
66
+ zle .accept-line
67
+ }
68
+ zle -N accept-line __whatcmd_accept_line
69
+ `;
70
+ }
71
+ export function hookScript(shell, prefix) {
72
+ return shell === "bash" ? bashHook(prefix) : zshHook(prefix);
73
+ }
74
+ export function hookFile(shell) {
75
+ return path.join(configDir(), `hook.${shell}`);
76
+ }
77
+ export function rcFile(shell, osName) {
78
+ const home = os.homedir();
79
+ if (shell === "zsh")
80
+ return path.join(process.env.ZDOTDIR || home, ".zshrc");
81
+ // macOS Terminal opens login shells, which read .bash_profile rather than .bashrc
82
+ return path.join(home, osName === "macos" ? ".bash_profile" : ".bashrc");
83
+ }
84
+ export function isHookInstalled(shell, osName) {
85
+ try {
86
+ return fs.readFileSync(rcFile(shell, osName), "utf8").includes(START);
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /** (Re)write the hook file for each shell that has it installed. Used when the prefix changes. */
93
+ export function refreshHookFiles(prefix) {
94
+ for (const shell of ["bash", "zsh"]) {
95
+ if (fs.existsSync(hookFile(shell)))
96
+ fs.writeFileSync(hookFile(shell), hookScript(shell, prefix));
97
+ }
98
+ }
99
+ export function installHook(shell, prefix, osName) {
100
+ const hook = hookFile(shell);
101
+ fs.mkdirSync(path.dirname(hook), { recursive: true });
102
+ fs.writeFileSync(hook, hookScript(shell, prefix));
103
+ const rc = rcFile(shell, osName);
104
+ if (isHookInstalled(shell, osName))
105
+ return { rc, hook, added: false };
106
+ const home = os.homedir();
107
+ const shown = hook.startsWith(home + path.sep) ? `$HOME${hook.slice(home.length)}` : hook;
108
+ const block = `\n${START}\n[ -f "${shown}" ] && . "${shown}"\n${END}\n`;
109
+ fs.appendFileSync(rc, block);
110
+ return { rc, hook, added: true };
111
+ }
112
+ export function uninstallHook(shell, osName) {
113
+ const rc = rcFile(shell, osName);
114
+ let text;
115
+ try {
116
+ text = fs.readFileSync(rc, "utf8");
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ const re = new RegExp(`\\n?${START}[\\s\\S]*?${END}\\n?`, "g");
122
+ if (!re.test(text))
123
+ return false;
124
+ fs.writeFileSync(rc, text.replace(re, "\n"));
125
+ try {
126
+ fs.unlinkSync(hookFile(shell));
127
+ }
128
+ catch { }
129
+ return true;
130
+ }
131
+ /** The bash shortcut needs bash 4+ (READLINE_LINE). macOS ships bash 3.2. */
132
+ export function bashTooOld(bashPath = process.env.SHELL && /bash$/.test(process.env.SHELL) ? process.env.SHELL : "bash") {
133
+ const r = run(bashPath, ["-c", "echo ${BASH_VERSINFO[0]}"], { timeoutMs: 2000 });
134
+ const major = Number(r.stdout.trim());
135
+ return r.ok && Number.isFinite(major) && major < 4;
136
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,39 @@
1
+ import readline from "node:readline/promises";
2
+ const colorOn = (s) => Boolean(s.isTTY) && !process.env.NO_COLOR;
3
+ function paint(code, text, stream = process.stderr) {
4
+ return colorOn(stream) ? `\x1b[${code}m${text}\x1b[0m` : text;
5
+ }
6
+ export const bold = (t, s) => paint("1", t, s);
7
+ export const dim = (t, s) => paint("2", t, s);
8
+ export const red = (t, s) => paint("31", t, s);
9
+ export const green = (t, s) => paint("32", t, s);
10
+ export const yellow = (t, s) => paint("33", t, s);
11
+ export const cyan = (t, s) => paint("36", t, s);
12
+ export const err = (line) => process.stderr.write(line + "\n");
13
+ export const ok = (line) => err(`${green("✔")} ${line}`);
14
+ export const warn = (line) => err(`${yellow("!")} ${line}`);
15
+ export const fail = (line) => err(`${red("✘")} ${line}`);
16
+ export const info = (line) => err(` ${dim(line)}`);
17
+ export const canPrompt = () => Boolean(process.stdin.isTTY && process.stderr.isTTY);
18
+ export async function ask(question) {
19
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
20
+ try {
21
+ return (await rl.question(question)).trim();
22
+ }
23
+ finally {
24
+ rl.close();
25
+ }
26
+ }
27
+ export async function confirm(question, def) {
28
+ const a = (await ask(`${question} ${def ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
29
+ if (!a)
30
+ return def;
31
+ return a === "y" || a === "yes";
32
+ }
33
+ export function formatBytes(n) {
34
+ if (n >= 1e9)
35
+ return `${(n / 1e9).toFixed(1)} GB`;
36
+ if (n >= 1e6)
37
+ return `${Math.round(n / 1e6)} MB`;
38
+ return `${Math.round(n / 1e3)} KB`;
39
+ }