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/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
|
+
)
|