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/shell_hook.py ADDED
@@ -0,0 +1,388 @@
1
+ """
2
+ ezShell Shell Integration & Hook Generator
3
+ ==========================================
4
+
5
+ Generates and installs non-intrusive shell hooks for Bash, Zsh, and Fish.
6
+ The hooks automatically capture the last executed command and its exit code
7
+ into ``~/.ezshell/.last_command`` and ``~/.ezshell/.last_exit_code``, enabling
8
+ instant error diagnosis and the global Alt+E keyboard shortcut.
9
+
10
+ ---
11
+
12
+ Générateur et installateur d'intégration shell d'ezShell
13
+ ========================================================
14
+
15
+ Génère et installe des hooks légers pour Bash, Zsh et Fish.
16
+ Les hooks capturent automatiquement la dernière commande exécutée et son code
17
+ de retour dans ``~/.ezshell/.last_command`` et ``~/.ezshell/.last_exit_code``,
18
+ permettant le diagnostic immédiat et le raccourci clavier Alt+E.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+ from typing import List, Optional, Tuple
27
+
28
+ _STATE_PREAMBLE = """export EZSH_SESSION="$$"
29
+ if [ -z "$EZSH_STATE_DIR" ]; then
30
+ if [ -n "$XDG_RUNTIME_DIR" ]; then EZSH_STATE_DIR="$XDG_RUNTIME_DIR/ezsh"; else EZSH_STATE_DIR="${TMPDIR:-/tmp}/ezsh-$(id -u)"; fi
31
+ export EZSH_STATE_DIR
32
+ fi
33
+ ( umask 077; mkdir -p "$EZSH_STATE_DIR" ) 2>/dev/null
34
+ """
35
+
36
+ ZSH_HOOK = """# ---------------------------------------------------------------------------
37
+ # ezShell Integration for Zsh (état par terminal ; insertion dans le prompt)
38
+ # ---------------------------------------------------------------------------
39
+ """ + _STATE_PREAMBLE + """__ezshell_preexec() {
40
+ __EZSHELL_LAST_CMD="$1"
41
+ }
42
+
43
+ __ezshell_precmd() {
44
+ local exit_code=$?
45
+ [[ -z "$__EZSHELL_LAST_CMD" ]] && return
46
+ case "$__EZSHELL_LAST_CMD" in
47
+ "ezsh suggest"*|"ezshell suggest"*|clear|exit|history*) ;;
48
+ *)
49
+ printf "%s" "$__EZSHELL_LAST_CMD" > "$EZSH_STATE_DIR/$EZSH_SESSION.cmd" 2>/dev/null
50
+ printf "%d" "$exit_code" > "$EZSH_STATE_DIR/$EZSH_SESSION.code" 2>/dev/null
51
+ case "$__EZSHELL_LAST_CMD" in
52
+ "ezsh run "*|"ezshell run "*) ;;
53
+ *) rm -f "$EZSH_STATE_DIR/$EZSH_SESSION.run.json" 2>/dev/null ;;
54
+ esac
55
+ ;;
56
+ esac
57
+ unset __EZSHELL_LAST_CMD
58
+ }
59
+
60
+ autoload -Uz add-zsh-hook 2>/dev/null
61
+ add-zsh-hook preexec __ezshell_preexec 2>/dev/null
62
+ add-zsh-hook precmd __ezshell_precmd 2>/dev/null
63
+
64
+ # Alt+E : l'interface s'affiche sur /dev/tty, la commande choisie est placée dans le prompt (jamais exécutée).
65
+ # Si la ligne de saisie contient du texte, il est utilisé comme question en langage naturel.
66
+ __ezshell_widget() {
67
+ local out
68
+ zle -I
69
+ out="$(command ezsh suggest ${BUFFER:+--ask="$BUFFER"} </dev/tty)"
70
+ if [[ $? -eq 0 && -n "$out" ]]; then
71
+ BUFFER="$out"
72
+ CURSOR=${#BUFFER}
73
+ fi
74
+ zle reset-prompt 2>/dev/null || true
75
+ }
76
+ zle -N __ezshell_widget 2>/dev/null || true
77
+ bindkey '\\ee' __ezshell_widget 2>/dev/null || true
78
+ # ---------------------------------------------------------------------------
79
+ """
80
+
81
+ BASH_HOOK = """# ---------------------------------------------------------------------------
82
+ # ezShell Integration for Bash (état par terminal ; insertion dans le prompt)
83
+ # ---------------------------------------------------------------------------
84
+ """ + _STATE_PREAMBLE + """__ezshell_prompt_command() {
85
+ local exit_code=$?
86
+ local last_cmd
87
+ last_cmd=$(HISTTIMEFORMAT= history 1 2>/dev/null | sed -e 's/^[ ]*[0-9]*[ ]*//')
88
+ [[ -z "$last_cmd" ]] && return
89
+ # Ne rien refaire si la ligne d'historique n'a pas changé (prompt vide).
90
+ local hist_id
91
+ hist_id=$(HISTTIMEFORMAT= history 1 2>/dev/null | awk '{print $1}')
92
+ [[ "$hist_id" == "$__EZSHELL_LAST_HIST_ID" ]] && return
93
+ __EZSHELL_LAST_HIST_ID="$hist_id"
94
+ case "$last_cmd" in
95
+ "ezsh suggest"*|"ezshell suggest"*|clear|exit|history*) ;;
96
+ *)
97
+ printf "%s" "$last_cmd" > "$EZSH_STATE_DIR/$EZSH_SESSION.cmd" 2>/dev/null
98
+ printf "%d" "$exit_code" > "$EZSH_STATE_DIR/$EZSH_SESSION.code" 2>/dev/null
99
+ case "$last_cmd" in
100
+ "ezsh run "*|"ezshell run "*) ;;
101
+ *) rm -f "$EZSH_STATE_DIR/$EZSH_SESSION.run.json" 2>/dev/null ;;
102
+ esac
103
+ ;;
104
+ esac
105
+ }
106
+
107
+ if [[ \";$PROMPT_COMMAND;\" != *\";__ezshell_prompt_command;\"* ]]; then
108
+ PROMPT_COMMAND=\"__ezshell_prompt_command${PROMPT_COMMAND:+;$PROMPT_COMMAND}\"
109
+ fi
110
+
111
+ # Alt+E : interface sur /dev/tty ; la commande choisie remplace la ligne (READLINE_LINE), jamais exécutée.
112
+ __ezshell_widget() {
113
+ local out
114
+ out="$(command ezsh suggest ${READLINE_LINE:+--ask="$READLINE_LINE"} </dev/tty)"
115
+ if [[ $? -eq 0 && -n "$out" ]]; then
116
+ READLINE_LINE="$out"
117
+ READLINE_POINT=${#READLINE_LINE}
118
+ fi
119
+ }
120
+ bind -x '"\\ee": __ezshell_widget' 2>/dev/null || true
121
+ # ---------------------------------------------------------------------------
122
+ """
123
+
124
+ FISH_HOOK = """# ---------------------------------------------------------------------------
125
+ # ezShell Integration for Fish
126
+ # ---------------------------------------------------------------------------
127
+ set -gx EZSH_SESSION $fish_pid
128
+ if test -z "$EZSH_STATE_DIR"
129
+ if test -n "$XDG_RUNTIME_DIR"
130
+ set -gx EZSH_STATE_DIR "$XDG_RUNTIME_DIR/ezsh"
131
+ else
132
+ set -gx EZSH_STATE_DIR (string join '' (test -n "$TMPDIR"; and echo $TMPDIR; or echo /tmp) "/ezsh-"(id -u))
133
+ end
134
+ end
135
+ mkdir -p -m 700 "$EZSH_STATE_DIR" 2>/dev/null
136
+
137
+ function __ezshell_postexec --on-event fish_postexec
138
+ set -l exit_code $status
139
+ set -l last_cmd $argv[1]
140
+ switch $last_cmd
141
+ case 'ezsh suggest*' 'ezshell suggest*' 'clear' 'exit' 'history*'
142
+ return
143
+ end
144
+ printf "%s" "$last_cmd" > "$EZSH_STATE_DIR/$EZSH_SESSION.cmd" 2>/dev/null
145
+ printf "%d" "$exit_code" > "$EZSH_STATE_DIR/$EZSH_SESSION.code" 2>/dev/null
146
+ switch $last_cmd
147
+ case 'ezsh run *' 'ezshell run *'
148
+ case '*'
149
+ rm -f "$EZSH_STATE_DIR/$EZSH_SESSION.run.json" 2>/dev/null
150
+ end
151
+ end
152
+
153
+ function __ezshell_widget
154
+ set -l buf (commandline)
155
+ set -l out
156
+ if test -n "$buf"
157
+ set out (command ezsh suggest --ask="$buf" </dev/tty)
158
+ else
159
+ set out (command ezsh suggest </dev/tty)
160
+ end
161
+ if test $status -eq 0; and test -n "$out"
162
+ commandline -r -- $out
163
+ commandline -C (string length -- $out)
164
+ end
165
+ commandline -f repaint
166
+ end
167
+ bind \\ee __ezshell_widget 2>/dev/null
168
+ # ---------------------------------------------------------------------------
169
+ """
170
+
171
+
172
+ POWERSHELL_HOOK = r"""# ---------------------------------------------------------------------------
173
+ # ezShell Integration for PowerShell (5.1 et 7, avec PSReadLine)
174
+ # ---------------------------------------------------------------------------
175
+ $env:EZSH_SESSION = "$PID"
176
+ $env:EZSH_SHELL = "powershell"
177
+ # Même dossier que celui calculé par ezsh (src/core/capture.py:state_dir) ; pwsh marche aussi
178
+ # sous Linux et macOS.
179
+ if (-not $env:EZSH_STATE_DIR) {
180
+ if ($env:LOCALAPPDATA) { $env:EZSH_STATE_DIR = Join-Path $env:LOCALAPPDATA 'ezsh' }
181
+ elseif ($env:XDG_RUNTIME_DIR) { $env:EZSH_STATE_DIR = Join-Path $env:XDG_RUNTIME_DIR 'ezsh' }
182
+ else { $env:EZSH_STATE_DIR = Join-Path ([IO.Path]::GetTempPath()) "ezsh-$(id -u)" }
183
+ }
184
+ New-Item -ItemType Directory -Force -Path $env:EZSH_STATE_DIR -ErrorAction SilentlyContinue | Out-Null
185
+
186
+ # On enrobe le prompt existant (starship, oh-my-posh...) une seule fois, sans le remplacer.
187
+ if (-not $global:__ezshell_hooked) {
188
+ $global:__ezshell_hooked = $true
189
+ $global:__ezshell_orig_prompt = $function:prompt
190
+ $global:__ezshell_last_id = (Get-History -Count 1).Id
191
+ }
192
+
193
+ function global:prompt {
194
+ $ok = $?
195
+ $native = $global:LASTEXITCODE
196
+ $h = Get-History -Count 1
197
+ if ($h -and $h.Id -ne $global:__ezshell_last_id) {
198
+ $global:__ezshell_last_id = $h.Id
199
+ $line = $h.CommandLine
200
+ if ($line -notmatch '^\s*(ezsh|ezshell)(\.exe)?\s+suggest' -and $line -notmatch '^\s*(clear|cls|exit)\s*$') {
201
+ # $? dit si la commande a réussi. Si la dernière commande de la ligne est un programme
202
+ # natif, $LASTEXITCODE donne son vrai code ; sinon (cmdlet en échec) on note 1.
203
+ $code = 0
204
+ if (-not $ok) {
205
+ $code = 1
206
+ try {
207
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($line, [ref]$null, [ref]$null)
208
+ $cmds = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)
209
+ $name = if ($cmds) { @($cmds)[-1].GetCommandName() } else { $null }
210
+ $info = if ($name) { Get-Command $name -ErrorAction SilentlyContinue | Select-Object -First 1 } else { $null }
211
+ if ($info -and $info.CommandType -eq 'Application' -and $native) { $code = $native }
212
+ } catch { }
213
+ }
214
+ $dir = $env:EZSH_STATE_DIR
215
+ [IO.File]::WriteAllText((Join-Path $dir "$env:EZSH_SESSION.cmd"), $line)
216
+ [IO.File]::WriteAllText((Join-Path $dir "$env:EZSH_SESSION.code"), "$code")
217
+ if ($line -notmatch '^\s*(ezsh|ezshell)(\.exe)?\s+run\s') {
218
+ Remove-Item (Join-Path $dir "$env:EZSH_SESSION.run.json") -ErrorAction SilentlyContinue
219
+ }
220
+ }
221
+ }
222
+ $global:LASTEXITCODE = $native
223
+ & $global:__ezshell_orig_prompt
224
+ }
225
+
226
+ # Alt+E : le panneau s'affiche dans la console ; la commande choisie remplace la ligne (jamais exécutée).
227
+ # Si la ligne contient du texte, il est envoyé comme question en langage naturel.
228
+ if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { try {
229
+ Set-PSReadLineKeyHandler -Chord 'Alt+e' -BriefDescription 'ezShell' -Description 'ezShell : expliquer la derniere erreur' -ScriptBlock {
230
+ $line = $null; $cursor = $null
231
+ [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
232
+ $prevEncoding = [Console]::OutputEncoding
233
+ $env:PYTHONIOENCODING = 'utf-8'
234
+ $env:EZSH_ASK = $line
235
+ $env:EZSH_ALT_SCREEN = '1'
236
+ try {
237
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
238
+ $out = & ezsh suggest
239
+ $code = $LASTEXITCODE
240
+ } finally {
241
+ [Console]::OutputEncoding = $prevEncoding
242
+ Remove-Item Env:EZSH_ASK, Env:EZSH_ALT_SCREEN -ErrorAction SilentlyContinue
243
+ }
244
+ if ($code -eq 0 -and $out) {
245
+ [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()
246
+ [Microsoft.PowerShell.PSConsoleReadLine]::Insert((@($out) -join ' '))
247
+ }
248
+ [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt()
249
+ }
250
+ } catch { } } # session non interactive (CI, scripts) : pas de raccourci, le reste fonctionne
251
+ # ---------------------------------------------------------------------------
252
+ """
253
+
254
+ POWERSHELL_INIT_LINE = "Invoke-Expression (& ezsh init powershell | Out-String)"
255
+ _POWERSHELL_NAMES = ("powershell", "pwsh")
256
+
257
+
258
+ def detect_current_shell() -> str:
259
+ """Shell de l'utilisateur : zsh, bash, fish ou powershell (bash par défaut sous Unix)."""
260
+ if os.environ.get("EZSH_SHELL") in _POWERSHELL_NAMES:
261
+ return "powershell"
262
+ shell_env = os.environ.get("SHELL", "").lower()
263
+ if "zsh" in shell_env:
264
+ return "zsh"
265
+ if "fish" in shell_env:
266
+ return "fish"
267
+ if "bash" in shell_env:
268
+ return "bash"
269
+ if os.name == "nt":
270
+ return "powershell"
271
+
272
+ # Fallback to checking existing rc files
273
+ home = Path.home()
274
+ if (home / ".zshrc").exists():
275
+ return "zsh"
276
+ return "bash"
277
+
278
+
279
+ def get_hook_script(shell_name: str) -> str:
280
+ """Script d'intégration pour 'zsh', 'bash', 'fish' ou 'powershell' (alias 'pwsh')."""
281
+ s = shell_name.strip().lower()
282
+ if s == "zsh":
283
+ return ZSH_HOOK
284
+ if s == "fish":
285
+ return FISH_HOOK
286
+ if s in _POWERSHELL_NAMES:
287
+ return POWERSHELL_HOOK
288
+ return BASH_HOOK
289
+
290
+
291
+ def init_line(shell_name: str) -> str:
292
+ """Ligne à ajouter au fichier de configuration du shell."""
293
+ if shell_name in _POWERSHELL_NAMES:
294
+ return POWERSHELL_INIT_LINE
295
+ if shell_name == "fish":
296
+ return "ezsh init fish | source"
297
+ return f'eval "$(ezsh init {shell_name})"'
298
+
299
+
300
+ def reload_hint(shell_name: str, rc_file: Path) -> str:
301
+ """Commande pour recharger la configuration dans le terminal courant."""
302
+ if shell_name in _POWERSHELL_NAMES:
303
+ return ". $PROFILE"
304
+ return f"source {rc_file}"
305
+
306
+
307
+ def powershell_profiles() -> List[Path]:
308
+ """$PROFILE de chaque PowerShell installé (5.1 et 7 n'utilisent pas le même fichier).
309
+
310
+ On demande le chemin à PowerShell lui-même : le dossier Documents peut être redirigé (OneDrive).
311
+ """
312
+ paths: List[Path] = []
313
+ for exe in ("pwsh", "powershell"):
314
+ if not shutil.which(exe):
315
+ continue
316
+ try:
317
+ out = subprocess.run(
318
+ [exe, "-NoLogo", "-NoProfile", "-Command", "$PROFILE"],
319
+ capture_output=True,
320
+ text=True,
321
+ timeout=15,
322
+ ).stdout.strip()
323
+ except (OSError, subprocess.SubprocessError):
324
+ continue
325
+ if out and Path(out) not in paths:
326
+ paths.append(Path(out))
327
+ return paths
328
+
329
+
330
+ def _append_line(rc_file: Path, line: str) -> Tuple[bool, str]:
331
+ """Ajoute la ligne d'init si elle n'y est pas déjà (lignes commentées ignorées)."""
332
+ try:
333
+ if rc_file.is_file():
334
+ content = rc_file.read_text(encoding="utf-8", errors="ignore")
335
+ active = [
336
+ ln for ln in content.splitlines() if not ln.lstrip().startswith("#")
337
+ ]
338
+ if any("ezsh init" in ln or "ezshell init" in ln for ln in active):
339
+ return True, f"ezShell hook is already present in {rc_file}"
340
+ rc_file.parent.mkdir(parents=True, exist_ok=True)
341
+ with open(rc_file, "a", encoding="utf-8") as f:
342
+ f.write(f"\n# ezShell auto-diagnostic hook\n{line}\n")
343
+ return True, f"ezShell hook successfully added to {rc_file}!"
344
+ except OSError as e:
345
+ return False, f"Failed to write to {rc_file}: {e}"
346
+
347
+
348
+ def install_hook(shell_name: Optional[str] = None) -> Tuple[bool, str, Path]:
349
+ """Ajoute la ligne d'init au fichier de configuration du shell.
350
+
351
+ Returns:
352
+ Tuple[bool, str, Path]: (succès, message, fichier modifié)
353
+ """
354
+ target_shell = (shell_name or detect_current_shell()).lower()
355
+ home = Path.home()
356
+
357
+ if target_shell in _POWERSHELL_NAMES:
358
+ profiles = powershell_profiles()
359
+ if not profiles:
360
+ return False, "PowerShell not found (powershell or pwsh).", home
361
+ results = [_append_line(p, POWERSHELL_INIT_LINE) for p in profiles]
362
+ ok = all(r[0] for r in results)
363
+ msg = "\n".join(r[1] for r in results)
364
+ if ok:
365
+ msg += (
366
+ "\nIf PowerShell refuses to load your profile, allow local scripts with: "
367
+ "Set-ExecutionPolicy -Scope CurrentUser RemoteSigned"
368
+ )
369
+ return ok, msg, profiles[0]
370
+
371
+ if target_shell == "zsh":
372
+ rc_file = home / ".zshrc"
373
+ elif target_shell == "fish":
374
+ rc_file = home / ".config" / "fish" / "config.fish"
375
+ else:
376
+ rc_file = home / ".bashrc"
377
+ ok, msg = _append_line(rc_file, init_line(target_shell))
378
+ return ok, msg, rc_file
379
+
380
+
381
+ __all__ = [
382
+ "detect_current_shell",
383
+ "get_hook_script",
384
+ "init_line",
385
+ "install_hook",
386
+ "powershell_profiles",
387
+ "reload_hint",
388
+ ]
src/core/suggest.py ADDED
@@ -0,0 +1,94 @@
1
+ """Orchestration : contexte -> masquage -> modèle -> validation -> risque -> nettoyage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import locale
6
+ import os
7
+ import platform
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ from src.core.capture import read_last_run
12
+ from src.core.provider import AskRequest, LLMProvider
13
+ from src.core.redact import redact_secrets
14
+ from src.core.risk import assess, merge_risk
15
+ from src.core.sanitize import sanitize_command
16
+ from src.core.schema import AskResponse, InvalidResponse, parse_response
17
+
18
+
19
+ def detect_lang() -> str:
20
+ forced = os.environ.get("EZSHELL_LANG")
21
+ if forced:
22
+ return "fr" if forced.lower().startswith("fr") else "en"
23
+ lang = os.environ.get("LANG") or (locale.getlocale()[0] or "")
24
+ return "fr" if lang.lower().startswith("fr") else "en"
25
+
26
+
27
+ def detect_shell() -> str:
28
+ """Shell courant : EZSH_SHELL (posé par le hook), sinon $SHELL, sinon PowerShell."""
29
+ forced = os.environ.get("EZSH_SHELL")
30
+ if forced:
31
+ return forced
32
+ if os.environ.get("SHELL"):
33
+ return os.path.basename(os.environ["SHELL"])
34
+ if os.name == "nt":
35
+ return "powershell"
36
+ return "sh"
37
+
38
+
39
+ def build_request(query: str = "") -> AskRequest:
40
+ """Contexte minimal et masqué. `query` donné => traduction ; sinon explication d'erreur."""
41
+ last = read_last_run()
42
+ shell = detect_shell()
43
+ req = AskRequest(
44
+ mode="translate" if query else "explain_error",
45
+ query=redact_secrets(query),
46
+ os_name=platform.system().lower(),
47
+ shell=shell,
48
+ cwd=os.getcwd().replace(os.path.expanduser("~"), "~", 1),
49
+ lang=detect_lang(),
50
+ )
51
+ if last and not query:
52
+ req.last_command = redact_secrets(last.command)
53
+ req.exit_code = last.exit_code
54
+ req.stderr_tail = redact_secrets(last.stderr_tail)[-4096:]
55
+ return req
56
+
57
+
58
+ @dataclass
59
+ class Suggestion:
60
+ response: AskResponse
61
+ command: str # nettoyée
62
+ multiline: bool
63
+ risk: str # low | medium | high (après fusion règles/modèle)
64
+ risk_reason: str
65
+ risk_floor_applied: bool # True si une règle a relevé le risque annoncé par le modèle
66
+ no_output_captured: bool
67
+
68
+
69
+ def get_suggestion(req: AskRequest, provider: LLMProvider) -> Suggestion:
70
+ """Valide la réponse (un nouvel essai si invalide), puis applique le plancher de risque."""
71
+ last_err: Optional[Exception] = None
72
+ resp: Optional[AskResponse] = None
73
+ for _ in range(2):
74
+ raw = provider.ask(req)
75
+ try:
76
+ resp = parse_response(raw)
77
+ break
78
+ except InvalidResponse as exc:
79
+ last_err = exc
80
+ if resp is None:
81
+ raise InvalidResponse(str(last_err))
82
+
83
+ safe = sanitize_command(resp.command)
84
+ assessment = assess(safe.text) if safe.text else assess("")
85
+ final, reason = merge_risk(assessment, resp.risk, resp.risk_reason)
86
+ return Suggestion(
87
+ response=resp,
88
+ command=safe.text,
89
+ multiline=safe.multiline,
90
+ risk=final,
91
+ risk_reason=reason,
92
+ risk_floor_applied=(final != resp.risk),
93
+ no_output_captured=(req.mode == "explain_error" and not req.stderr_tail),
94
+ )