pehloo-shell 0.1.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.
@@ -0,0 +1,4 @@
1
+ """lfm-shell package."""
2
+
3
+ __version__ = "0.1.0"
4
+
pehloo_shell/cli.py ADDED
@@ -0,0 +1,348 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import urllib.error
10
+ import urllib.request
11
+ from pathlib import Path
12
+
13
+ from dotenv import load_dotenv
14
+
15
+ from pehloo_shell.onboarding import run_wizard
16
+ from pehloo_shell.providers import LOCAL_PROVIDER, PROVIDERS, provider_for
17
+
18
+ load_dotenv()
19
+
20
+ CONFIG_PATH = Path.home() / ".pehloo" / "shell-config.json"
21
+ SYSTEM_PROMPT = """You translate requests into shell commands.
22
+ Return exactly one shell command and nothing else: no explanation, no prompt symbol,
23
+ and no Markdown fence. Prefer portable commands unless the request requires a
24
+ platform-specific feature. Never invent placeholder paths or values. Words such as
25
+ \"here\" and \"current folder\" mean the shell's current working directory."""
26
+ EXAMPLE_REQUEST = "List the 10 largest directories in the current folder."
27
+ EXAMPLE_ANSWER = "du -h --max-depth=1 | sort -hr | head -n 10"
28
+ # Sent as a follow-up turn when the user presses `c` at the confirmation prompt.
29
+ REVISION_PROMPT = "Change that command: {change}"
30
+ ACTION_PROMPT = "[y] run [n] reject [c] change > "
31
+ # Exit status for Ctrl-C / Ctrl-D at a prompt (128 + SIGINT, the shell convention).
32
+ INTERRUPTED = 130
33
+
34
+
35
+ def clean_request(value: str) -> str:
36
+ """Remove a leading shell-comment marker while preserving the request."""
37
+ return re.sub(r"^\s*(?://|#)\s*", "", value).strip()
38
+
39
+
40
+ def clean_response(value: str) -> str:
41
+ """Tolerate Markdown fences even though the model is asked not to use them."""
42
+ value = value.strip()
43
+ match = re.fullmatch(r"```(?:bash|sh|shell|zsh)?\s*\n?(.*?)\n?```", value, re.DOTALL | re.IGNORECASE)
44
+ return (match.group(1) if match else value).strip()
45
+
46
+
47
+ def config_path() -> Path:
48
+ return Path(os.environ.get("PEHLOO_SHELL_CONFIG", CONFIG_PATH))
49
+
50
+
51
+ def load_config() -> dict[str, str]:
52
+ path = config_path()
53
+ if not path.exists():
54
+ return {}
55
+ try:
56
+ raw_config = json.loads(path.read_text(encoding="utf-8"))
57
+ except json.JSONDecodeError as error:
58
+ raise RuntimeError(f"invalid config file {path}: {error}") from error
59
+ if not isinstance(raw_config, dict):
60
+ raise RuntimeError(f"invalid config file {path}: expected a JSON object")
61
+
62
+ config: dict[str, str] = {}
63
+ aliases = {
64
+ "inference_provider": "inference_provider",
65
+ "provider": "inference_provider",
66
+ "url": "url",
67
+ "model_slug": "model_slug",
68
+ "model": "model_slug",
69
+ "api_key": "api_key",
70
+ "apikey": "api_key",
71
+ }
72
+ for key, value in raw_config.items():
73
+ normalized_key = aliases.get(str(key).strip().lower().replace("-", "_").replace(" ", "_"))
74
+ if normalized_key and value:
75
+ config[normalized_key] = str(value)
76
+ return config
77
+
78
+
79
+ def setting_from_env(name: str, fallback: str) -> str:
80
+ return os.environ.get(name) or fallback
81
+
82
+
83
+ def api_key_for(provider, config: dict[str, str]) -> str:
84
+ """Resolve the API key: the provider's own env var, a generic override, then the config file."""
85
+ candidates = [
86
+ os.environ.get(provider.key_env, "") if provider.key_env else "",
87
+ os.environ.get("PEHLOO_SHELL_API_KEY", ""),
88
+ config.get("api_key", ""),
89
+ ]
90
+ for candidate in candidates:
91
+ if candidate:
92
+ return candidate
93
+ return ""
94
+
95
+
96
+ def resolve_settings(config: dict[str, str]) -> tuple[str, str, str, str]:
97
+ provider = provider_for(setting_from_env("PEHLOO_SHELL_PROVIDER", config.get("inference_provider", LOCAL_PROVIDER.name)))
98
+ url = setting_from_env("LFM_SHELL_URL", config.get("url", provider.chat_url))
99
+ model = setting_from_env("LFM_SHELL_MODEL", config.get("model_slug", provider.default_model))
100
+ return provider.name, url, model, api_key_for(provider, config)
101
+
102
+
103
+ def auth_headers(provider: str, api_key: str = "") -> dict[str, str]:
104
+ """Headers for the chat request. The key is only sent when we have one."""
105
+ backend = provider_for(provider)
106
+ headers = {
107
+ "Content-Type": "application/json",
108
+ "User-Agent": "pehloo-shell/0.1",
109
+ }
110
+ if api_key:
111
+ headers["Authorization"] = f"Bearer {api_key}"
112
+ elif backend.api_key_required:
113
+ raise RuntimeError(f"{backend.name} needs an API key: export {backend.key_env} or run `pls --setup`")
114
+ return headers
115
+
116
+
117
+ def build_messages(prompt: str, previous_command: str = "", change: str = "") -> list[dict[str, str]]:
118
+ """Build the chat payload, appending the revision turn when the user asked for a change."""
119
+ messages = [
120
+ {"role": "system", "content": SYSTEM_PROMPT},
121
+ {"role": "user", "content": EXAMPLE_REQUEST},
122
+ {"role": "assistant", "content": EXAMPLE_ANSWER},
123
+ {"role": "user", "content": prompt},
124
+ ]
125
+ if previous_command:
126
+ messages.append({"role": "assistant", "content": previous_command})
127
+ messages.append({"role": "user", "content": REVISION_PROMPT.format(change=change)})
128
+ return messages
129
+
130
+
131
+ def generate_command(
132
+ prompt: str,
133
+ url: str,
134
+ model: str,
135
+ timeout: float,
136
+ provider: str = "llama.cpp",
137
+ api_key: str = "",
138
+ previous_command: str = "",
139
+ change: str = "",
140
+ ) -> str:
141
+ """Ask the model for one command; `previous_command` + `change` ask it to revise one."""
142
+ payload = {
143
+ "model": model,
144
+ "messages": build_messages(prompt, previous_command, change),
145
+ "temperature": 0.2,
146
+ "max_tokens": 8192,
147
+ }
148
+ request = urllib.request.Request(
149
+ url,
150
+ data=json.dumps(payload).encode("utf-8"),
151
+ headers=auth_headers(provider, api_key),
152
+ method="POST",
153
+ )
154
+ try:
155
+ with urllib.request.urlopen(request, timeout=timeout) as response:
156
+ result = json.load(response)
157
+ except urllib.error.HTTPError as error:
158
+ detail = error.read().decode("utf-8", errors="replace")
159
+ raise RuntimeError(f"server returned HTTP {error.code}: {detail}") from error
160
+ except urllib.error.URLError as error:
161
+ raise RuntimeError(f"could not reach {url}: {error.reason}") from error
162
+ except (TimeoutError, json.JSONDecodeError) as error:
163
+ raise RuntimeError(f"invalid or timed-out response from {url}: {error}") from error
164
+
165
+ try:
166
+ content = result["choices"][0]["message"]["content"]
167
+ except (KeyError, IndexError, TypeError) as error:
168
+ raise RuntimeError(f"unexpected server response: {result!r}") from error
169
+
170
+ command = clean_response(content)
171
+ if not command:
172
+ raise RuntimeError("the model returned an empty command")
173
+ return command
174
+
175
+
176
+ def stdin_is_interactive() -> bool:
177
+ """True when both ends are a terminal, so asking the user a question is safe."""
178
+ return sys.stdin.isatty() and sys.stdout.isatty()
179
+
180
+
181
+ def print_command(command: str, markdown: bool) -> None:
182
+ """Print the command, optionally fenced so it can be pasted into Markdown."""
183
+ print(f"```bash\n{command}\n```" if markdown else command)
184
+
185
+
186
+ def choose_action(answer: str) -> str:
187
+ """Map a typed reply to `run`, `reject`, `revise` — or `ask` for anything else.
188
+
189
+ Enter and `n` reject: a stray newline must never run a command.
190
+ """
191
+ answer = answer.strip().lower()
192
+ if answer in {"y", "yes"}:
193
+ return "run"
194
+ if answer in {"c", "change", "revise"}:
195
+ return "revise"
196
+ if answer in {"n", "no", ""}:
197
+ return "reject"
198
+ return "ask"
199
+
200
+
201
+ def read_line(prompt: str) -> str | None:
202
+ """Read one line, or None when the user interrupts or closes the prompt."""
203
+ try:
204
+ return input(prompt).strip()
205
+ except (EOFError, KeyboardInterrupt):
206
+ print()
207
+ return None
208
+
209
+
210
+ def run_command(command: str) -> int:
211
+ """Run the confirmed command in the user's shell, passing its exit status through."""
212
+ try:
213
+ return subprocess.run(command, shell=True, check=False).returncode
214
+ except KeyboardInterrupt:
215
+ print()
216
+ return INTERRUPTED
217
+
218
+
219
+ def confirm_and_run(prompt: str, command: str, args: argparse.Namespace, api_key: str) -> int:
220
+ """Show the command and act on the user's choice: run it, reject it, or change it.
221
+
222
+ `c` sends the requested change back to the model together with the previous
223
+ command, so iterating does not mean retyping the request. Returns the exit
224
+ status `pls` should exit with.
225
+ """
226
+ while True:
227
+ print_command(command, args.markdown)
228
+ answer = read_line(ACTION_PROMPT)
229
+ if answer is None:
230
+ return INTERRUPTED
231
+
232
+ action = choose_action(answer)
233
+ if action == "reject":
234
+ return 0
235
+ if action == "ask":
236
+ continue
237
+ if action == "run":
238
+ return run_command(command)
239
+
240
+ change = read_line("how should it change? ")
241
+ if change is None:
242
+ return INTERRUPTED
243
+ if not change:
244
+ continue
245
+
246
+ try:
247
+ command = generate_command(
248
+ prompt,
249
+ args.url,
250
+ args.model,
251
+ args.timeout,
252
+ args.provider,
253
+ api_key,
254
+ previous_command=command,
255
+ change=change,
256
+ )
257
+ except RuntimeError as error:
258
+ print(f"pls: {error}", file=sys.stderr)
259
+ return 1
260
+
261
+
262
+ def offer_setup(force: bool) -> dict[str, str]:
263
+ """Run the setup wizard, or explain why we cannot.
264
+
265
+ Reached when `--setup` was passed, or when there is no config file yet and the
266
+ user can answer questions. Without a terminal we keep the old behaviour — no
267
+ prompts, default settings — so piping a request into `pls` still works.
268
+ """
269
+ if not stdin_is_interactive():
270
+ if force:
271
+ raise RuntimeError("--setup needs an interactive terminal")
272
+ return {}
273
+ try:
274
+ return run_wizard(config_path())
275
+ except (EOFError, KeyboardInterrupt):
276
+ print("\npls: setup skipped, using default settings", file=sys.stderr)
277
+ return {}
278
+
279
+
280
+ def build_parser(
281
+ provider: str = LOCAL_PROVIDER.name,
282
+ url: str = LOCAL_PROVIDER.chat_url,
283
+ model: str = LOCAL_PROVIDER.default_model,
284
+ ) -> argparse.ArgumentParser:
285
+ parser = argparse.ArgumentParser(
286
+ prog="pls",
287
+ description=(
288
+ "Generate a shell command with an OpenAI-compatible model API, then choose "
289
+ "whether to run it, reject it, or change it."
290
+ ),
291
+ epilog="pehloo-shell · a Pehloo tool (https://pehloo.xyz)",
292
+ )
293
+ parser.add_argument("request", nargs="*", help='request, for example: "list files by size"')
294
+ parser.add_argument("--provider", default=provider, choices=sorted(PROVIDERS))
295
+ parser.add_argument("--url", default=url)
296
+ parser.add_argument("--model", default=model)
297
+ parser.add_argument("--timeout", type=float, default=30.0, help="request timeout in seconds")
298
+ parser.add_argument("--markdown", action="store_true", help="wrap output in a bash Markdown fence")
299
+ parser.add_argument("--setup", action="store_true", help="run the backend setup wizard again")
300
+ return parser
301
+
302
+
303
+ def main(argv: list[str] | None = None) -> int:
304
+ argv = list(sys.argv[1:] if argv is None else argv)
305
+ setup_requested = "--setup" in argv
306
+
307
+ try:
308
+ config = load_config()
309
+ if setup_requested or (not config and stdin_is_interactive()):
310
+ config = offer_setup(force=setup_requested)
311
+ provider, url, model, api_key = resolve_settings(config)
312
+ except RuntimeError as error:
313
+ print(f"pls: {error}", file=sys.stderr)
314
+ return 1
315
+
316
+ args = build_parser(provider, url, model).parse_args(argv)
317
+ if args.setup and not args.request:
318
+ return 0 # `pls --setup` on its own is only the wizard
319
+
320
+ raw_request = " ".join(args.request)
321
+ if not raw_request and not sys.stdin.isatty():
322
+ raw_request = sys.stdin.read()
323
+ if not raw_request:
324
+ answer = read_line("// ")
325
+ if answer is None:
326
+ return INTERRUPTED
327
+ raw_request = answer
328
+
329
+ prompt = clean_request(raw_request)
330
+ if not prompt:
331
+ print("pls: a request is required", file=sys.stderr)
332
+ return 2
333
+
334
+ try:
335
+ command = generate_command(prompt, args.url, args.model, args.timeout, args.provider, api_key)
336
+ except RuntimeError as error:
337
+ print(f"pls: {error}", file=sys.stderr)
338
+ return 1
339
+
340
+ if not stdin_is_interactive():
341
+ # Piped or redirected: print the command and stay out of the way.
342
+ print_command(command, args.markdown)
343
+ return 0
344
+ return confirm_and_run(prompt, command, args, api_key)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ raise SystemExit(main())
@@ -0,0 +1,160 @@
1
+ """First-run setup: pick a backend, find its models, write shell-config.json.
2
+
3
+ `pls` calls `run_wizard()` when there is no config file yet, so a new user gets a
4
+ working backend instead of a connection error. The wizard is plain question/answer
5
+ flow over stdin — no terminal-cursor tricks — so it stays readable and testable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from pehloo_shell.providers import (
15
+ HOSTED_PROVIDERS,
16
+ LOCAL_PROVIDER,
17
+ Provider,
18
+ chat_url,
19
+ list_models,
20
+ )
21
+
22
+ # Terminal branding, matching pehloo.xyz: boxed status tags and ▸ progress lines.
23
+ BANNER = """\
24
+ ╔══════════════════════════════╗
25
+ ║ PLS_SETUP: MODEL_BACKEND ║
26
+ ╚══════════════════════════════╝
27
+ ▸ pehloo-shell · a Pehloo tool (pehloo.xyz)"""
28
+
29
+
30
+ def ask(question: str, default: str = "") -> str:
31
+ """Prompt for one line, returning `default` when the user presses Enter."""
32
+ suffix = f" [{default}]" if default else ""
33
+ answer = input(f"{question}{suffix}: ").strip()
34
+ return answer or default
35
+
36
+
37
+ def ask_yes_no(question: str, default: bool = True) -> bool:
38
+ """Ask a yes/no question; anything unrecognised asks again rather than guessing."""
39
+ hint = "Y/n" if default else "y/N"
40
+ while True:
41
+ answer = input(f"{question} [{hint}]: ").strip().lower()
42
+ if not answer:
43
+ return default
44
+ if answer in {"y", "yes"}:
45
+ return True
46
+ if answer in {"n", "no"}:
47
+ return False
48
+ print(" please answer y or n")
49
+
50
+
51
+ def choose_model(models: list[str], default: str) -> str:
52
+ """Let the user pick a detected model by number, or type one that is not listed."""
53
+ # A provider's default model is often not installed on a local server, so the
54
+ # first detected model becomes the default instead of an unusable name.
55
+ fallback = default if default in models else (models[0] if models else default)
56
+ for index, model in enumerate(models, start=1):
57
+ marker = " (default)" if model == fallback else ""
58
+ print(f" {index}. {model}{marker}")
59
+ while True:
60
+ answer = ask("Model number or name", fallback)
61
+ if answer.isdigit() and 1 <= int(answer) <= len(models):
62
+ return models[int(answer) - 1]
63
+ if answer:
64
+ return answer
65
+ print(" a model is required")
66
+
67
+
68
+ def discover_models(url: str, api_key: str) -> list[str]:
69
+ """List the server's models, retrying when it is unreachable.
70
+
71
+ Returns an empty list when the user gives up, so the caller can fall back to
72
+ a typed-in model name instead of losing the whole setup.
73
+ """
74
+ while True:
75
+ try:
76
+ models = list_models(url, api_key)
77
+ except RuntimeError as error:
78
+ print(f" ! {error}")
79
+ models = []
80
+ if models:
81
+ return models
82
+ if not ask_yes_no("Try the model list again?", default=False):
83
+ return []
84
+
85
+
86
+ def configure_local() -> dict[str, str]:
87
+ """Collect settings for a self-hosted OpenAI-compatible server."""
88
+ print(f"\nLocal server: {LOCAL_PROVIDER.label} — any OpenAI-compatible server.")
89
+ url = ask("Server URL", LOCAL_PROVIDER.url)
90
+ api_key = ask("API key (leave blank if the server has none)", "")
91
+ models = discover_models(url, api_key)
92
+ if not models:
93
+ print(" starting without a model list; type the model name your server serves")
94
+ model = choose_model(models, LOCAL_PROVIDER.default_model)
95
+ return _config(provider=LOCAL_PROVIDER, url=url, model=model, api_key=api_key)
96
+
97
+
98
+ def configure_hosted() -> dict[str, str]:
99
+ """Collect settings for one of the hosted OpenAI-compatible providers."""
100
+ print("\nHosted providers:")
101
+ for index, provider in enumerate(HOSTED_PROVIDERS, start=1):
102
+ print(f" {index}. {provider.label}")
103
+
104
+ while True:
105
+ answer = ask("Provider number", "1")
106
+ if answer.isdigit() and 1 <= int(answer) <= len(HOSTED_PROVIDERS):
107
+ provider = HOSTED_PROVIDERS[int(answer) - 1]
108
+ break
109
+ print(" please pick one of the numbers above")
110
+
111
+ from_env = os.environ.get(provider.key_env, "") if provider.key_env else ""
112
+ if from_env:
113
+ print(f" found {provider.key_env} in your environment")
114
+ else:
115
+ print(f" create a key at {provider.key_url}")
116
+
117
+ api_key = ask(f"{provider.key_env or 'API key'} (stored in your config file)", from_env)
118
+ # A key that already lives in the environment is not copied into the config
119
+ # file; the CLI reads the environment variable first anyway.
120
+ stored_key = "" if api_key == from_env else api_key
121
+
122
+ models = discover_models(provider.url, api_key)
123
+ if not models:
124
+ print(f" starting without a model list; the default is {provider.default_model}")
125
+ model = choose_model(models, provider.default_model)
126
+ return _config(provider=provider, url=provider.url, model=model, api_key=stored_key)
127
+
128
+
129
+ def _config(provider: Provider, url: str, model: str, api_key: str) -> dict[str, str]:
130
+ """Assemble the config file body, omitting an empty API key."""
131
+ config = {
132
+ "inference_provider": provider.name,
133
+ "url": chat_url(url),
134
+ "model_slug": model,
135
+ }
136
+ if api_key:
137
+ config["api_key"] = api_key
138
+ return config
139
+
140
+
141
+ def write_config(config: dict[str, str], path: Path) -> Path:
142
+ """Write shell-config.json readable only by the user — it may hold an API key."""
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
145
+ os.chmod(path, 0o600)
146
+ return path
147
+
148
+
149
+ def run_wizard(path: Path) -> dict[str, str]:
150
+ """Run the whole setup flow and save the result to `path`."""
151
+ print(BANNER)
152
+ print(f"▸ your answers are saved to {path} (re-run any time with `pls --setup`)")
153
+ if ask_yes_no("\nUse a local model server?", default=True):
154
+ config = configure_local()
155
+ else:
156
+ config = configure_hosted()
157
+ write_config(config, path)
158
+ print(f"▸ saved {path}")
159
+ print(f"▸ provider={config['inference_provider']} url={config['url']} model={config['model_slug']}")
160
+ return config
@@ -0,0 +1,158 @@
1
+ """Inference backends `pls` can talk to, and model discovery against them.
2
+
3
+ Every backend here speaks the OpenAI chat-completions API, so the transport in
4
+ `cli.py` stays a single urllib call. This module only carries what the rest of
5
+ the CLI needs per backend: where the API lives, which model to fall back on, and
6
+ where the API key comes from.
7
+
8
+ `url` is always the *base* URL (`.../v1`); `.chat_url` and `.models_url` derive
9
+ the two endpoints from it. Stored config keeps the full chat-completions URL,
10
+ which is what earlier versions wrote, and `chat_url()` accepts both spellings so
11
+ hand-written config files keep working.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Provider:
24
+ """One OpenAI-compatible backend."""
25
+
26
+ name: str # canonical id, stored in shell-config.json
27
+ label: str # human wording for the setup wizard
28
+ url: str # base URL, no /chat/completions suffix
29
+ default_model: str
30
+ key_env: str # environment variable holding the API key ("" if not needed)
31
+ key_url: str # where a user can create a key ("" if not needed)
32
+ api_key_required: bool
33
+
34
+ @property
35
+ def chat_url(self) -> str:
36
+ return chat_url(self.url)
37
+
38
+ @property
39
+ def models_url(self) -> str:
40
+ return models_url(self.url)
41
+
42
+
43
+ LOCAL_PROVIDER = Provider(
44
+ name="llama.cpp",
45
+ label="llama.cpp, Ollama, LM Studio or vLLM",
46
+ url="http://127.0.0.1:8080/v1",
47
+ default_model="LFM2.5-1.2B-Instruct-Q8_0.gguf",
48
+ key_env="",
49
+ key_url="",
50
+ api_key_required=False,
51
+ )
52
+
53
+ HOSTED_PROVIDERS = (
54
+ Provider(
55
+ name="cerebras",
56
+ label="Cerebras — very fast hosted models, free tier",
57
+ url="https://api.cerebras.ai/v1",
58
+ default_model="gpt-oss-120b",
59
+ key_env="CEREBRAS_API_KEY",
60
+ key_url="https://cloud.cerebras.ai",
61
+ api_key_required=True,
62
+ ),
63
+ Provider(
64
+ name="groq",
65
+ label="Groq — very fast hosted models, free tier",
66
+ url="https://api.groq.com/openai/v1",
67
+ default_model="llama-3.3-70b-versatile",
68
+ key_env="GROQ_API_KEY",
69
+ key_url="https://console.groq.com/keys",
70
+ api_key_required=True,
71
+ ),
72
+ Provider(
73
+ name="openrouter",
74
+ label="OpenRouter — many models behind one key",
75
+ url="https://openrouter.ai/api/v1",
76
+ default_model="openai/gpt-4o-mini",
77
+ key_env="OPENROUTER_API_KEY",
78
+ key_url="https://openrouter.ai/keys",
79
+ api_key_required=True,
80
+ ),
81
+ )
82
+
83
+ PROVIDERS: dict[str, Provider] = {p.name: p for p in (LOCAL_PROVIDER, *HOSTED_PROVIDERS)}
84
+
85
+ # Spellings users (and the earlier Cerebras-only version) may already have in a
86
+ # config file or environment variable.
87
+ ALIASES = {
88
+ "llama.cpp": "llama.cpp",
89
+ "llamacpp": "llama.cpp",
90
+ "llama_cpp": "llama.cpp",
91
+ "local": "llama.cpp",
92
+ "cerebras.ai": "cerebras",
93
+ "openrouter.ai": "openrouter",
94
+ }
95
+
96
+
97
+ def provider_for(name: str) -> Provider:
98
+ """Look up a provider, or raise a RuntimeError naming the supported ones.
99
+
100
+ Silently falling back to the local default on a typo would send requests to
101
+ the wrong server, so an unknown name stops the CLI instead.
102
+ """
103
+ key = name.strip().lower()
104
+ if not key:
105
+ return LOCAL_PROVIDER
106
+ canonical = ALIASES.get(key, key)
107
+ if canonical in PROVIDERS:
108
+ return PROVIDERS[canonical]
109
+ supported = ", ".join(PROVIDERS)
110
+ raise RuntimeError(f"unknown provider {name!r} (supported: {supported}; or pass --url for any OpenAI-compatible server)")
111
+
112
+
113
+ def chat_url(url: str) -> str:
114
+ """The chat-completions endpoint for `url`, accepting a base or a full URL."""
115
+ base = url.strip().rstrip("/")
116
+ if base.endswith("/chat/completions"):
117
+ return base
118
+ return base + "/chat/completions"
119
+
120
+
121
+ def models_url(url: str) -> str:
122
+ """The `/models` endpoint that pairs with a chat-completions URL."""
123
+ base = url.strip().rstrip("/")
124
+ return base.removesuffix("/chat/completions") + "/models"
125
+
126
+
127
+ def list_models(url: str, api_key: str = "", timeout: float = 5.0) -> list[str]:
128
+ """Ask the server which models it serves, in the order it reports them.
129
+
130
+ Raises RuntimeError with a readable message: the setup wizard prints it and
131
+ offers a retry, which is how a user notices a server that is not running yet.
132
+ """
133
+ endpoint = models_url(url)
134
+ headers = {"User-Agent": "pehloo-shell/0.1"}
135
+ if api_key:
136
+ headers["Authorization"] = f"Bearer {api_key}"
137
+ request = urllib.request.Request(endpoint, headers=headers, method="GET")
138
+ try:
139
+ with urllib.request.urlopen(request, timeout=timeout) as response:
140
+ payload = json.load(response)
141
+ except urllib.error.HTTPError as error:
142
+ detail = error.read().decode("utf-8", errors="replace")[:200]
143
+ raise RuntimeError(f"{endpoint} returned HTTP {error.code}: {detail}") from error
144
+ except urllib.error.URLError as error:
145
+ raise RuntimeError(f"could not reach {endpoint}: {error.reason}") from error
146
+ except (TimeoutError, json.JSONDecodeError) as error:
147
+ raise RuntimeError(f"invalid or timed-out response from {endpoint}: {error}") from error
148
+
149
+ entries = payload.get("data") if isinstance(payload, dict) else None
150
+ if not isinstance(entries, list):
151
+ raise RuntimeError(f"unexpected response from {endpoint}: expected a JSON object with a 'data' list")
152
+
153
+ models: list[str] = []
154
+ for entry in entries:
155
+ model_id = entry.get("id") if isinstance(entry, dict) else None
156
+ if model_id and str(model_id) not in models:
157
+ models.append(str(model_id))
158
+ return models
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: pehloo-shell
3
+ Version: 0.1.0
4
+ Summary: Natural language in, shell command out — review it, then press y to run. A Pehloo tool.
5
+ Project-URL: Homepage, https://pehloo.xyz
6
+ Author: Ribhu
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: python-dotenv>=1
14
+ Description-Content-Type: text/markdown
15
+
16
+ ```
17
+ ██████╗ ███████╗██╗ ██╗██╗ ██████╗ ██████╗
18
+ ██╔══██╗██╔════╝██║ ██║██║ ██╔═══██╗██╔═══██╗
19
+ ██████╔╝█████╗ ███████║██║ ██║ ██║██║ ██║
20
+ ██╔═══╝ ██╔══╝ ██╔══██║██║ ██║ ██║██║ ██║
21
+ ██║ ███████╗██║ ██║███████╗╚██████╔╝╚██████╔╝
22
+ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝
23
+ ```
24
+
25
+ # pls
26
+
27
+ **Natural language in, shell command out. Review it, then press `y`.**
28
+
29
+ `pls` is the Pehloo shell assistant — from [Pehloo](https://pehloo.xyz), AI
30
+ tools for builders who ship. It asks an OpenAI-compatible model to turn your
31
+ request into one shell command, shows it to you, and waits:
32
+
33
+ ```console
34
+ $ pls "list the 10 largest directories in the current folder"
35
+ du -h --max-depth=1 | sort -hr | head -n 10
36
+ [y] run [n] reject [c] change >
37
+ ```
38
+
39
+ - `y` runs the command in your shell; `pls` exits with its status.
40
+ - `n` or Enter rejects it — nothing runs.
41
+ - `c` asks the model to change it (e.g. type `sort by size instead`) and shows
42
+ the revision, with the previous command as context.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ curl -fsSL https://raw.githubusercontent.com/ribhu97/pehloo-shell/main/install.sh | sh
48
+ sh install.sh # from a checkout
49
+ ```
50
+
51
+ The installer works at user level — no sudo, no system Python — using uv, pipx
52
+ or pip, whichever you have, and prints the installed `pls` path plus the
53
+ `export PATH=…` line if you need one. It installs the `pehloo-shell` package
54
+ from PyPI, falling back to this repository while the package is unpublished.
55
+ Point it elsewhere with `--source` / `$PEHLOO_SHELL_SOURCE`:
56
+
57
+ ```bash
58
+ sh install.sh --source "git+https://github.com/ribhu97/pehloo-shell"
59
+ ```
60
+
61
+ Already have a Python tool runner? Skip the script:
62
+
63
+ ```bash
64
+ uv tool install pehloo-shell # or: pipx install pehloo-shell
65
+ ```
66
+
67
+ For development see [AGENTS.md](AGENTS.md) (`uv sync`, then `.venv/bin/pls`).
68
+ Note that `pipx install .` and `uv tool install .` copy the code — rerun them
69
+ after changing the CLI.
70
+
71
+ ## Setup
72
+
73
+ The first run with no config asks which backend to use:
74
+
75
+ ```console
76
+ ╔══════════════════════════════╗
77
+ ║ PLS_SETUP: MODEL_BACKEND ║
78
+ ╚══════════════════════════════╝
79
+ ▸ pehloo-shell · a Pehloo tool (pehloo.xyz)
80
+ ▸ your answers are saved to ~/.pehloo/shell-config.json (re-run any time with `pls --setup`)
81
+
82
+ Use a local model server? [Y/n]: y
83
+
84
+ Local server: llama.cpp, Ollama, LM Studio or vLLM — any OpenAI-compatible server.
85
+ Server URL [http://127.0.0.1:8080/v1]:
86
+ API key (leave blank if the server has none):
87
+ 1. LFM2.5-1.2B-Instruct-Q8_0.gguf (default)
88
+ 2. MiniCPM5-2B
89
+ Model number or name [1]:
90
+ ```
91
+
92
+ - **Local** (`y`): any OpenAI-compatible server — llama.cpp, Ollama, LM Studio,
93
+ vLLM. `pls` reads its `/v1/models` list so you can pick a model instead of
94
+ guessing a name.
95
+ - **Hosted** (`n`): Cerebras, Groq or OpenRouter. The wizard tells you where to
96
+ create a key and detects the provider's models with it. A key already in your
97
+ environment is used without being copied into the config file.
98
+
99
+ Re-run setup any time with `pls --setup`, or edit `~/.pehloo/shell-config.json`
100
+ by hand.
101
+
102
+ ## Non-interactive
103
+
104
+ ```bash
105
+ echo "show files changed today" | pls # prints the command, runs nothing
106
+ pls --markdown "show my current directory"
107
+ ```
108
+
109
+ When stdin is not a terminal — pipes, scripts, CI — `pls` prints the command and
110
+ stops. Nothing executes without an explicit `y` at a terminal.
111
+
112
+ ## Environment variables
113
+
114
+ Handy for CI, or to keep the API key out of the config file. They override the
115
+ config file; command-line flags override both.
116
+
117
+ | Variable | Meaning |
118
+ | --- | --- |
119
+ | `PEHLOO_SHELL_CONFIG` | where the config file lives (default `~/.pehloo/shell-config.json`) |
120
+ | `PEHLOO_SHELL_PROVIDER` | `llama.cpp`, `cerebras`, `groq` or `openrouter` |
121
+ | `PEHLOO_SHELL_API_KEY` | API key for any provider |
122
+ | `CEREBRAS_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` | provider-specific keys |
123
+ | `LFM_SHELL_URL` | chat-completions URL of any OpenAI-compatible server |
124
+ | `LFM_SHELL_MODEL` | model name to send |
125
+
126
+ Read the command before you press `y`: model output can be destructive.
@@ -0,0 +1,9 @@
1
+ pehloo_shell/__init__.py,sha256=2Knag3nEzNeB_p-yLd5zF4wDcyGBTquyTkU9g0tnsH8,49
2
+ pehloo_shell/cli.py,sha256=RaQ8krDZWgYMsmMfJJJE77OzxnAp0KhY9-kxH0XkJ9U,12568
3
+ pehloo_shell/onboarding.py,sha256=2ZRYU22nasC3uN_eEOMO4nnkiNDgl0NRSwBqlc_sKY8,6269
4
+ pehloo_shell/providers.py,sha256=3lvecfiyZKbh-pEKthu-TMheD5meftKhUwlgWZdM-IU,5679
5
+ pehloo_shell-0.1.0.dist-info/METADATA,sha256=5vVrqycQ7Vd0syWPepknhsAf08JDoxrl4xEU_UOjL0o,5156
6
+ pehloo_shell-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ pehloo_shell-0.1.0.dist-info/entry_points.txt,sha256=sKSI6gXz9matj4mXS3s3Pej89pj1_AROwoBQwVSskqI,46
8
+ pehloo_shell-0.1.0.dist-info/licenses/LICENSE,sha256=qWj0J2VpC5ZXkKM_u3On6kcgSqFXUsPc5WX5jUxmku0,1062
9
+ pehloo_shell-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pls = pehloo_shell.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ribhu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.