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 ADDED
@@ -0,0 +1,3 @@
1
+ """cli-guru — plain-language shell commands from a local ollama model."""
2
+
3
+ __version__ = "0.2.0"
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