cli-guru 0.2.0__py3-none-any.whl
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.
- cli_guru/__init__.py +3 -0
- cli_guru/backend.py +127 -0
- cli_guru/cli.py +288 -0
- cli_guru/config.py +152 -0
- cli_guru/context.py +244 -0
- cli_guru/danger.py +174 -0
- cli_guru/install.py +115 -0
- cli_guru/manpage.py +181 -0
- cli_guru/prompts.py +63 -0
- cli_guru/sanitise.py +132 -0
- cli_guru/shell/cli-guru.bash +78 -0
- cli_guru/shell/cli-guru.ps1 +32 -0
- cli_guru/shell/cli-guru.zsh +67 -0
- cli_guru-0.2.0.dist-info/METADATA +716 -0
- cli_guru-0.2.0.dist-info/RECORD +19 -0
- cli_guru-0.2.0.dist-info/WHEEL +5 -0
- cli_guru-0.2.0.dist-info/entry_points.txt +2 -0
- cli_guru-0.2.0.dist-info/licenses/LICENSE +21 -0
- cli_guru-0.2.0.dist-info/top_level.txt +1 -0
cli_guru/__init__.py
ADDED
cli_guru/backend.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Model I/O. The ONLY module that talks to a model.
|
|
2
|
+
|
|
3
|
+
Everything else (context, man parsing, sanitising, shell adapters) must not
|
|
4
|
+
import this or know it exists — see CLAUDE.md "The seam that keeps this
|
|
5
|
+
reversible". Swapping in another provider means writing one more class here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackendError(Exception):
|
|
17
|
+
"""User-facing failure. The message is printed verbatim to stderr."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# A real reply is a few KB: ask is capped at 160 predicted tokens, explain at
|
|
21
|
+
# 700. This bound exists for the case where the host is not what we think it is
|
|
22
|
+
# — `$OLLAMA_HOST` may point across a LAN, over plain HTTP — so a hostile or
|
|
23
|
+
# broken endpoint cannot stream until the process runs out of memory.
|
|
24
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_capped(resp) -> bytes:
|
|
28
|
+
data = resp.read(MAX_RESPONSE_BYTES + 1)
|
|
29
|
+
if len(data) > MAX_RESPONSE_BYTES:
|
|
30
|
+
raise BackendError(
|
|
31
|
+
f"ollama sent more than {MAX_RESPONSE_BYTES // (1024 * 1024)}MB — refusing it"
|
|
32
|
+
)
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OllamaBackend:
|
|
37
|
+
def __init__(
|
|
38
|
+
self, host: str, model: str, timeout: int = 20, keep_alive: str = "8h"
|
|
39
|
+
) -> None:
|
|
40
|
+
self.host = host.rstrip("/")
|
|
41
|
+
self.model = model
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.keep_alive = keep_alive
|
|
44
|
+
|
|
45
|
+
def chat(
|
|
46
|
+
self, system: str, user: str, *, think: bool = False, num_predict: int = 160
|
|
47
|
+
) -> Tuple[str, Optional[str]]:
|
|
48
|
+
"""Return (content, thinking). `thinking` is for --debug only, never output.
|
|
49
|
+
|
|
50
|
+
`num_predict` is deliberately tight for ask: this model can degenerate
|
|
51
|
+
into a repetition loop (observed: `--quiet --quiet --quiet ...` for 20s
|
|
52
|
+
until the token cap). A low cap bounds the damage; repeat_penalty makes
|
|
53
|
+
it rarer.
|
|
54
|
+
"""
|
|
55
|
+
payload = {
|
|
56
|
+
"model": self.model,
|
|
57
|
+
"stream": False,
|
|
58
|
+
"think": think,
|
|
59
|
+
"messages": [
|
|
60
|
+
{"role": "system", "content": system},
|
|
61
|
+
{"role": "user", "content": user},
|
|
62
|
+
],
|
|
63
|
+
# Keeps the model resident so the next keypress is warm.
|
|
64
|
+
"keep_alive": self.keep_alive,
|
|
65
|
+
"options": {
|
|
66
|
+
"temperature": 0.1,
|
|
67
|
+
"num_predict": num_predict,
|
|
68
|
+
"repeat_penalty": 1.15,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
req = urllib.request.Request(
|
|
72
|
+
f"{self.host}/api/chat",
|
|
73
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
74
|
+
headers={"Content-Type": "application/json"},
|
|
75
|
+
method="POST",
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
79
|
+
body = json.loads(_read_capped(resp).decode("utf-8"))
|
|
80
|
+
except urllib.error.HTTPError as exc:
|
|
81
|
+
detail = ""
|
|
82
|
+
try:
|
|
83
|
+
detail = json.loads(_read_capped(exc).decode("utf-8")).get("error", "")
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
if exc.code == 404 or "not found" in detail.lower():
|
|
87
|
+
raise BackendError(
|
|
88
|
+
f"model {self.model!r} not found (pull it with: ollama pull {self.model})"
|
|
89
|
+
) from exc
|
|
90
|
+
raise BackendError(f"ollama returned HTTP {exc.code}: {detail or exc.reason}") from exc
|
|
91
|
+
except urllib.error.URLError as exc:
|
|
92
|
+
reason = getattr(exc, "reason", exc)
|
|
93
|
+
if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
|
|
94
|
+
raise BackendError(
|
|
95
|
+
f"timed out after {self.timeout}s — try a smaller model or raise timeout"
|
|
96
|
+
) from exc
|
|
97
|
+
raise BackendError(
|
|
98
|
+
f"no ollama at {self.host} (start it with: ollama serve, "
|
|
99
|
+
f"or set OLLAMA_HOST)"
|
|
100
|
+
) from exc
|
|
101
|
+
except TimeoutError as exc:
|
|
102
|
+
raise BackendError(f"timed out after {self.timeout}s — try a smaller model") from exc
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise BackendError("ollama returned a malformed response") from exc
|
|
105
|
+
|
|
106
|
+
msg = body.get("message") or {}
|
|
107
|
+
# Thinking models return `thinking` alongside `content`. Read content ONLY:
|
|
108
|
+
# concatenating them would paste the model's reasoning into a live prompt.
|
|
109
|
+
return msg.get("content") or "", msg.get("thinking")
|
|
110
|
+
|
|
111
|
+
def check(self) -> str:
|
|
112
|
+
"""Verify reachability and that the model is pulled. Returns a status line."""
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(f"{self.host}/api/tags", timeout=self.timeout) as resp:
|
|
115
|
+
tags = json.loads(_read_capped(resp).decode("utf-8"))
|
|
116
|
+
except urllib.error.URLError as exc:
|
|
117
|
+
raise BackendError(
|
|
118
|
+
f"no ollama at {self.host} (start it with: ollama serve, or set OLLAMA_HOST)"
|
|
119
|
+
) from exc
|
|
120
|
+
names = [m.get("name", "") for m in tags.get("models", [])]
|
|
121
|
+
if self.model not in names:
|
|
122
|
+
available = ", ".join(names) or "none"
|
|
123
|
+
raise BackendError(
|
|
124
|
+
f"model {self.model!r} not pulled (available: {available}; "
|
|
125
|
+
f"get it with: ollama pull {self.model})"
|
|
126
|
+
)
|
|
127
|
+
return f"ok: {self.host} reachable, model {self.model} present"
|
cli_guru/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
`ask` stdout is pasted straight into a live shell prompt, so it prints the
|
|
4
|
+
command and NOTHING else. Every diagnostic goes to stderr.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from . import __version__, config, context, danger, install, manpage, prompts, sanitise
|
|
15
|
+
from .backend import BackendError, OllamaBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _err(msg: str) -> None:
|
|
19
|
+
print(f"cli-guru: {msg}", file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _backend(cfg, timeout_key: str = "timeout") -> OllamaBackend:
|
|
23
|
+
timeout = int(cfg.get(timeout_key, cfg["timeout"]))
|
|
24
|
+
return OllamaBackend(
|
|
25
|
+
cfg["host"], cfg["model"], timeout, str(cfg.get("keep_alive", "8h"))
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _joined(parts: List[str]) -> str:
|
|
30
|
+
"""Join the trailing words into one line.
|
|
31
|
+
|
|
32
|
+
argparse.REMAINDER is used for these positionals so that `cli-guru explain ls -la`
|
|
33
|
+
works: with nargs="*", argparse claims `-la` as an unknown option and exits 2.
|
|
34
|
+
A leading `--` (which the shell widget always passes) is dropped here.
|
|
35
|
+
"""
|
|
36
|
+
parts = list(parts)
|
|
37
|
+
if parts and parts[0] == "--":
|
|
38
|
+
parts = parts[1:]
|
|
39
|
+
return " ".join(parts).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_question() -> Optional[str]:
|
|
43
|
+
"""Ask for a question interactively, NEVER writing the prompt to stdout.
|
|
44
|
+
|
|
45
|
+
stdout is the readline buffer: the shell widget runs this as `out=$(...)`,
|
|
46
|
+
so a prompt printed there is swallowed by the capture and the terminal
|
|
47
|
+
appears to hang, blocked on input the user cannot see they owe. stderr is no
|
|
48
|
+
better — the adapter redirects it to a temp file and only prints it after
|
|
49
|
+
the command exits. The controlling terminal is the only channel the user is
|
|
50
|
+
actually looking at, so ask there.
|
|
51
|
+
|
|
52
|
+
Returns None when there is no terminal to ask on, so the caller can say so
|
|
53
|
+
instead of blocking forever.
|
|
54
|
+
"""
|
|
55
|
+
if sys.stdout.isatty():
|
|
56
|
+
try:
|
|
57
|
+
return input("cli-guru> ").strip()
|
|
58
|
+
except (EOFError, KeyboardInterrupt):
|
|
59
|
+
return ""
|
|
60
|
+
try:
|
|
61
|
+
# Two handles, not one "r+": /dev/tty is a character device and is not
|
|
62
|
+
# seekable, so r+ raises io.UnsupportedOperation (an OSError subclass,
|
|
63
|
+
# so it would be swallowed below and look like "no terminal").
|
|
64
|
+
# No /dev/tty on Windows, and none in a container or some ssh/tmux
|
|
65
|
+
# sessions; OSError there means "cannot ask", not "user said nothing".
|
|
66
|
+
with open("/dev/tty", "w") as tty_out, open("/dev/tty", "r") as tty_in:
|
|
67
|
+
tty_out.write("cli-guru> ")
|
|
68
|
+
tty_out.flush()
|
|
69
|
+
return (tty_in.readline() or "").strip()
|
|
70
|
+
except (OSError, KeyboardInterrupt):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_ask(args, cfg) -> int:
|
|
75
|
+
question = _joined(args.text)
|
|
76
|
+
if not question:
|
|
77
|
+
typed = _read_question()
|
|
78
|
+
if typed is None:
|
|
79
|
+
_err("nothing to ask — type your question on the line first, then press the key")
|
|
80
|
+
return 1
|
|
81
|
+
question = typed
|
|
82
|
+
if not question:
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
ctx = context.collect(cfg)
|
|
86
|
+
user = prompts.ask_user(question, context.render(ctx, bool(cfg.get("include_tools"))))
|
|
87
|
+
if args.debug:
|
|
88
|
+
print(f"--- system ---\n{prompts.ASK_SYSTEM}\n--- user ---\n{user}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
raw, thinking = _backend(cfg).chat(
|
|
92
|
+
prompts.ASK_SYSTEM, user, think=bool(cfg["think"]), num_predict=160
|
|
93
|
+
)
|
|
94
|
+
except BackendError as exc:
|
|
95
|
+
_err(str(exc))
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
if args.debug and thinking:
|
|
99
|
+
print(f"--- thinking ---\n{thinking}", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
command = sanitise.command(raw)
|
|
102
|
+
if not command:
|
|
103
|
+
_err("model returned no usable command")
|
|
104
|
+
return 1
|
|
105
|
+
# The command goes to stdout (the readline buffer); the warning goes to
|
|
106
|
+
# stderr, which the shell widget prints above the prompt. Deterministic, so
|
|
107
|
+
# it does not depend on the model having noticed.
|
|
108
|
+
warning = danger.banner(command)
|
|
109
|
+
if warning:
|
|
110
|
+
print(warning, file=sys.stderr)
|
|
111
|
+
print(command)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_explain(args, cfg) -> int:
|
|
116
|
+
line = _joined(args.text)
|
|
117
|
+
if not line:
|
|
118
|
+
_err("nothing to explain")
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
cmd, sub = manpage.base_command(line)
|
|
122
|
+
# Opt-in only: fetching docs must not execute the command under explanation.
|
|
123
|
+
run_help = bool(getattr(args, "run_help", False)) or bool(cfg.get("explain_run_help"))
|
|
124
|
+
doc, source = manpage.fetch(cmd, sub, run_help=run_help)
|
|
125
|
+
if doc:
|
|
126
|
+
doc = manpage.truncate(doc, int(cfg["max_man_chars"]))
|
|
127
|
+
|
|
128
|
+
user = prompts.explain_user(line, doc, source)
|
|
129
|
+
if args.debug:
|
|
130
|
+
print(f"--- source: {source} ({len(doc or '')} chars) ---", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
raw, thinking = _backend(cfg, "timeout_explain").chat(
|
|
134
|
+
prompts.EXPLAIN_SYSTEM, user, think=bool(cfg["think_explain"]), num_predict=700
|
|
135
|
+
)
|
|
136
|
+
except BackendError as exc:
|
|
137
|
+
_err(str(exc))
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
if args.debug and thinking:
|
|
141
|
+
print(f"--- thinking ---\n{thinking}", file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
text = sanitise.prose(raw)
|
|
144
|
+
if not text:
|
|
145
|
+
_err("model returned no explanation")
|
|
146
|
+
return 1
|
|
147
|
+
# cli-guru owns the warning, not the model: qwen2.5-coder:3b missed 12 of 15
|
|
148
|
+
# destructive commands when this was left to the prompt.
|
|
149
|
+
warning = danger.banner(line)
|
|
150
|
+
if warning:
|
|
151
|
+
print(warning)
|
|
152
|
+
# Drop a duplicate warning line the model may have produced anyway.
|
|
153
|
+
stripped = [ln for ln in text.splitlines() if not ln.upper().lstrip().startswith("WARNING")]
|
|
154
|
+
text = "\n".join(stripped).strip()
|
|
155
|
+
print(text)
|
|
156
|
+
if source == "none":
|
|
157
|
+
_err("no local man page found — answer is from general knowledge")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_check(args, cfg) -> int:
|
|
162
|
+
try:
|
|
163
|
+
print(_backend(cfg).check())
|
|
164
|
+
except BackendError as exc:
|
|
165
|
+
_err(str(exc))
|
|
166
|
+
return 1
|
|
167
|
+
ctx = context.collect(cfg)
|
|
168
|
+
print(f"shell: {ctx['shell']} userland: {ctx['userland']} os: {ctx['os']}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _script_for(shell: str) -> Path:
|
|
173
|
+
"""Adapters live inside the package, so an installed wheel finds them too."""
|
|
174
|
+
name, _ = install.SHELL_FILES[shell]
|
|
175
|
+
return Path(__file__).resolve().parent / "shell" / name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_install(args, cfg) -> int:
|
|
179
|
+
shell = args.shell or install.detect_shell()
|
|
180
|
+
if shell not in install.SHELL_FILES:
|
|
181
|
+
_err(f"unsupported shell {shell!r} (expected bash, zsh or powershell)")
|
|
182
|
+
return 1
|
|
183
|
+
script = _script_for(shell)
|
|
184
|
+
if not script.exists():
|
|
185
|
+
_err(f"shell integration file not found: {script}")
|
|
186
|
+
return 1
|
|
187
|
+
try:
|
|
188
|
+
rc, current, new = install.plan(shell, script)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
_err(str(exc))
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
if current == new:
|
|
194
|
+
print(f"already installed in {rc} (nothing to do)")
|
|
195
|
+
return 0
|
|
196
|
+
if args.dry_run:
|
|
197
|
+
print(install.diff(rc, current, new))
|
|
198
|
+
print("\n(dry run — nothing written)")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
bak = install.write(rc, current, new)
|
|
202
|
+
print(f"installed cli-guru ({shell}) in {rc}")
|
|
203
|
+
if bak:
|
|
204
|
+
print(f"backup: {bak}")
|
|
205
|
+
print("keys: Ctrl-X Ctrl-A = ask, Ctrl-X Ctrl-H = explain")
|
|
206
|
+
print(f"start a new shell, or run: . {rc}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_uninstall(args, cfg) -> int:
|
|
211
|
+
shell = args.shell or install.detect_shell()
|
|
212
|
+
if shell not in install.SHELL_FILES:
|
|
213
|
+
_err(f"unsupported shell {shell!r}")
|
|
214
|
+
return 1
|
|
215
|
+
try:
|
|
216
|
+
rc, current, new = install.uninstall_plan(shell)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
_err(str(exc))
|
|
219
|
+
return 1
|
|
220
|
+
if current == new:
|
|
221
|
+
print(f"cli-guru is not installed in {rc}")
|
|
222
|
+
return 0
|
|
223
|
+
if args.dry_run:
|
|
224
|
+
print(install.diff(rc, current, new))
|
|
225
|
+
print("\n(dry run — nothing written)")
|
|
226
|
+
return 0
|
|
227
|
+
install.write(rc, current, new, backup=False)
|
|
228
|
+
print(f"removed cli-guru block from {rc}")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
233
|
+
parser = argparse.ArgumentParser(
|
|
234
|
+
prog="cli-guru",
|
|
235
|
+
description="Plain-language shell commands from a local ollama model.",
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument("--version", action="version", version=f"cli-guru {__version__}")
|
|
238
|
+
parser.add_argument("--debug", action="store_true",
|
|
239
|
+
help="dump prompt and model reasoning to stderr")
|
|
240
|
+
parser.add_argument("--model", help="override the model")
|
|
241
|
+
parser.add_argument("--host", help="override the ollama host")
|
|
242
|
+
|
|
243
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
244
|
+
|
|
245
|
+
p_ask = sub.add_parser("ask", help="plain language -> one shell command")
|
|
246
|
+
p_ask.add_argument("text", nargs=argparse.REMAINDER)
|
|
247
|
+
p_ask.set_defaults(func=cmd_ask)
|
|
248
|
+
|
|
249
|
+
p_exp = sub.add_parser("explain", help="explain a command using its man page")
|
|
250
|
+
p_exp.add_argument("--run-help", action="store_true",
|
|
251
|
+
help="if no man page exists, RUN `<cmd> --help` to get its docs")
|
|
252
|
+
p_exp.add_argument("text", nargs=argparse.REMAINDER)
|
|
253
|
+
p_exp.set_defaults(func=cmd_explain)
|
|
254
|
+
|
|
255
|
+
p_chk = sub.add_parser("check", help="verify ollama is reachable and the model is pulled")
|
|
256
|
+
p_chk.set_defaults(func=cmd_check)
|
|
257
|
+
|
|
258
|
+
p_ins = sub.add_parser("install", help="add the cli-guru block to your shell rc file")
|
|
259
|
+
p_ins.add_argument("--shell", choices=sorted(install.SHELL_FILES))
|
|
260
|
+
p_ins.add_argument("--dry-run", action="store_true", help="print the diff, write nothing")
|
|
261
|
+
p_ins.set_defaults(func=cmd_install)
|
|
262
|
+
|
|
263
|
+
p_uni = sub.add_parser("uninstall", help="remove the cli-guru block from your shell rc file")
|
|
264
|
+
p_uni.add_argument("--shell", choices=sorted(install.SHELL_FILES))
|
|
265
|
+
p_uni.add_argument("--dry-run", action="store_true")
|
|
266
|
+
p_uni.set_defaults(func=cmd_uninstall)
|
|
267
|
+
|
|
268
|
+
return parser
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
272
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
273
|
+
# `cli-guru --check` is documented alongside the `check` subcommand.
|
|
274
|
+
argv = ["check" if a == "--check" else a for a in argv]
|
|
275
|
+
|
|
276
|
+
parser = build_parser()
|
|
277
|
+
args = parser.parse_args(argv)
|
|
278
|
+
cfg = config.load({"model": args.model, "host": args.host})
|
|
279
|
+
try:
|
|
280
|
+
return int(args.func(args, cfg))
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
return 130
|
|
283
|
+
except BrokenPipeError:
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
cli_guru/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Configuration: defaults, file, environment. Stdlib only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
DEFAULTS: Dict[str, Any] = {
|
|
12
|
+
# Measured on a CPU-only host, each model alone (bench/eval_ask.py and
|
|
13
|
+
# bench/eval_hard.py; 5+ repeats per case, hard set run twice):
|
|
14
|
+
# model easy hard median resident
|
|
15
|
+
# qwen2.5-coder:1.5b 92% 64% 202ms 2.12G
|
|
16
|
+
# qwen2.5-coder:3b 83% 46% 489ms 3.37G
|
|
17
|
+
# qwen2.5-coder:7b 92% 79% 702ms 6.92G
|
|
18
|
+
# nemotron-3-nano:4b 80% - 911ms 3.78G
|
|
19
|
+
# 1.5b beats 3b on BOTH sets while being smaller and 2x faster, so 3b has
|
|
20
|
+
# no niche. Default to 1.5b; 7b is the accuracy option for harder requests.
|
|
21
|
+
"model": "qwen2.5-coder:1.5b",
|
|
22
|
+
"host": "http://localhost:11434",
|
|
23
|
+
"think": False, # ask: measured 7x slower, no accuracy gain
|
|
24
|
+
# explain: thinking generated 383 extra tokens -> 14.9s vs 3.7s, and the
|
|
25
|
+
# non-thinking answer was already correct. The man page does the grounding.
|
|
26
|
+
"think_explain": False,
|
|
27
|
+
# Ollama unloads an idle model after 5 minutes by default, so the first
|
|
28
|
+
# keypress after a break pays the cold-load penalty. Keeping it resident is
|
|
29
|
+
# the difference between a snappy key and a visible stall.
|
|
30
|
+
# "30m", "8h", or -1 to pin it in RAM until ollama restarts.
|
|
31
|
+
"keep_alive": "8h",
|
|
32
|
+
"timeout": 20, # ask: a keypress must not hang the prompt
|
|
33
|
+
"timeout_explain": 45, # explain: a man page is a much larger prompt
|
|
34
|
+
"history_lines": 10,
|
|
35
|
+
"max_files": 50,
|
|
36
|
+
# Listing available tools MEASURABLY hurts small models: qwen2.5-coder:3b
|
|
37
|
+
# went from 25% to 75% on a four-case probe when the list was removed. It
|
|
38
|
+
# sees `rg` and forces it into every answer with invented flags. Off by
|
|
39
|
+
# default; enable only if your model is shown to benefit.
|
|
40
|
+
"include_tools": False,
|
|
41
|
+
# 12000 chars was ~3100 prompt tokens on a 4B model. 6000 keeps explain
|
|
42
|
+
# responsive while still carrying the whole OPTIONS section for most tools.
|
|
43
|
+
"max_man_chars": 6000,
|
|
44
|
+
# When no man page exists, fall back to running `<cmd> --help`. OFF by
|
|
45
|
+
# default: explain is what you reach for BEFORE running something, so it
|
|
46
|
+
# must not run it for you. Opt in only if you trust what you paste.
|
|
47
|
+
"explain_run_help": False,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_BOOL = {"true": True, "false": False, "yes": True, "no": False, "1": True, "0": False}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def config_dir() -> Path:
|
|
54
|
+
"""Per-platform config location. Ten lines, so no platformdirs dependency."""
|
|
55
|
+
if platform.system() == "Windows":
|
|
56
|
+
base = os.environ.get("APPDATA")
|
|
57
|
+
if base:
|
|
58
|
+
return Path(base) / "cli-guru"
|
|
59
|
+
return Path.home() / "AppData" / "Roaming" / "cli-guru"
|
|
60
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
61
|
+
if xdg:
|
|
62
|
+
return Path(xdg) / "cli-guru"
|
|
63
|
+
return Path.home() / ".config" / "cli-guru"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_simple_toml(text: str) -> Dict[str, Any]:
|
|
67
|
+
"""Flat key = value subset of TOML.
|
|
68
|
+
|
|
69
|
+
Used only when tomllib is unavailable (Python < 3.11). The config is flat
|
|
70
|
+
scalars by design, so this is sufficient and keeps us stdlib-only on 3.9/3.10.
|
|
71
|
+
"""
|
|
72
|
+
out: Dict[str, Any] = {}
|
|
73
|
+
for raw in text.splitlines():
|
|
74
|
+
line = raw.strip()
|
|
75
|
+
if not line or line.startswith("#") or line.startswith("["):
|
|
76
|
+
continue
|
|
77
|
+
if "=" not in line:
|
|
78
|
+
continue
|
|
79
|
+
key, _, val = line.partition("=")
|
|
80
|
+
key, val = key.strip(), val.strip()
|
|
81
|
+
# strip trailing inline comment outside of quotes
|
|
82
|
+
if not val.startswith(('"', "'")):
|
|
83
|
+
val = val.split("#", 1)[0].strip()
|
|
84
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
|
85
|
+
out[key] = val[1:-1]
|
|
86
|
+
elif val.lower() in _BOOL:
|
|
87
|
+
out[key] = _BOOL[val.lower()]
|
|
88
|
+
elif re.fullmatch(r"-?\d+", val):
|
|
89
|
+
out[key] = int(val)
|
|
90
|
+
elif val:
|
|
91
|
+
out[key] = val
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_file(path: Path) -> Dict[str, Any]:
|
|
96
|
+
try:
|
|
97
|
+
raw = path.read_bytes()
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
return {}
|
|
100
|
+
try:
|
|
101
|
+
import tomllib # Python 3.11+
|
|
102
|
+
return dict(tomllib.loads(raw.decode("utf-8")))
|
|
103
|
+
except ImportError:
|
|
104
|
+
return _parse_simple_toml(raw.decode("utf-8", "replace"))
|
|
105
|
+
except Exception:
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _coerce(key: str, value: str) -> Any:
|
|
110
|
+
default = DEFAULTS.get(key)
|
|
111
|
+
if isinstance(default, bool):
|
|
112
|
+
return _BOOL.get(str(value).lower(), default)
|
|
113
|
+
if isinstance(default, int):
|
|
114
|
+
try:
|
|
115
|
+
return int(value)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return default
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load(overrides: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
|
122
|
+
"""Precedence: CLI flag > env > config file > default."""
|
|
123
|
+
cfg = dict(DEFAULTS)
|
|
124
|
+
cfg.update(_load_file(config_dir() / "config.toml"))
|
|
125
|
+
|
|
126
|
+
env_map = {
|
|
127
|
+
"model": "CLI_GURU_MODEL",
|
|
128
|
+
"host": "OLLAMA_HOST",
|
|
129
|
+
"timeout": "CLI_GURU_TIMEOUT",
|
|
130
|
+
"think": "CLI_GURU_THINK",
|
|
131
|
+
}
|
|
132
|
+
for key, env in env_map.items():
|
|
133
|
+
val = os.environ.get(env)
|
|
134
|
+
if val:
|
|
135
|
+
cfg[key] = _coerce(key, val)
|
|
136
|
+
|
|
137
|
+
for key, val in (overrides or {}).items():
|
|
138
|
+
if val is not None:
|
|
139
|
+
cfg[key] = val
|
|
140
|
+
|
|
141
|
+
cfg["host"] = normalise_host(str(cfg["host"]))
|
|
142
|
+
return cfg
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def normalise_host(host: str) -> str:
|
|
146
|
+
"""Accept `1.2.3.4:11434` as well as a full URL — OLLAMA_HOST is used both ways."""
|
|
147
|
+
host = host.strip().rstrip("/")
|
|
148
|
+
if not host.startswith(("http://", "https://")):
|
|
149
|
+
host = "http://" + host
|
|
150
|
+
if not re.search(r":\d+$", host):
|
|
151
|
+
host += ":11434"
|
|
152
|
+
return host
|