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.
- pehloo_shell/__init__.py +4 -0
- pehloo_shell/cli.py +348 -0
- pehloo_shell/onboarding.py +160 -0
- pehloo_shell/providers.py +158 -0
- pehloo_shell-0.1.0.dist-info/METADATA +126 -0
- pehloo_shell-0.1.0.dist-info/RECORD +9 -0
- pehloo_shell-0.1.0.dist-info/WHEEL +4 -0
- pehloo_shell-0.1.0.dist-info/entry_points.txt +2 -0
- pehloo_shell-0.1.0.dist-info/licenses/LICENSE +21 -0
pehloo_shell/__init__.py
ADDED
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,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.
|