ezshell 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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
src/core/capture.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Capture de la dernière commande, de son code de sortie et de la fin de sa sortie d'erreur.
|
|
2
|
+
|
|
3
|
+
Prototype M0 (DEVELOPMENT.md §7.3). Deux sources, la plus riche gagne :
|
|
4
|
+
|
|
5
|
+
1. ``ezsh run -- <cmd>`` : exécute la commande, laisse stdout/stdin au terminal,
|
|
6
|
+
recopie stderr en direct et en garde la fin (~4 Ko). Écrit ``<session>.run.json``.
|
|
7
|
+
2. Hooks shell (preexec/precmd, PROMPT_COMMAND) : écrivent uniquement la commande
|
|
8
|
+
et le code de sortie (``<session>.cmd`` / ``<session>.code``) : pas de sortie.
|
|
9
|
+
|
|
10
|
+
Aucun contenu n'est envoyé ici : le masquage des secrets se fait avant l'envoi.
|
|
11
|
+
On ne rejoue JAMAIS la commande échouée (effets de bord).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
|
|
29
|
+
STDERR_TAIL_BYTES = 4096
|
|
30
|
+
IS_WINDOWS = os.name == "nt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_dir() -> Path:
|
|
34
|
+
"""Dossier d'état par utilisateur (0700), identique à celui calculé par les hooks."""
|
|
35
|
+
override = os.environ.get("EZSH_STATE_DIR")
|
|
36
|
+
if override:
|
|
37
|
+
base = Path(override)
|
|
38
|
+
elif IS_WINDOWS:
|
|
39
|
+
# Même chemin que le hook PowerShell : %LOCALAPPDATA%\ezsh (privé à l'utilisateur).
|
|
40
|
+
local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
41
|
+
base = Path(local) / "ezsh"
|
|
42
|
+
else:
|
|
43
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
44
|
+
base = (
|
|
45
|
+
Path(runtime) / "ezsh"
|
|
46
|
+
if runtime
|
|
47
|
+
else Path(tempfile.gettempdir()) / f"ezsh-{os.getuid()}"
|
|
48
|
+
)
|
|
49
|
+
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
50
|
+
return base
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def session_key() -> str:
|
|
54
|
+
"""Un état par terminal : EZSH_SESSION est exporté par les hooks (PID du shell)."""
|
|
55
|
+
return os.environ.get("EZSH_SESSION") or str(os.getppid())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class LastRun:
|
|
60
|
+
command: str
|
|
61
|
+
exit_code: Optional[int]
|
|
62
|
+
stderr_tail: str = ""
|
|
63
|
+
cwd: str = ""
|
|
64
|
+
captured_output: bool = False # True si la sortie d'erreur a été capturée
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
68
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
69
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
70
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
71
|
+
fh.write(text)
|
|
72
|
+
os.replace(tmp, path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_and_capture(argv: List[str]) -> int:
|
|
76
|
+
"""Exécute ``argv`` en gardant la fin de stderr. Retourne le code de sortie de la commande."""
|
|
77
|
+
if not argv:
|
|
78
|
+
print("ezsh run: aucune commande fournie (usage : ezsh run -- <commande>)", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
tail = bytearray()
|
|
81
|
+
lock = threading.Lock()
|
|
82
|
+
launch = argv
|
|
83
|
+
if IS_WINDOWS and not shutil.which(argv[0]):
|
|
84
|
+
# Cmdlet ou alias PowerShell (Get-ChildItem, dir…) : on le lance via PowerShell.
|
|
85
|
+
exe = shutil.which("pwsh") or shutil.which("powershell")
|
|
86
|
+
if exe:
|
|
87
|
+
launch = [exe, "-NoLogo", "-NoProfile", "-Command", _powershell_script(argv)]
|
|
88
|
+
try:
|
|
89
|
+
proc = subprocess.Popen(launch, stderr=subprocess.PIPE)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
msg = f"ezsh: commande introuvable : {argv[0]}\n"
|
|
92
|
+
sys.stderr.write(msg)
|
|
93
|
+
_store(argv, 127, msg.encode())
|
|
94
|
+
return 127
|
|
95
|
+
except PermissionError:
|
|
96
|
+
msg = f"ezsh: permission refusée : {argv[0]}\n"
|
|
97
|
+
sys.stderr.write(msg)
|
|
98
|
+
_store(argv, 126, msg.encode())
|
|
99
|
+
return 126
|
|
100
|
+
|
|
101
|
+
def pump() -> None:
|
|
102
|
+
assert proc.stderr is not None
|
|
103
|
+
for chunk in iter(lambda: proc.stderr.read1(4096), b""): # type: ignore[attr-defined]
|
|
104
|
+
try:
|
|
105
|
+
sys.stderr.buffer.write(chunk)
|
|
106
|
+
sys.stderr.buffer.flush()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
with lock:
|
|
110
|
+
tail.extend(chunk)
|
|
111
|
+
if len(tail) > STDERR_TAIL_BYTES:
|
|
112
|
+
del tail[: len(tail) - STDERR_TAIL_BYTES]
|
|
113
|
+
|
|
114
|
+
t = threading.Thread(target=pump, daemon=True)
|
|
115
|
+
t.start()
|
|
116
|
+
# Ctrl+C arrive aussi à l'enfant (même terminal) : on le laisse décider de s'arrêter
|
|
117
|
+
# au lieu de le tuer, pour ne pas casser les programmes qui gèrent SIGINT (REPL, psql…).
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
code = proc.wait()
|
|
121
|
+
break
|
|
122
|
+
except KeyboardInterrupt:
|
|
123
|
+
continue
|
|
124
|
+
if code < 0: # tué par un signal (Unix) : convention shell 128 + n
|
|
125
|
+
code = 128 - code
|
|
126
|
+
t.join(timeout=2)
|
|
127
|
+
_store(argv, code, bytes(tail))
|
|
128
|
+
return code
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ps_quote(arg: str) -> str:
|
|
132
|
+
"""Argument PowerShell : nu s'il est sûr (garde -Recurse comme paramètre), sinon '…'."""
|
|
133
|
+
if re.fullmatch(r"[\w\-.:/\\=+,@%]+", arg):
|
|
134
|
+
return arg
|
|
135
|
+
return "'" + arg.replace("'", "''") + "'"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _powershell_script(argv: List[str]) -> str:
|
|
139
|
+
"""Script PowerShell qui lance `argv` et reproduit les codes de sortie d'un shell :
|
|
140
|
+
127 si la commande n'existe pas, le code du programme natif, sinon 1 en cas d'échec."""
|
|
141
|
+
name = argv[0].replace("'", "''")
|
|
142
|
+
return (
|
|
143
|
+
f"if (-not (Get-Command -Name '{name}' -ErrorAction SilentlyContinue)) {{ "
|
|
144
|
+
f"[Console]::Error.WriteLine('ezsh: commande introuvable : {name}'); exit 127 }}; "
|
|
145
|
+
f"& {' '.join(_ps_quote(a) for a in argv)}; "
|
|
146
|
+
"if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _store(argv: List[str], code: int, tail: bytes) -> None:
|
|
151
|
+
if IS_WINDOWS:
|
|
152
|
+
command = subprocess.list2cmdline(argv)
|
|
153
|
+
else:
|
|
154
|
+
import shlex
|
|
155
|
+
|
|
156
|
+
command = " ".join(shlex.quote(a) for a in argv)
|
|
157
|
+
payload = {
|
|
158
|
+
"command": command,
|
|
159
|
+
"exit_code": code,
|
|
160
|
+
"stderr_tail": tail.decode("utf-8", errors="replace"),
|
|
161
|
+
"cwd": os.getcwd(),
|
|
162
|
+
"ts": time.time(),
|
|
163
|
+
}
|
|
164
|
+
try:
|
|
165
|
+
_atomic_write(state_dir() / f"{session_key()}.run.json", json.dumps(payload))
|
|
166
|
+
except OSError:
|
|
167
|
+
pass # la capture est un confort : ne jamais casser la commande
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read(path: Path) -> Optional[str]:
|
|
171
|
+
try:
|
|
172
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
173
|
+
except OSError:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def read_last_run() -> Optional[LastRun]:
|
|
178
|
+
"""Dernière commande de CE terminal. Utilise la capture ``run`` si elle est la plus récente."""
|
|
179
|
+
d, key = state_dir(), session_key()
|
|
180
|
+
cmd_p, code_p, run_p = d / f"{key}.cmd", d / f"{key}.code", d / f"{key}.run.json"
|
|
181
|
+
|
|
182
|
+
run = None
|
|
183
|
+
raw = _read(run_p)
|
|
184
|
+
if raw:
|
|
185
|
+
try:
|
|
186
|
+
run = json.loads(raw)
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
run = None
|
|
189
|
+
|
|
190
|
+
# Les hooks suppriment run.json dès qu'une autre commande tourne :
|
|
191
|
+
# s'il existe, c'est le plus récent.
|
|
192
|
+
if run:
|
|
193
|
+
return LastRun(
|
|
194
|
+
run["command"],
|
|
195
|
+
run.get("exit_code"),
|
|
196
|
+
run.get("stderr_tail", ""),
|
|
197
|
+
run.get("cwd", ""),
|
|
198
|
+
True,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
hook_cmd = (_read(cmd_p) or "").strip()
|
|
202
|
+
hook_code = (_read(code_p) or "").strip()
|
|
203
|
+
|
|
204
|
+
if hook_cmd:
|
|
205
|
+
try:
|
|
206
|
+
code = int(hook_code) if hook_code else None
|
|
207
|
+
except ValueError:
|
|
208
|
+
code = None
|
|
209
|
+
return LastRun(hook_cmd, code, "", "", False)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = ["run_and_capture", "read_last_run", "LastRun", "state_dir", "session_key"]
|
src/core/chat_bridge.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pont entre les interfaces GUI / TUI / --explain et le fournisseur de `Alt+E`.
|
|
2
|
+
|
|
3
|
+
Toutes les interfaces passent ainsi par le même chemin : proxy ezShell (ou Groq direct avec
|
|
4
|
+
EZSH_DIRECT=1, ou mode simulé), validation JSON, nettoyage, plancher de risque. Le résultat
|
|
5
|
+
est converti en `ChatMessageResult` pour que les interfaces existantes n'aient qu'à l'afficher.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from src.core.assistant import ChatMessageResult
|
|
13
|
+
from src.core.provider import (
|
|
14
|
+
LLMProvider,
|
|
15
|
+
MissingKey,
|
|
16
|
+
ProviderError,
|
|
17
|
+
ProviderOffline,
|
|
18
|
+
QuotaExceeded,
|
|
19
|
+
UpdateRequired,
|
|
20
|
+
get_provider,
|
|
21
|
+
)
|
|
22
|
+
from src.core.redact import redact_secrets
|
|
23
|
+
from src.core.safety import RiskLevel, SafetyReport
|
|
24
|
+
from src.core.schema import InvalidResponse
|
|
25
|
+
from src.core.suggest import build_request, detect_lang, get_suggestion
|
|
26
|
+
from src.ui.panel import tr
|
|
27
|
+
|
|
28
|
+
_RISK = {"low": RiskLevel.SAFE, "medium": RiskLevel.WARNING, "high": RiskLevel.DANGEROUS}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ask(
|
|
32
|
+
query: str = "",
|
|
33
|
+
*,
|
|
34
|
+
mode: str = "translate",
|
|
35
|
+
command: str = "",
|
|
36
|
+
provider: Optional[LLMProvider] = None,
|
|
37
|
+
) -> ChatMessageResult:
|
|
38
|
+
"""`mode` : translate (question libre), explain_error (dernier échec), explain_command."""
|
|
39
|
+
if mode == "explain_error":
|
|
40
|
+
req = build_request("")
|
|
41
|
+
if command and not req.last_command:
|
|
42
|
+
req.last_command = command
|
|
43
|
+
if query: # texte d'erreur fourni à la main (--explain)
|
|
44
|
+
req.stderr_tail = redact_secrets(query)[-4096:]
|
|
45
|
+
req.exit_code = req.exit_code if req.exit_code is not None else 1
|
|
46
|
+
elif mode == "explain_command":
|
|
47
|
+
req = build_request(command or query)
|
|
48
|
+
req.mode = "explain_command"
|
|
49
|
+
else:
|
|
50
|
+
req = build_request(query)
|
|
51
|
+
|
|
52
|
+
s = get_suggestion(req, provider or get_provider())
|
|
53
|
+
parts = [s.response.explanation]
|
|
54
|
+
if s.response.cause:
|
|
55
|
+
parts.append(f"{tr(req.lang)['cause']} : {s.response.cause}")
|
|
56
|
+
commands = [s.command] if s.command else []
|
|
57
|
+
reports = [
|
|
58
|
+
SafetyReport(
|
|
59
|
+
command=s.command,
|
|
60
|
+
risk_level=_RISK.get(s.risk, RiskLevel.WARNING),
|
|
61
|
+
reason=s.risk_reason,
|
|
62
|
+
requires_explicit_confirmation=s.risk == "high",
|
|
63
|
+
)
|
|
64
|
+
] if s.command else []
|
|
65
|
+
return ChatMessageResult(
|
|
66
|
+
content="\n\n".join(p for p in parts if p),
|
|
67
|
+
suggested_commands=commands,
|
|
68
|
+
safety_reports=reports,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def provider_label(provider: LLMProvider, config=None) -> str:
|
|
73
|
+
"""Libellé court du fournisseur actif, pour les barres d'état."""
|
|
74
|
+
from urllib.parse import urlparse
|
|
75
|
+
|
|
76
|
+
from src.core.provider import MockProvider
|
|
77
|
+
from src.core.proxy_client import ProxyProvider
|
|
78
|
+
|
|
79
|
+
if isinstance(provider, MockProvider):
|
|
80
|
+
return "Mock · offline"
|
|
81
|
+
if isinstance(provider, ProxyProvider):
|
|
82
|
+
return f"ezShell · {urlparse(provider.base_url).hostname or 'proxy'}"
|
|
83
|
+
model = getattr(provider, "model", "") or (config.get_effective_model() if config else "")
|
|
84
|
+
return f"Groq · {model}".rstrip(" ·")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def describe_error(exc: Exception) -> str:
|
|
88
|
+
"""Message court et traduit pour une erreur du fournisseur (mêmes textes que `Alt+E`)."""
|
|
89
|
+
t = tr(detect_lang())
|
|
90
|
+
if isinstance(exc, ProviderOffline):
|
|
91
|
+
return t["offline"]
|
|
92
|
+
if isinstance(exc, UpdateRequired):
|
|
93
|
+
return t["update_required"]
|
|
94
|
+
if isinstance(exc, QuotaExceeded):
|
|
95
|
+
if exc.scope == "anonymous":
|
|
96
|
+
return t["trial"]
|
|
97
|
+
return t["quota"].format(scope=exc.scope, reset=exc.reset_at or "?")
|
|
98
|
+
if isinstance(exc, MissingKey):
|
|
99
|
+
return t["nokey"]
|
|
100
|
+
if isinstance(exc, InvalidResponse):
|
|
101
|
+
return t["invalid"]
|
|
102
|
+
if isinstance(exc, ProviderError):
|
|
103
|
+
return t["error"]
|
|
104
|
+
return f"{t['error']} ({type(exc).__name__})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["ask", "describe_error"]
|
src/core/config.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ezShell Configuration Management
|
|
3
|
+
==================================
|
|
4
|
+
This module defines the ``EzShellConfig`` dataclass, which centralises every
|
|
5
|
+
user-configurable setting for the ezShell application. Configuration is
|
|
6
|
+
resolved through a layered precedence chain so that more specific sources
|
|
7
|
+
always win over more general ones:
|
|
8
|
+
|
|
9
|
+
1. Local ``.env.local`` / ``.env`` file in the current working directory
|
|
10
|
+
2. Process-level environment variables (``GROQ_API_KEY``, ``EZSHELL_MODEL``)
|
|
11
|
+
3. Persisted user config file (``~/.ezshell/config.json`` or a custom path)
|
|
12
|
+
4. Hard-coded default values defined in this module
|
|
13
|
+
|
|
14
|
+
The :func:`EzShellConfig.load` classmethod merges all sources automatically,
|
|
15
|
+
while :func:`EzShellConfig.save` serialises the current settings back to
|
|
16
|
+
disk as JSON.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
Gestion de la configuration d'ezShell
|
|
21
|
+
=======================================
|
|
22
|
+
Ce module définit le dataclass ``EzShellConfig``, qui centralise tous les
|
|
23
|
+
paramètres configurables par l'utilisateur pour l'application ezShell.
|
|
24
|
+
La configuration est résolue selon une chaîne de priorité : les sources
|
|
25
|
+
plus spécifiques remplacent toujours les sources plus générales.
|
|
26
|
+
|
|
27
|
+
1. Fichier ``.env.local`` / ``.env`` dans le répertoire de travail courant
|
|
28
|
+
2. Variables d'environnement du processus (``GROQ_API_KEY``, ``EZSHELL_MODEL``)
|
|
29
|
+
3. Fichier de configuration utilisateur persisté (``~/.ezshell/config.json``)
|
|
30
|
+
4. Valeurs par défaut définies dans ce module
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from dataclasses import asdict, dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
from dotenv import load_dotenv
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Module-level constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
#: Default directory where ezShell stores its persistent configuration.
|
|
48
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".ezshell"
|
|
49
|
+
|
|
50
|
+
#: Full path to the default JSON configuration file.
|
|
51
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.json"
|
|
52
|
+
|
|
53
|
+
#: Groq model identifier used when no model is specified by the user.
|
|
54
|
+
DEFAULT_MODEL = "openai/gpt-oss-120b"
|
|
55
|
+
|
|
56
|
+
#: Supported provider identifiers.
|
|
57
|
+
SUPPORTED_PROVIDERS: list[str] = ["groq", "openai", "anthropic", "ollama"]
|
|
58
|
+
|
|
59
|
+
#: Default model per provider.
|
|
60
|
+
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
|
61
|
+
"groq": DEFAULT_MODEL,
|
|
62
|
+
"openai": "gpt-4o-mini",
|
|
63
|
+
"anthropic": "claude-3-5-haiku-20241022",
|
|
64
|
+
"ollama": "llama3",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class EzShellConfig:
|
|
70
|
+
"""Centralised, immutable-style configuration for an ezShell session.
|
|
71
|
+
|
|
72
|
+
All fields have sensible defaults so that ``EzShellConfig()`` is always
|
|
73
|
+
a valid, usable configuration without any environment setup.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
groq_api_key (Optional[str]): Groq platform API key used to
|
|
77
|
+
authenticate requests. When ``None``, the value is looked up
|
|
78
|
+
from the ``GROQ_API_KEY`` environment variable at runtime via
|
|
79
|
+
:meth:`get_effective_api_key`.
|
|
80
|
+
model (str): Groq model identifier (e.g. ``"openai/gpt-oss-120b"``).
|
|
81
|
+
Overridable via the ``EZSHELL_MODEL`` environment variable.
|
|
82
|
+
temperature (float): Sampling temperature passed to the language
|
|
83
|
+
model. Lower values (closer to 0) produce more deterministic
|
|
84
|
+
output; higher values increase creativity. Default is ``0.2``.
|
|
85
|
+
max_tokens (int): Maximum number of tokens the model may generate in
|
|
86
|
+
a single response. Default is ``1024``.
|
|
87
|
+
auto_suggest_fix (bool): When ``True``, ezShell automatically
|
|
88
|
+
proposes a corrected command after a shell error without waiting
|
|
89
|
+
for an explicit user request. Default is ``True``.
|
|
90
|
+
stream_explanation (bool): When ``True``, model explanations are
|
|
91
|
+
printed incrementally as tokens arrive rather than buffered and
|
|
92
|
+
displayed all at once. Default is ``True``.
|
|
93
|
+
max_history_items (int): Maximum number of shell-command history
|
|
94
|
+
entries retained in the session context sent to the model.
|
|
95
|
+
Default is ``100``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
groq_api_key: Optional[str] = None
|
|
99
|
+
model: str = DEFAULT_MODEL
|
|
100
|
+
temperature: float = 0.2
|
|
101
|
+
max_tokens: int = 1024
|
|
102
|
+
auto_suggest_fix: bool = True
|
|
103
|
+
stream_explanation: bool = True
|
|
104
|
+
max_history_items: int = 100
|
|
105
|
+
# --- multi-provider ---
|
|
106
|
+
provider: str = "groq"
|
|
107
|
+
openai_api_key: Optional[str] = None
|
|
108
|
+
anthropic_api_key: Optional[str] = None
|
|
109
|
+
ollama_base_url: str = "http://localhost:11434"
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def load(cls, cwd: Optional[Path] = None, config_path: Optional[Path] = None) -> EzShellConfig:
|
|
113
|
+
"""Build an ``EzShellConfig`` instance by merging all configuration sources.
|
|
114
|
+
|
|
115
|
+
Sources are applied from lowest to highest precedence so that each
|
|
116
|
+
subsequent source can selectively override earlier values:
|
|
117
|
+
|
|
118
|
+
1. User config JSON file (``~/.ezshell/config.json`` or *config_path*)
|
|
119
|
+
2. Process environment variables (``GROQ_API_KEY``, ``EZSHELL_MODEL``)
|
|
120
|
+
3. ``.env.local`` / ``.env`` dotenv file in *cwd* (loaded into the
|
|
121
|
+
environment before step 2 is evaluated, but ``override=False``
|
|
122
|
+
prevents it from overwriting already-set variables)
|
|
123
|
+
4. Dataclass field defaults for any key not supplied by the above
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
cwd (Optional[Path]): Directory to search for ``.env.local`` /
|
|
127
|
+
``.env`` files. Defaults to ``Path.cwd()`` when ``None``.
|
|
128
|
+
config_path (Optional[Path]): Explicit path to a JSON config file.
|
|
129
|
+
When ``None``, :data:`DEFAULT_CONFIG_PATH` is used.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
EzShellConfig: A fully initialised configuration object whose
|
|
133
|
+
field values reflect the merged result of all available sources.
|
|
134
|
+
"""
|
|
135
|
+
# 1. Load local .env files if present
|
|
136
|
+
current_dir = cwd or Path.cwd()
|
|
137
|
+
env_local = current_dir / ".env.local"
|
|
138
|
+
env_default = current_dir / ".env"
|
|
139
|
+
# Prefer .env.local over .env; override=False keeps already-set vars intact
|
|
140
|
+
if env_local.is_file():
|
|
141
|
+
load_dotenv(dotenv_path=env_local, override=False)
|
|
142
|
+
elif env_default.is_file():
|
|
143
|
+
load_dotenv(dotenv_path=env_default, override=False)
|
|
144
|
+
|
|
145
|
+
# 2. Base dict from user config JSON file if it exists
|
|
146
|
+
data: dict = {}
|
|
147
|
+
target_config = config_path or DEFAULT_CONFIG_PATH
|
|
148
|
+
if target_config.is_file():
|
|
149
|
+
try:
|
|
150
|
+
with open(target_config, "r", encoding="utf-8") as f:
|
|
151
|
+
file_data = json.load(f)
|
|
152
|
+
if isinstance(file_data, dict):
|
|
153
|
+
data.update(file_data)
|
|
154
|
+
except Exception:
|
|
155
|
+
# Silently ignore malformed or unreadable config files so that
|
|
156
|
+
# a corrupted file never prevents ezShell from starting.
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# 3. Environment variables take precedence over config file
|
|
160
|
+
env_map = {
|
|
161
|
+
"GROQ_API_KEY": "groq_api_key",
|
|
162
|
+
"OPENAI_API_KEY": "openai_api_key",
|
|
163
|
+
"ANTHROPIC_API_KEY": "anthropic_api_key",
|
|
164
|
+
"EZSHELL_MODEL": "model",
|
|
165
|
+
"EZSHELL_PROVIDER": "provider",
|
|
166
|
+
"OLLAMA_BASE_URL": "ollama_base_url",
|
|
167
|
+
}
|
|
168
|
+
for env_var, field_name in env_map.items():
|
|
169
|
+
val = os.environ.get(env_var)
|
|
170
|
+
if val:
|
|
171
|
+
data[field_name] = val
|
|
172
|
+
|
|
173
|
+
valid_keys = {
|
|
174
|
+
"groq_api_key", "model", "temperature", "max_tokens",
|
|
175
|
+
"auto_suggest_fix", "stream_explanation", "max_history_items",
|
|
176
|
+
"provider", "openai_api_key", "anthropic_api_key", "ollama_base_url",
|
|
177
|
+
}
|
|
178
|
+
filtered_data = {k: v for k, v in data.items() if k in valid_keys}
|
|
179
|
+
|
|
180
|
+
return cls(**filtered_data)
|
|
181
|
+
|
|
182
|
+
def save(self, path: Optional[Path] = None) -> None:
|
|
183
|
+
"""Persist the current configuration to a JSON file on disk.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
path (Optional[Path]): Destination. Defaults to ``~/.ezshell/config.json``.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
OSError: If the file cannot be created or written.
|
|
190
|
+
"""
|
|
191
|
+
target_path = path or DEFAULT_CONFIG_PATH
|
|
192
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
194
|
+
json.dump(asdict(self), f, indent=2)
|
|
195
|
+
|
|
196
|
+
def get_effective_api_key(self) -> Optional[str]:
|
|
197
|
+
"""Return the API key for the currently active provider.
|
|
198
|
+
|
|
199
|
+
Ollama is local — it requires no key and returns ``None``.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Optional[str]: Active API key, or ``None``.
|
|
203
|
+
"""
|
|
204
|
+
if self.provider == "openai":
|
|
205
|
+
return self.openai_api_key or os.environ.get("OPENAI_API_KEY")
|
|
206
|
+
if self.provider == "anthropic":
|
|
207
|
+
return self.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
208
|
+
if self.provider == "ollama":
|
|
209
|
+
return None
|
|
210
|
+
# Default: groq
|
|
211
|
+
return self.groq_api_key or os.environ.get("GROQ_API_KEY")
|
|
212
|
+
|
|
213
|
+
def get_effective_model(self) -> str:
|
|
214
|
+
"""Return the model name to use for the active provider.
|
|
215
|
+
|
|
216
|
+
If :attr:`model` is still the Groq default and the provider has
|
|
217
|
+
changed, returns the canonical default for that provider.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
str: Model identifier for the active provider.
|
|
221
|
+
"""
|
|
222
|
+
if self.model == DEFAULT_MODEL and self.provider in PROVIDER_DEFAULT_MODELS:
|
|
223
|
+
return PROVIDER_DEFAULT_MODELS[self.provider]
|
|
224
|
+
return self.model
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Public API surface
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
__all__ = [
|
|
232
|
+
"DEFAULT_CONFIG_DIR",
|
|
233
|
+
"DEFAULT_CONFIG_PATH",
|
|
234
|
+
"DEFAULT_MODEL",
|
|
235
|
+
"SUPPORTED_PROVIDERS",
|
|
236
|
+
"PROVIDER_DEFAULT_MODELS",
|
|
237
|
+
"EzShellConfig",
|
|
238
|
+
]
|