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.
src/core/context.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+
13
+ @dataclass
14
+ class CommandHistoryEntry:
15
+ command: str
16
+ exit_code: int
17
+ timestamp: float
18
+
19
+
20
+ @dataclass
21
+ class SystemContext:
22
+ os_name: str
23
+ os_version: str
24
+ architecture: str
25
+ current_user: str
26
+ current_directory: str
27
+ shell_name: str
28
+ git_branch: Optional[str] = None
29
+ git_is_dirty: Optional[bool] = None
30
+ recent_history: List[CommandHistoryEntry] = field(default_factory=list)
31
+
32
+ @classmethod
33
+ def collect(cls, history: Optional[List[CommandHistoryEntry]] = None) -> SystemContext:
34
+ """Collects current runtime environment and system context."""
35
+ os_name = platform.system()
36
+ os_version = platform.release()
37
+
38
+ # Extract Linux distro name if available
39
+ if os_name == "Linux":
40
+ try:
41
+ import distro # type: ignore
42
+
43
+ distro_name = distro.name(pretty=True)
44
+ if distro_name:
45
+ os_name = distro_name
46
+ except Exception:
47
+ if Path("/etc/os-release").is_file():
48
+ try:
49
+ with open("/etc/os-release", "r") as f:
50
+ for line in f:
51
+ if line.startswith("PRETTY_NAME="):
52
+ os_name = line.split("=", 1)[1].strip().strip('"')
53
+ break
54
+ except Exception:
55
+ pass
56
+ elif os_name == "Darwin":
57
+ os_name = f"macOS {platform.mac_ver()[0]}"
58
+
59
+ # Git context
60
+ git_branch = None
61
+ git_is_dirty = None
62
+ if shutil.which("git"):
63
+ try:
64
+ # Check branch
65
+ branch_proc = subprocess.run(
66
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=1.0,
70
+ )
71
+ if branch_proc.returncode == 0:
72
+ git_branch = branch_proc.stdout.strip()
73
+
74
+ # Check dirty status
75
+ status_proc = subprocess.run(
76
+ ["git", "status", "--porcelain"],
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=1.0,
80
+ )
81
+ git_is_dirty = bool(status_proc.stdout.strip())
82
+ except Exception:
83
+ pass
84
+
85
+ from src.core.suggest import detect_shell
86
+
87
+ shell_name = detect_shell()
88
+
89
+ return cls(
90
+ os_name=os_name,
91
+ os_version=os_version,
92
+ architecture=platform.machine(),
93
+ current_user=getpass.getuser(),
94
+ current_directory=str(Path.cwd()),
95
+ shell_name=shell_name,
96
+ git_branch=git_branch,
97
+ git_is_dirty=git_is_dirty,
98
+ recent_history=history or [],
99
+ )
100
+
101
+ def to_prompt_context(self) -> str:
102
+ """Formats the system context as a concise string for LLM prompts."""
103
+ lines = [
104
+ f"OS: {self.os_name} ({self.architecture})",
105
+ f"Shell: {self.shell_name}",
106
+ f"Current Directory: {self.current_directory}",
107
+ f"User: {self.current_user}",
108
+ ]
109
+ if self.git_branch:
110
+ dirty_str = " (uncommitted changes)" if self.git_is_dirty else " (clean)"
111
+ lines.append(f"Git Branch: {self.git_branch}{dirty_str}")
112
+
113
+ if self.recent_history:
114
+ recent_str = " -> ".join(
115
+ f"`{entry.command}` (exit: {entry.exit_code})" for entry in self.recent_history[-3:]
116
+ )
117
+ lines.append(f"Recent Commands: {recent_str}")
118
+
119
+ return "\n".join(lines)
120
+
121
+
122
+ def get_last_shell_command() -> Optional[str]:
123
+ """Retrieves the last non-ezShell command executed by the user.
124
+
125
+ Inspects in priority order:
126
+ 1. ~/.ezshell/.last_command (marker written by shell hook)
127
+ 2. $HISTFILE
128
+ 3. ~/.zsh_history (handles Zsh extended format ': <timestamp>:0;<cmd>')
129
+ 4. ~/.bash_history
130
+ 5. ~/.histfile
131
+
132
+ Returns:
133
+ Optional[str]: The command string, or None if no command history found.
134
+ """
135
+ # 0. État par terminal (hooks M0) : plus fiable que l'historique partagé
136
+ try:
137
+ from src.core.capture import read_last_run
138
+
139
+ run = read_last_run()
140
+ if run and run.command:
141
+ return run.command
142
+ except Exception:
143
+ pass
144
+
145
+ candidates = []
146
+
147
+ # 1. ezShell runtime marker
148
+ ez_marker = Path.home() / ".ezshell" / ".last_command"
149
+ if ez_marker.is_file():
150
+ candidates.append(ez_marker)
151
+
152
+ # 2. $HISTFILE
153
+ histfile = os.environ.get("HISTFILE")
154
+ if histfile and Path(histfile).is_file():
155
+ candidates.append(Path(histfile))
156
+
157
+ # 3. Standard shell history paths
158
+ home = Path.home()
159
+ for p in [
160
+ home / ".zsh_history",
161
+ home / ".bash_history",
162
+ home / ".histfile",
163
+ home / ".zhistory",
164
+ ]:
165
+ if p.is_file():
166
+ candidates.append(p)
167
+
168
+ ignored_prefixes = ("ezsh", "ezshell", "eszh", "clear", "exit", "history")
169
+
170
+ for p in candidates:
171
+ try:
172
+ with open(p, "rb") as f:
173
+ f.seek(0, os.SEEK_END)
174
+ size = f.tell()
175
+ f.seek(max(0, size - 8192))
176
+ content = f.read().decode("utf-8", errors="ignore")
177
+ lines = content.splitlines()
178
+
179
+ for raw_line in reversed(lines):
180
+ line = raw_line.strip()
181
+ # Zsh extended history format: ': 1712345678:0;command'
182
+ if line.startswith(":") and ";" in line:
183
+ line = line.split(";", 1)[1].strip()
184
+
185
+ if not line:
186
+ continue
187
+
188
+ parts = line.split()
189
+ if not parts:
190
+ continue
191
+ cmd_name = parts[0]
192
+ base_cmd = Path(cmd_name).name
193
+
194
+ # Ignore ezshell aliases and system navigation commands
195
+ if base_cmd in ignored_prefixes or cmd_name in ignored_prefixes:
196
+ continue
197
+ if any(x in line for x in ("ezshell", "ezsh", "eszh")):
198
+ continue
199
+
200
+ return line
201
+ except Exception:
202
+ continue
203
+
204
+ return None
205
+
206
+
207
+ def get_last_exit_code() -> Optional[int]:
208
+ """Retrieves the last command's exit code from ~/.ezshell/.last_exit_code.
209
+
210
+ Returns:
211
+ Optional[int]: The exit code (e.g. 0, 1, 127), or None if not available.
212
+ """
213
+ try:
214
+ from src.core.capture import read_last_run
215
+
216
+ run = read_last_run()
217
+ if run and run.exit_code is not None:
218
+ return run.exit_code
219
+ except Exception:
220
+ pass
221
+
222
+ marker = Path.home() / ".ezshell" / ".last_exit_code"
223
+ if marker.is_file():
224
+ try:
225
+ val = marker.read_text(encoding="utf-8").strip()
226
+ return int(val)
227
+ except Exception:
228
+ pass
229
+ return None
@@ -0,0 +1,376 @@
1
+ """
2
+ ezShell Groq API Client
3
+ ========================
4
+ This module wraps the official ``groq`` Python SDK to provide a thin,
5
+ opinionated interface for the language-model calls that power ezShell.
6
+ It surfaces a minimal API — :meth:`GroqClient.chat`,
7
+ :meth:`GroqClient.complete`, and :meth:`GroqClient.stream_complete` — and
8
+ translates every ``groq`` SDK exception into a narrow hierarchy of
9
+ application-specific errors so that callers never need to import from
10
+ ``groq`` directly.
11
+
12
+ The underlying :class:`groq.Groq` SDK client is created lazily on first use
13
+ and reused for subsequent calls, avoiding redundant TCP handshakes.
14
+
15
+ Exception hierarchy
16
+ -------------------
17
+ ::
18
+
19
+ GroqClientError (base — unexpected / uncategorised errors)
20
+ ├── GroqAuthError (missing or invalid API key)
21
+ ├── GroqRateLimitError (quota exceeded)
22
+ └── GroqConnectionError (network / DNS / timeout failure)
23
+
24
+ Usage example::
25
+
26
+ from ezShell.src.core.groq_client import GroqClient
27
+
28
+ client = GroqClient(api_key="gsk_...")
29
+ reply = client.complete(
30
+ prompt="List the files in /tmp",
31
+ system_prompt="You are a helpful shell assistant.",
32
+ )
33
+ print(reply)
34
+
35
+ ---
36
+
37
+ Client Groq pour ezShell
38
+ ==========================
39
+ Ce module encapsule le SDK Python officiel ``groq`` afin d'exposer une
40
+ interface légère et ciblée pour les appels au modèle de langage utilisés
41
+ par ezShell. Il traduit chaque exception du SDK en une hiérarchie
42
+ d'erreurs propre à l'application, de sorte que les appelants n'aient
43
+ jamais à importer directement depuis ``groq``.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import os
49
+ from typing import Generator, Optional
50
+
51
+ import groq
52
+ from groq import Groq
53
+
54
+ from .config import DEFAULT_MODEL
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Application-level exception hierarchy
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class GroqClientError(Exception):
62
+ """Base exception for all errors raised by :class:`GroqClient`.
63
+
64
+ Catch this class to handle any Groq-related failure regardless of the
65
+ specific root cause. More specific sub-classes are also available for
66
+ callers that need fine-grained error handling.
67
+ """
68
+
69
+
70
+ class GroqAuthError(GroqClientError):
71
+ """Raised when API key is missing, malformed, or rejected by the server.
72
+
73
+ This exception is raised in two scenarios:
74
+
75
+ * At construction time when no API key can be found in either the
76
+ constructor argument or the ``GROQ_API_KEY`` environment variable.
77
+ * At request time when the Groq API returns an ``AuthenticationError``
78
+ or ``PermissionDeniedError`` (e.g. the key has been revoked).
79
+ """
80
+
81
+
82
+ class GroqRateLimitError(GroqClientError):
83
+ """Raised when the Groq API rate limit or token quota is exceeded.
84
+
85
+ Callers should implement back-off / retry logic when catching this
86
+ exception, or prompt the user to wait before retrying.
87
+ """
88
+
89
+
90
+ class GroqConnectionError(GroqClientError):
91
+ """Raised on network-level failures when contacting the Groq API.
92
+
93
+ Covers DNS resolution failures, TCP connection refusal, TLS errors, and
94
+ request timeouts. The underlying :class:`groq.APIConnectionError` is
95
+ chained as the ``__cause__`` for inspection.
96
+ """
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Main client class
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class GroqClient:
105
+ """Thin, opinionated wrapper around the official Groq SDK for ezShell.
106
+
107
+ The client is initialised with an API key and an optional timeout, then
108
+ exposes three completion methods:
109
+
110
+ * :meth:`chat` — multi-turn conversation with an explicit message list
111
+ * :meth:`complete` — single prompt + system-prompt convenience wrapper
112
+ * :meth:`stream_complete` — streaming generator variant of :meth:`complete`
113
+
114
+ The underlying :class:`groq.Groq` SDK instance is created lazily via the
115
+ :attr:`client` property and reused across calls.
116
+
117
+ Example::
118
+
119
+ client = GroqClient() # reads GROQ_API_KEY from environment
120
+ for chunk in client.stream_complete(
121
+ prompt="Explain what `ls -la` does.",
122
+ system_prompt="You are a concise shell tutor.",
123
+ ):
124
+ print(chunk, end="", flush=True)
125
+
126
+ Args:
127
+ api_key (Optional[str]): Groq platform API key. When ``None``,
128
+ the ``GROQ_API_KEY`` environment variable is used as a fallback.
129
+ timeout (float): HTTP request timeout in seconds applied to every
130
+ API call. Default is ``15.0``.
131
+
132
+ Raises:
133
+ GroqAuthError: If no API key is available from either *api_key* or
134
+ the environment at construction time.
135
+ """
136
+
137
+ def __init__(self, api_key: Optional[str] = None, timeout: float = 15.0):
138
+ # Prefer the explicit argument; fall back to the environment variable
139
+ self.api_key = api_key or os.environ.get("GROQ_API_KEY")
140
+ if not self.api_key:
141
+ raise GroqAuthError(
142
+ "No Groq API key found. Please set GROQ_API_KEY in your environment, "
143
+ ".env.local, or ~/.ezshell/config.json."
144
+ )
145
+ self.timeout = timeout
146
+ # Deferred until first use to avoid creating network connections at import time
147
+ self._client: Optional[Groq] = None
148
+
149
+ @property
150
+ def client(self) -> Groq:
151
+ """Lazily initialised :class:`groq.Groq` SDK instance.
152
+
153
+ The instance is created on the first property access and cached for
154
+ all subsequent calls, giving connection reuse without requiring the
155
+ caller to manage the lifecycle explicitly.
156
+
157
+ Returns:
158
+ Groq: A configured ``groq.Groq`` client ready to make API calls.
159
+ """
160
+ if self._client is None:
161
+ self._client = Groq(api_key=self.api_key, timeout=self.timeout)
162
+ return self._client
163
+
164
+ def validate_connection(self) -> bool:
165
+ """Probe the Groq API to verify that the key and network are working.
166
+
167
+ Sends a minimal one-token request to avoid unnecessary token
168
+ consumption. Any response — even an error response — from the server
169
+ indicates network connectivity; only authentication and permission
170
+ errors cause the method to return ``False``.
171
+
172
+ Returns:
173
+ bool: ``True`` if the API key is accepted and a response is
174
+ received; ``False`` if authentication fails or any exception is
175
+ raised (including network errors).
176
+ """
177
+ try:
178
+ self.client.models.list()
179
+ return True
180
+ except (groq.AuthenticationError, groq.PermissionDeniedError):
181
+ # Key is present but rejected by the server
182
+ return False
183
+ except Exception:
184
+ # Network errors, timeouts, or other unexpected failures
185
+ return False
186
+
187
+ def chat(
188
+ self,
189
+ messages: list[dict[str, str]],
190
+ model: str = DEFAULT_MODEL,
191
+ temperature: float = 0.2,
192
+ max_tokens: int = 1024,
193
+ ) -> str:
194
+ """Send a multi-turn conversation to the Groq API and return the reply.
195
+
196
+ This is the lowest-level completion method; callers supply a fully
197
+ assembled message list, giving full control over the conversation
198
+ history and system prompt.
199
+
200
+ Args:
201
+ messages (list[dict[str, str]]): Ordered list of message dicts,
202
+ each with ``"role"`` (``"system"``, ``"user"``, or
203
+ ``"assistant"``) and ``"content"`` keys, following the
204
+ OpenAI Chat Completions format accepted by Groq.
205
+ model (str): Groq model identifier to use for this request.
206
+ Defaults to ``"openai/gpt-oss-120b"``.
207
+ temperature (float): Sampling temperature in the range ``[0, 2]``.
208
+ Lower values produce more focused, deterministic output.
209
+ Defaults to ``0.2``.
210
+ max_tokens (int): Maximum number of tokens the model may generate.
211
+ Defaults to ``1024``.
212
+
213
+ Returns:
214
+ str: The model's text reply, or an empty string if the API
215
+ returns a choice with no content.
216
+
217
+ Raises:
218
+ GroqAuthError: If the API key is rejected by the server.
219
+ GroqRateLimitError: If the rate limit or token quota is exceeded.
220
+ GroqConnectionError: If a network-level error prevents the
221
+ request from reaching the API.
222
+ GroqClientError: For any other API error (e.g. invalid model,
223
+ server-side 5xx, unexpected SDK exception).
224
+ """
225
+ try:
226
+ response = self.client.chat.completions.create(
227
+ model=model,
228
+ messages=messages,
229
+ temperature=temperature,
230
+ max_tokens=max_tokens,
231
+ )
232
+ return response.choices[0].message.content or ""
233
+ except groq.AuthenticationError as e:
234
+ raise GroqAuthError(f"Groq authentication failed: {e}") from e
235
+ except groq.RateLimitError as e:
236
+ raise GroqRateLimitError(f"Groq rate limit exceeded: {e}") from e
237
+ except groq.NotFoundError:
238
+ # If requested model is not found on account, fallback to available chat models
239
+ for fallback_model in ("openai/gpt-oss-20b", "qwen/qwen3.8-27b"):
240
+ if fallback_model != model:
241
+ try:
242
+ resp = self.client.chat.completions.create(
243
+ model=fallback_model,
244
+ messages=messages,
245
+ temperature=temperature,
246
+ max_tokens=max_tokens,
247
+ )
248
+ return resp.choices[0].message.content or ""
249
+ except Exception:
250
+ continue
251
+ raise GroqClientError(
252
+ f"Groq model '{model}' not found on account and fallbacks failed."
253
+ )
254
+ except groq.APIConnectionError as e:
255
+ raise GroqConnectionError(f"Network error connecting to Groq API: {e}") from e
256
+ except groq.APIStatusError as e:
257
+ raise GroqClientError(f"Groq API error (status {e.status_code}): {e}") from e
258
+ except Exception as e:
259
+ raise GroqClientError(f"Unexpected Groq error: {e}") from e
260
+
261
+ def complete(
262
+ self,
263
+ prompt: str,
264
+ system_prompt: str,
265
+ model: str = DEFAULT_MODEL,
266
+ temperature: float = 0.2,
267
+ max_tokens: int = 1024,
268
+ ) -> str:
269
+ """Execute a single-turn completion with a dedicated system prompt.
270
+
271
+ Convenience wrapper around :meth:`chat` that constructs a two-message
272
+ conversation (system + user) and returns the model's reply as a plain
273
+ string. Use :meth:`chat` directly when you need to pass a richer
274
+ conversation history.
275
+
276
+ Args:
277
+ prompt (str): The user-facing instruction or question to send to
278
+ the model.
279
+ system_prompt (str): Instruction placed in the ``"system"`` role
280
+ that shapes the model's behaviour for this request.
281
+ model (str): Groq model identifier. Defaults to
282
+ ``"openai/gpt-oss-120b"``.
283
+ temperature (float): Sampling temperature. Defaults to ``0.2``.
284
+ max_tokens (int): Maximum tokens to generate. Defaults to
285
+ ``1024``.
286
+
287
+ Returns:
288
+ str: The model's text response.
289
+
290
+ Raises:
291
+ GroqAuthError: Propagated from :meth:`chat`.
292
+ GroqRateLimitError: Propagated from :meth:`chat`.
293
+ GroqConnectionError: Propagated from :meth:`chat`.
294
+ GroqClientError: Propagated from :meth:`chat`.
295
+ """
296
+ # Assemble the standard two-message format expected by the Chat API
297
+ messages = [
298
+ {"role": "system", "content": system_prompt},
299
+ {"role": "user", "content": prompt},
300
+ ]
301
+ return self.chat(messages, model=model, temperature=temperature, max_tokens=max_tokens)
302
+
303
+ def stream_complete(
304
+ self,
305
+ prompt: str,
306
+ system_prompt: str,
307
+ model: str = DEFAULT_MODEL,
308
+ temperature: float = 0.2,
309
+ max_tokens: int = 1024,
310
+ ) -> Generator[str, None, None]:
311
+ """Stream a completion response as an iterable of text chunks.
312
+
313
+ Opens a server-sent-events stream to the Groq API and yields each
314
+ content delta as it arrives, enabling the caller to display tokens
315
+ progressively rather than waiting for the full response. The
316
+ generator silently skips chunks whose ``delta.content`` is ``None``
317
+ or empty (e.g. the final ``[DONE]`` sentinel).
318
+
319
+ Args:
320
+ prompt (str): The user-facing instruction or question.
321
+ system_prompt (str): Instruction placed in the ``"system"`` role.
322
+ model (str): Groq model identifier. Defaults to
323
+ ``"openai/gpt-oss-120b"``.
324
+ temperature (float): Sampling temperature. Defaults to ``0.2``.
325
+ max_tokens (int): Maximum tokens to generate. Defaults to
326
+ ``1024``.
327
+
328
+ Yields:
329
+ str: Successive text deltas from the model as they are received.
330
+ Each yielded string is a partial token or word fragment;
331
+ concatenating all yielded values produces the full response.
332
+
333
+ Raises:
334
+ GroqAuthError: If the API key is rejected when the stream opens.
335
+ GroqRateLimitError: If the rate limit is exceeded.
336
+ GroqConnectionError: If the stream cannot be established due to a
337
+ network error.
338
+ GroqClientError: For any other unexpected SDK exception.
339
+ """
340
+ try:
341
+ stream = self.client.chat.completions.create(
342
+ model=model,
343
+ messages=[
344
+ {"role": "system", "content": system_prompt},
345
+ {"role": "user", "content": prompt},
346
+ ],
347
+ temperature=temperature,
348
+ max_tokens=max_tokens,
349
+ stream=True, # Enable server-sent-events streaming mode
350
+ )
351
+ for chunk in stream:
352
+ delta = chunk.choices[0].delta.content
353
+ # Skip empty deltas (e.g. role-only chunks and the final sentinel)
354
+ if delta:
355
+ yield delta
356
+ except groq.AuthenticationError as e:
357
+ raise GroqAuthError(f"Groq authentication failed: {e}") from e
358
+ except groq.RateLimitError as e:
359
+ raise GroqRateLimitError(f"Groq rate limit exceeded: {e}") from e
360
+ except groq.APIConnectionError as e:
361
+ raise GroqConnectionError(f"Network error connecting to Groq API: {e}") from e
362
+ except Exception as e:
363
+ raise GroqClientError(f"Unexpected Groq streaming error: {e}") from e
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Public API surface
368
+ # ---------------------------------------------------------------------------
369
+
370
+ __all__ = [
371
+ "GroqClientError",
372
+ "GroqAuthError",
373
+ "GroqRateLimitError",
374
+ "GroqConnectionError",
375
+ "GroqClient",
376
+ ]