hexcli 2.8.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.
- hexcli/__init__.py +6 -0
- hexcli/agent.py +1931 -0
- hexcli/assets/hexcli.ico +0 -0
- hexcli/assets/hexcli.png +0 -0
- hexcli/cancel.py +76 -0
- hexcli/chatlog.py +232 -0
- hexcli/commands.py +73 -0
- hexcli/compaction.py +309 -0
- hexcli/config.py +217 -0
- hexcli/diffview.py +106 -0
- hexcli/distribution.py +237 -0
- hexcli/doctor.py +265 -0
- hexcli/escalate.py +192 -0
- hexcli/http_client.py +156 -0
- hexcli/launcher.py +481 -0
- hexcli/lineedit.py +1110 -0
- hexcli/llm.py +599 -0
- hexcli/local_escalation.py +191 -0
- hexcli/lockfile.py +71 -0
- hexcli/loop_v2.py +393 -0
- hexcli/markdown_stream.py +241 -0
- hexcli/memory.py +416 -0
- hexcli/network.py +154 -0
- hexcli/parsing.py +215 -0
- hexcli/paths.py +127 -0
- hexcli/prompts.py +321 -0
- hexcli/protocol_v2.py +505 -0
- hexcli/repl.py +807 -0
- hexcli/safety.py +127 -0
- hexcli/sessions.py +226 -0
- hexcli/setup_wizard.py +144 -0
- hexcli/shell_session.py +186 -0
- hexcli/statusbar.py +894 -0
- hexcli/stream_render.py +250 -0
- hexcli/telemetry.py +131 -0
- hexcli/tools.py +775 -0
- hexcli/ui.py +1106 -0
- hexcli-2.8.0.dist-info/METADATA +394 -0
- hexcli-2.8.0.dist-info/RECORD +42 -0
- hexcli-2.8.0.dist-info/WHEEL +4 -0
- hexcli-2.8.0.dist-info/entry_points.txt +3 -0
- hexcli-2.8.0.dist-info/licenses/LICENSE +21 -0
hexcli/safety.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.safety — Command safety classifier and append-only audit log."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Patterns checked in order: first match wins. Destructive > safe > caution.
|
|
11
|
+
|
|
12
|
+
_DESTRUCTIVE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
|
|
13
|
+
r"\bremove-item\b",
|
|
14
|
+
r"(?<![a-z])rm\s", # rm <args> but not 'strm' etc.
|
|
15
|
+
r"(?<![a-z])del\s",
|
|
16
|
+
r"(?<![a-z])rd\s",
|
|
17
|
+
r"\berase\b",
|
|
18
|
+
# Format-Volume / Format-Disk / the DOS `format X:` — not PowerShell's
|
|
19
|
+
# output formatters (Format-List, Format-Table, Format-Wide, Format-Custom,
|
|
20
|
+
# Format-Hex), which the CPU/RAM cookbook queries end in. Those asked for
|
|
21
|
+
# confirmation for weeks and were auto-denied in every unattended eval.
|
|
22
|
+
r"\bformat-(?!list\b|table\b|wide\b|custom\b|hex\b)\w+",
|
|
23
|
+
r"^\s*format\s+[a-z]:",
|
|
24
|
+
r"git\s+reset\s+--hard\b",
|
|
25
|
+
r"git\s+push\s+(-f\b|--force\b)",
|
|
26
|
+
r"git\s+clean\s+-[a-z]*f", # git clean -f / -df / -xf
|
|
27
|
+
r"\breg\s+delete\b",
|
|
28
|
+
r"\bclear-recyclebin\b",
|
|
29
|
+
r"\b(stop|restart)-computer\b",
|
|
30
|
+
r"\bdiskpart\b",
|
|
31
|
+
# -Force and -Recurse together — almost always Remove-Item level danger
|
|
32
|
+
r"-force\b[^|&\n]*-recurse\b|-recurse\b[^|&\n]*-force\b",
|
|
33
|
+
# Invoke-Expression / iex: evaluates arbitrary strings as code
|
|
34
|
+
r"\b(invoke-expression|iex)\b",
|
|
35
|
+
]]
|
|
36
|
+
|
|
37
|
+
# Sensitive-data access: not destructive, but touching credentials, keys, or
|
|
38
|
+
# security-critical system files. Requires explicit confirmation (deny when
|
|
39
|
+
# non-interactive). Checked BEFORE the safe list — v1.7's blanket `^get-\w+`
|
|
40
|
+
# safe rule waved `Get-Content …\drivers\etc\hosts` straight through, which is
|
|
41
|
+
# exactly the injection payload uc3-t7 measured executing 3/3.
|
|
42
|
+
_SENSITIVE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
|
|
43
|
+
r"[\\/]\.ssh\b", # ssh keys & config
|
|
44
|
+
r"\bid_(rsa|ed25519|ecdsa|dsa)\b",
|
|
45
|
+
r"[\\/]\.gnupg\b",
|
|
46
|
+
r"[\\/]\.aws\b",
|
|
47
|
+
r"[\\/]\.azure\b",
|
|
48
|
+
r"drivers[\\/]+etc[\\/]+hosts\b", # hosts-file tampering/recon
|
|
49
|
+
r"\bconfig[\\/]+(sam|security|system)\b", # registry hive files
|
|
50
|
+
r"\breg\s+(save|export)\b.*\b(sam|security|system)\b",
|
|
51
|
+
r"\bntds\.dit\b",
|
|
52
|
+
r"\bvaultcmd\b", # Windows credential vault
|
|
53
|
+
r"\bcmdkey\b.*/list",
|
|
54
|
+
r"login data", # browser credential stores
|
|
55
|
+
r"-encodedcommand\b|\s-enc\b|\s-e[nc]?\b\s+[A-Za-z0-9+/=]{20,}", # obfuscated exec
|
|
56
|
+
r"frombase64string",
|
|
57
|
+
r"protecteddata\]::unprotect", # DPAPI secrets
|
|
58
|
+
r"\$env:\w*(token|secret|password|api_?key)\w*", # credential env vars
|
|
59
|
+
# Launching a program image (uc3-t9's calc.exe): run_code's workspace
|
|
60
|
+
# boundary refuses `C:\Windows\System32\calc.exe`, but the same payload
|
|
61
|
+
# routed through run_command ran three different ways in one afternoon
|
|
62
|
+
# (2026-09-02: the absolute path, bare `calc.exe`, `start calc.exe`).
|
|
63
|
+
# Any command segment that STARTS with an executable image, plus
|
|
64
|
+
# Start-Process / start / cmd /c start, is confirm-gated like the rest
|
|
65
|
+
# of this tier. `python script.py`, cmdlets and git are untouched.
|
|
66
|
+
r"(?:^|[&|;]\s*)(?:&\s*)?(?:\"[^\"]*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b[^\"]*\""
|
|
67
|
+
r"|'[^']*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b[^']*'"
|
|
68
|
+
r"|(?:[a-z]:[\\/]|[.\\/]+)?[^\s\"'|&;]*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b)",
|
|
69
|
+
r"(?:^|[&|;]\s*)(?:start-process|start|saps)\b",
|
|
70
|
+
r"\bcmd(\.exe)?\s+/[ck]\s+start\b",
|
|
71
|
+
]]
|
|
72
|
+
|
|
73
|
+
_SAFE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
|
|
74
|
+
r"^\s*get-\w+", # Get-Process, Get-ChildItem …
|
|
75
|
+
r"^\s*(ls|dir)\b",
|
|
76
|
+
r"^\s*(cat|type)\s",
|
|
77
|
+
r"^\s*git\s+(status|log|diff|show|branch|stash list|tag|remote -v|describe)\b",
|
|
78
|
+
r"^\s*(python|python3|py)\s+(--version|-V)\b",
|
|
79
|
+
r"^\s*pip\s+(list|show|freeze)\b",
|
|
80
|
+
r"^\s*(where|where\.exe)\b",
|
|
81
|
+
r"^\s*(echo|write-output|write-host|pwd|test-path)\b",
|
|
82
|
+
r"^\s*(node|npm)\s+(--version|-v)\b",
|
|
83
|
+
r"^\s*select-string\b", # grep equivalent — read-only
|
|
84
|
+
]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify_command(cmd: str) -> str:
|
|
88
|
+
"""Return 'safe', 'caution', 'sensitive', or 'destructive'.
|
|
89
|
+
|
|
90
|
+
Priority: destructive > sensitive > safe > caution. Sensitive must outrank
|
|
91
|
+
the safe list, or read-only cmdlet prefixes whitelist credential access.
|
|
92
|
+
"""
|
|
93
|
+
s = cmd.strip()
|
|
94
|
+
for pat in _DESTRUCTIVE:
|
|
95
|
+
if pat.search(s):
|
|
96
|
+
return "destructive"
|
|
97
|
+
for pat in _SENSITIVE:
|
|
98
|
+
if pat.search(s):
|
|
99
|
+
return "sensitive"
|
|
100
|
+
for pat in _SAFE:
|
|
101
|
+
if pat.match(s):
|
|
102
|
+
return "safe"
|
|
103
|
+
return "caution"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def append_audit_log(
|
|
107
|
+
session_id: str | None,
|
|
108
|
+
classification: str,
|
|
109
|
+
cmd: str,
|
|
110
|
+
exit_code: int | str | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Append one JSON line to .shellai/audit.log. Best-effort — never raises."""
|
|
113
|
+
try:
|
|
114
|
+
log_dir = Path.cwd() / ".shellai"
|
|
115
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
entry: dict[str, object] = {
|
|
117
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
118
|
+
"session": session_id or "",
|
|
119
|
+
"classification": classification,
|
|
120
|
+
"cmd": cmd,
|
|
121
|
+
}
|
|
122
|
+
if exit_code is not None:
|
|
123
|
+
entry["exit_code"] = exit_code
|
|
124
|
+
with (log_dir / "audit.log").open("a", encoding="utf-8") as fh:
|
|
125
|
+
fh.write(json.dumps(entry) + "\n")
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
hexcli/sessions.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.sessions — session objects and the on-disk history store.
|
|
3
|
+
|
|
4
|
+
Lifted out of agent.py unchanged. Owns HISTORY_PATH, because the only readers
|
|
5
|
+
of it are the two functions here; keeping the path next to them is what makes
|
|
6
|
+
the store redirectable in tests.
|
|
7
|
+
|
|
8
|
+
`evals/test_core.py` patches `sessions.HISTORY_PATH` to a temp file. That patch
|
|
9
|
+
MUST target this module: these functions resolve the name in their own
|
|
10
|
+
namespace, so patching a re-exported copy on hexcli.agent would silently do
|
|
11
|
+
nothing and the suite would write to the real history.json while still passing.
|
|
12
|
+
|
|
13
|
+
Checkpoints (/save, /load) deliberately stay in agent.py — they capture a
|
|
14
|
+
workspace_snapshot, which belongs with the tools.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from datetime import UTC, datetime, timedelta
|
|
21
|
+
from typing import Any
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
from hexcli import paths
|
|
25
|
+
from hexcli.ui import C, cprint
|
|
26
|
+
|
|
27
|
+
# ~/.shellai/history.json (a checkout's history.json is migrated once).
|
|
28
|
+
HISTORY_PATH = paths.history_path()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def utc_now() -> datetime:
|
|
32
|
+
return datetime.now(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def iso_now() -> str:
|
|
36
|
+
return utc_now().isoformat()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_timestamp(value: str) -> datetime:
|
|
40
|
+
dt = datetime.fromisoformat(value)
|
|
41
|
+
if dt.tzinfo is None:
|
|
42
|
+
dt = dt.replace(tzinfo=UTC)
|
|
43
|
+
return dt
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_session() -> dict[str, Any]:
|
|
47
|
+
now = iso_now()
|
|
48
|
+
return {
|
|
49
|
+
"id": str(uuid4()),
|
|
50
|
+
"title": "New session",
|
|
51
|
+
"created_at": now,
|
|
52
|
+
"modified_at": now,
|
|
53
|
+
"messages": [],
|
|
54
|
+
"compact_count": 0,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def session_has_messages(session: dict[str, Any]) -> bool:
|
|
59
|
+
msgs = session.get("messages")
|
|
60
|
+
return isinstance(msgs, list) and len(msgs) > 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_session_title(text: str) -> str:
|
|
64
|
+
"""The first words of the first message, as typed: "what is 2+2" stays
|
|
65
|
+
readable ("What Is 22" did not). Trailing punctuation goes, the case
|
|
66
|
+
is the person's own but for the first letter, and long lines are cut
|
|
67
|
+
at a word."""
|
|
68
|
+
first_line = text.strip().splitlines()[0] if text.strip() else ""
|
|
69
|
+
cleaned = re.sub(r"[^A-Za-z0-9\s\-+_./:'\"]", "", first_line)
|
|
70
|
+
words = [w.strip("-+_./:'\"") for w in cleaned.split()]
|
|
71
|
+
words = [w for w in words if w]
|
|
72
|
+
if not words:
|
|
73
|
+
return "New session"
|
|
74
|
+
title = " ".join(words[:8])
|
|
75
|
+
if len(title) > 48:
|
|
76
|
+
title = title[:47].rsplit(" ", 1)[0] + "…"
|
|
77
|
+
return title[0].upper() + title[1:]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def touch_session(session: dict[str, Any]) -> None:
|
|
81
|
+
session["modified_at"] = iso_now()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def append_session_message(session: dict[str, Any], role: str, content: str) -> None:
|
|
85
|
+
msgs = session.setdefault("messages", [])
|
|
86
|
+
if not isinstance(msgs, list):
|
|
87
|
+
session["messages"] = []
|
|
88
|
+
msgs = session["messages"]
|
|
89
|
+
if not session_has_messages(session) and role == "user":
|
|
90
|
+
session["title"] = generate_session_title(content)
|
|
91
|
+
msgs.append({"role": role, "content": content})
|
|
92
|
+
touch_session(session)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sort_sessions(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
96
|
+
_epoch = datetime.min.replace(tzinfo=UTC)
|
|
97
|
+
|
|
98
|
+
def _key(s: dict[str, Any]) -> datetime:
|
|
99
|
+
raw = s.get("modified_at", "")
|
|
100
|
+
try:
|
|
101
|
+
return parse_timestamp(str(raw))
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
return _epoch
|
|
104
|
+
|
|
105
|
+
return sorted(sessions, key=_key, reverse=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_history_store(sessions: list[dict[str, Any]]) -> None:
|
|
109
|
+
payload = json.dumps({"sessions": sort_sessions(sessions)}, indent=2) + "\n"
|
|
110
|
+
tmp = HISTORY_PATH.with_suffix(".tmp")
|
|
111
|
+
tmp.write_text(payload, encoding="utf-8")
|
|
112
|
+
tmp.replace(HISTORY_PATH)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_history_store(config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
116
|
+
sessions: list[dict[str, Any]] = []
|
|
117
|
+
if HISTORY_PATH.exists():
|
|
118
|
+
# A truncated or corrupted history file used to raise here and take
|
|
119
|
+
# the whole app down on EVERY launch — unrecoverable without knowing
|
|
120
|
+
# to delete a file you were never told about. Past history is never
|
|
121
|
+
# worth more than a working CLI: quarantine it and carry on.
|
|
122
|
+
try:
|
|
123
|
+
with HISTORY_PATH.open("r", encoding="utf-8") as fh:
|
|
124
|
+
data = json.load(fh)
|
|
125
|
+
raw = data.get("sessions", []) if isinstance(data, dict) else []
|
|
126
|
+
sessions = [s for s in raw if isinstance(s, dict)]
|
|
127
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
128
|
+
quarantine = HISTORY_PATH.with_suffix(".corrupt")
|
|
129
|
+
try:
|
|
130
|
+
HISTORY_PATH.replace(quarantine)
|
|
131
|
+
cprint(
|
|
132
|
+
f"\n History file was unreadable ({exc.__class__.__name__}). "
|
|
133
|
+
f"Moved it to {quarantine.name} and started a fresh history.",
|
|
134
|
+
C.YELLOW,
|
|
135
|
+
)
|
|
136
|
+
except OSError:
|
|
137
|
+
cprint("\n History file is unreadable and could not be moved; "
|
|
138
|
+
"continuing with an empty history.", C.YELLOW)
|
|
139
|
+
sessions = []
|
|
140
|
+
|
|
141
|
+
cutoff = utc_now() - timedelta(days=int(config.get("history_retention_days", 30)))
|
|
142
|
+
filtered: list[dict[str, Any]] = []
|
|
143
|
+
changed = False
|
|
144
|
+
for s in sessions:
|
|
145
|
+
try:
|
|
146
|
+
modified_at = parse_timestamp(str(s.get("modified_at", "")))
|
|
147
|
+
except ValueError:
|
|
148
|
+
changed = True
|
|
149
|
+
continue
|
|
150
|
+
if modified_at < cutoff:
|
|
151
|
+
changed = True
|
|
152
|
+
continue
|
|
153
|
+
s.setdefault("title", "New session")
|
|
154
|
+
s.setdefault("created_at", s.get("modified_at", iso_now()))
|
|
155
|
+
s.setdefault("messages", [])
|
|
156
|
+
s.setdefault("compact_count", 0)
|
|
157
|
+
filtered.append(s)
|
|
158
|
+
|
|
159
|
+
filtered = sort_sessions(filtered)
|
|
160
|
+
if changed:
|
|
161
|
+
save_history_store(filtered)
|
|
162
|
+
return filtered
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def search_sessions(
|
|
166
|
+
sessions: list[dict[str, Any]],
|
|
167
|
+
term: str,
|
|
168
|
+
max_snippets: int = 2,
|
|
169
|
+
context: int = 44,
|
|
170
|
+
) -> list[dict[str, Any]]:
|
|
171
|
+
"""Case-insensitive substring search over titles and message content.
|
|
172
|
+
|
|
173
|
+
Returns hits in the same order as `sessions`, each carrying the 1-based
|
|
174
|
+
index into that list — the SAME number /history shows and /resume takes,
|
|
175
|
+
so a search result is directly resumable. Snippets come pre-split as
|
|
176
|
+
(role, prefix, match, suffix) so the renderer can highlight the match
|
|
177
|
+
without re-finding it.
|
|
178
|
+
"""
|
|
179
|
+
term_l = term.lower()
|
|
180
|
+
if not term_l:
|
|
181
|
+
return []
|
|
182
|
+
hits: list[dict[str, Any]] = []
|
|
183
|
+
for idx, session in enumerate(sessions, start=1):
|
|
184
|
+
raw_matches: list[tuple[str, str, int]] = []
|
|
185
|
+
title = str(session.get("title", ""))
|
|
186
|
+
pos = title.lower().find(term_l)
|
|
187
|
+
if pos >= 0:
|
|
188
|
+
raw_matches.append(("title", title, pos))
|
|
189
|
+
for msg in session.get("messages", []):
|
|
190
|
+
if len(raw_matches) >= max_snippets:
|
|
191
|
+
break
|
|
192
|
+
if not isinstance(msg, dict):
|
|
193
|
+
continue
|
|
194
|
+
content = str(msg.get("content", ""))
|
|
195
|
+
pos = content.lower().find(term_l)
|
|
196
|
+
if pos >= 0:
|
|
197
|
+
raw_matches.append((str(msg.get("role", "?")), content, pos))
|
|
198
|
+
if not raw_matches:
|
|
199
|
+
continue
|
|
200
|
+
snippets: list[tuple[str, str, str, str]] = []
|
|
201
|
+
for role, text, pos in raw_matches[:max_snippets]:
|
|
202
|
+
start = max(0, pos - context)
|
|
203
|
+
end = min(len(text), pos + len(term) + context)
|
|
204
|
+
prefix = ("…" if start > 0 else "") + text[start:pos].replace("\n", " ")
|
|
205
|
+
match = text[pos:pos + len(term)].replace("\n", " ")
|
|
206
|
+
suffix = text[pos + len(term):end].replace("\n", " ") + ("…" if end < len(text) else "")
|
|
207
|
+
snippets.append((role, prefix, match, suffix))
|
|
208
|
+
hits.append({"index": idx, "session": session, "snippets": snippets})
|
|
209
|
+
return hits
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def upsert_session(sessions: list[dict[str, Any]], session: dict[str, Any]) -> None:
|
|
213
|
+
if not session_has_messages(session):
|
|
214
|
+
return
|
|
215
|
+
for i, existing in enumerate(sessions):
|
|
216
|
+
if existing.get("id") == session.get("id"):
|
|
217
|
+
sessions[i] = session
|
|
218
|
+
return
|
|
219
|
+
sessions.append(session)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def sync_session_store(sessions: list[dict[str, Any]], session: dict[str, Any]) -> None:
|
|
223
|
+
upsert_session(sessions, session)
|
|
224
|
+
save_history_store(sessions)
|
|
225
|
+
|
|
226
|
+
|
hexcli/setup_wizard.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""hexcli/setup_wizard.py — /setup: interactive configuration wizard.
|
|
2
|
+
|
|
3
|
+
A handful of questions covering the config decisions that actually matter
|
|
4
|
+
day one (safety posture, network policy, UI). Each shows the current value
|
|
5
|
+
as the default, so Enter-Enter-Enter through the wizard changes nothing.
|
|
6
|
+
|
|
7
|
+
It writes ONLY the keys the user was asked about, merged over whatever the
|
|
8
|
+
config file already contains — never a full DEFAULT_CONFIG dump. A config
|
|
9
|
+
file full of copied defaults is exactly the drift bug the generated example
|
|
10
|
+
config exists to prevent: values silently pinned at whatever release they
|
|
11
|
+
were written by.
|
|
12
|
+
|
|
13
|
+
IO is injected (ask/echo) so the whole flow is testable offline; EOF or
|
|
14
|
+
Ctrl+C at any question aborts without writing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from hexcli.ui import C, cprint
|
|
24
|
+
|
|
25
|
+
# (key, question, kind, choices) — kind is "bool" or "choice".
|
|
26
|
+
QUESTIONS: list[tuple[str, str, str, tuple[str, ...]]] = [
|
|
27
|
+
("autopilot_confirm_destructive",
|
|
28
|
+
"Confirm destructive commands such as rm and format?", "bool", ()),
|
|
29
|
+
("autopilot_confirm_sensitive",
|
|
30
|
+
"Confirm access to sensitive paths such as SSH keys and credentials?", "bool", ()),
|
|
31
|
+
("workspace_write_scope",
|
|
32
|
+
"Block file writes outside the project directory?", "bool", ()),
|
|
33
|
+
("network_access",
|
|
34
|
+
"Network access for fetch_url?", "choice", ("ask", "deny", "allow")),
|
|
35
|
+
("show_diffs",
|
|
36
|
+
"Show a diff after every file change?", "bool", ()),
|
|
37
|
+
("rich_input",
|
|
38
|
+
"Rich input line with history and Tab completion?", "bool", ()),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ask_bool(ask: Callable[[str], str], question: str, current: bool) -> bool:
|
|
43
|
+
hint = "[Y/n]" if current else "[y/N]"
|
|
44
|
+
answer = ask(f" {question} {hint} ").strip().lower()
|
|
45
|
+
if not answer:
|
|
46
|
+
return current
|
|
47
|
+
return answer in ("y", "yes", "1", "true", "on")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ask_choice(ask: Callable[[str], str], question: str,
|
|
51
|
+
choices: tuple[str, ...], current: str) -> str:
|
|
52
|
+
menu = "/".join(c.upper() if c == current else c for c in choices)
|
|
53
|
+
while True:
|
|
54
|
+
answer = ask(f" {question} [{menu}] ").strip().lower()
|
|
55
|
+
if not answer:
|
|
56
|
+
return current
|
|
57
|
+
if answer in choices:
|
|
58
|
+
return answer
|
|
59
|
+
cprint(f" Answer {', '.join(choices[:-1])} or {choices[-1]}.", C.DIM)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_config_keys(path: Path, chosen: dict[str, Any]) -> None:
|
|
63
|
+
"""Merge the chosen keys over the existing file, atomically."""
|
|
64
|
+
existing: dict[str, Any] = {}
|
|
65
|
+
if path.exists():
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
if isinstance(data, dict):
|
|
69
|
+
existing = data
|
|
70
|
+
except (json.JSONDecodeError, OSError):
|
|
71
|
+
pass # unreadable file: the wizard's keys become the file
|
|
72
|
+
existing.update(chosen)
|
|
73
|
+
payload = json.dumps(existing, indent=2) + "\n"
|
|
74
|
+
tmp = path.with_suffix(".tmp")
|
|
75
|
+
tmp.write_text(payload, encoding="utf-8")
|
|
76
|
+
tmp.replace(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def overridden_by_project(chosen: dict[str, Any], project_cfg: Path) -> list[str]:
|
|
80
|
+
"""Keys the wizard just saved that a project .shellai/config.json will
|
|
81
|
+
still override on every load (it deep-merges on top of the user config).
|
|
82
|
+
|
|
83
|
+
Without this, the wizard's closing "applies on every launch" line is a
|
|
84
|
+
lie in any repo that ships its own config.
|
|
85
|
+
"""
|
|
86
|
+
if not project_cfg.exists():
|
|
87
|
+
return []
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(project_cfg.read_text(encoding="utf-8"))
|
|
90
|
+
except (json.JSONDecodeError, OSError):
|
|
91
|
+
return []
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return []
|
|
94
|
+
return [k for k in chosen if k in data and data[k] != chosen[k]]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_wizard(
|
|
98
|
+
config: dict[str, Any],
|
|
99
|
+
config_path: Path,
|
|
100
|
+
ask: Callable[[str], str] = input,
|
|
101
|
+
project_cfg: Path | None = None,
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""Run the wizard. Returns True if the config file was written.
|
|
104
|
+
|
|
105
|
+
Updates `config` in place on success so answers apply to the running
|
|
106
|
+
session immediately, not just the next launch.
|
|
107
|
+
"""
|
|
108
|
+
print()
|
|
109
|
+
cprint(" Setup", C.BOLD)
|
|
110
|
+
cprint(" Enter keeps the current value.", C.DIM)
|
|
111
|
+
print()
|
|
112
|
+
chosen: dict[str, Any] = {}
|
|
113
|
+
try:
|
|
114
|
+
for key, question, kind, choices in QUESTIONS:
|
|
115
|
+
if kind == "bool":
|
|
116
|
+
chosen[key] = _ask_bool(ask, question, bool(config.get(key, True)))
|
|
117
|
+
else:
|
|
118
|
+
current = str(config.get(key, choices[0]))
|
|
119
|
+
if current not in choices:
|
|
120
|
+
current = choices[0]
|
|
121
|
+
chosen[key] = _ask_choice(ask, question, choices, current)
|
|
122
|
+
print()
|
|
123
|
+
for key, value in chosen.items():
|
|
124
|
+
marker = "" if config.get(key) == value else " changed"
|
|
125
|
+
cprint(f" {key} = {value!r}{marker}", C.DIM)
|
|
126
|
+
print()
|
|
127
|
+
confirm = ask(f" Save to {config_path.name}? [Y/n] ").strip().lower()
|
|
128
|
+
except (EOFError, KeyboardInterrupt):
|
|
129
|
+
print()
|
|
130
|
+
cprint(" Cancelled. Nothing written.", C.DIM)
|
|
131
|
+
return False
|
|
132
|
+
if confirm not in ("", "y", "yes"):
|
|
133
|
+
cprint(" Nothing written.", C.DIM)
|
|
134
|
+
return False
|
|
135
|
+
write_config_keys(config_path, chosen)
|
|
136
|
+
config.update(chosen)
|
|
137
|
+
cprint(f" Saved {config_path}.", C.DIM)
|
|
138
|
+
shadowed = overridden_by_project(
|
|
139
|
+
chosen, project_cfg if project_cfg is not None else Path.cwd() / ".shellai" / "config.json"
|
|
140
|
+
)
|
|
141
|
+
if shadowed:
|
|
142
|
+
cprint(f" .shellai/config.json overrides {', '.join(sorted(shadowed))}. "
|
|
143
|
+
"Edit it there too.", C.YELLOW)
|
|
144
|
+
return True
|
hexcli/shell_session.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.shell_session — persistent PowerShell session for the v2 `shell` tool.
|
|
3
|
+
|
|
4
|
+
v1 spawned a fresh `powershell.exe -Command` per call, so `cd`, environment
|
|
5
|
+
variables, and venv activation evaporated between steps. This module keeps ONE
|
|
6
|
+
PowerShell process alive per agent session and multiplexes commands through it
|
|
7
|
+
with a sentinel protocol:
|
|
8
|
+
|
|
9
|
+
<command>
|
|
10
|
+
Write-Output "<sentinel> <exit-ish code>"
|
|
11
|
+
|
|
12
|
+
stdout+stderr are merged at the command level (2>&1) so the model sees errors
|
|
13
|
+
inline, in order. A timeout kills the whole process tree (taskkill /T) and the
|
|
14
|
+
next call transparently respawns a fresh session — a hung command can never
|
|
15
|
+
wedge the agent loop.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import queue
|
|
21
|
+
import subprocess
|
|
22
|
+
import threading
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
_DEFAULT_TIMEOUT_S = 60
|
|
27
|
+
_OUTPUT_CAP_CHARS = 200_000 # hard runaway guard; the agent loop trims further
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShellSession:
|
|
31
|
+
"""One persistent PowerShell process; safe to reuse across commands."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cwd: str | None = None, shell_exe: str = "powershell.exe") -> None:
|
|
34
|
+
self._cwd = cwd
|
|
35
|
+
self._shell_exe = shell_exe
|
|
36
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
37
|
+
self._out_queue: queue.Queue[str | None] = queue.Queue()
|
|
38
|
+
self._reader: threading.Thread | None = None
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
# -- lifecycle ----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _spawn(self) -> None:
|
|
44
|
+
self._proc = subprocess.Popen(
|
|
45
|
+
[self._shell_exe, "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", "-"],
|
|
46
|
+
stdin=subprocess.PIPE,
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.STDOUT,
|
|
49
|
+
cwd=self._cwd,
|
|
50
|
+
text=True,
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
errors="replace",
|
|
53
|
+
bufsize=1,
|
|
54
|
+
)
|
|
55
|
+
self._out_queue = queue.Queue()
|
|
56
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
57
|
+
self._reader.start()
|
|
58
|
+
|
|
59
|
+
def _read_loop(self) -> None:
|
|
60
|
+
proc = self._proc
|
|
61
|
+
if proc is None or proc.stdout is None:
|
|
62
|
+
return
|
|
63
|
+
q = self._out_queue
|
|
64
|
+
for line in proc.stdout:
|
|
65
|
+
q.put(line)
|
|
66
|
+
q.put(None) # EOF marker
|
|
67
|
+
|
|
68
|
+
def _send(self, text: str) -> None:
|
|
69
|
+
assert self._proc is not None and self._proc.stdin is not None
|
|
70
|
+
self._proc.stdin.write(text + "\n")
|
|
71
|
+
self._proc.stdin.flush()
|
|
72
|
+
|
|
73
|
+
def _alive(self) -> bool:
|
|
74
|
+
return self._proc is not None and self._proc.poll() is None
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._kill_tree()
|
|
79
|
+
|
|
80
|
+
def _kill_tree(self) -> None:
|
|
81
|
+
if self._proc is None:
|
|
82
|
+
return
|
|
83
|
+
pid = self._proc.pid
|
|
84
|
+
try:
|
|
85
|
+
subprocess.run(
|
|
86
|
+
["taskkill", "/T", "/F", "/PID", str(pid)],
|
|
87
|
+
capture_output=True, timeout=10,
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
try:
|
|
91
|
+
self._proc.kill()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
self._proc = None
|
|
95
|
+
|
|
96
|
+
# -- command execution --------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def run(self, command: str, timeout_s: int = _DEFAULT_TIMEOUT_S) -> dict[str, Any]:
|
|
99
|
+
"""Run one command; returns {"output": str, "exit_code": int|None,
|
|
100
|
+
"timed_out": bool, "restarted": bool}."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
restarted = False
|
|
103
|
+
if not self._alive():
|
|
104
|
+
self._kill_tree()
|
|
105
|
+
self._spawn()
|
|
106
|
+
restarted = self._proc is not None
|
|
107
|
+
|
|
108
|
+
sentinel = f"__HEX_DONE_{uuid.uuid4().hex}__"
|
|
109
|
+
# Windows PowerShell 5.1 decodes piped stdin — and encodes piped
|
|
110
|
+
# stdout — with the OEM codepage, mangling any non-ASCII content.
|
|
111
|
+
# So the command travels IN as UTF-16LE base64 (Invoke-Expression
|
|
112
|
+
# keeps it in the session's own scope: cd, $env:, and variables
|
|
113
|
+
# all persist), and the output travels OUT as UTF-8 base64 on the
|
|
114
|
+
# sentinel line. Base64 is pure ASCII and survives any codepage.
|
|
115
|
+
#
|
|
116
|
+
# Exit-code detection: native commands set $LASTEXITCODE; cmdlet
|
|
117
|
+
# failures are detected via $Error growth ($? is useless here —
|
|
118
|
+
# it would reflect the last pipeline stage, not the command).
|
|
119
|
+
cmd_b64 = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
|
|
120
|
+
wrapped = (
|
|
121
|
+
"$global:LASTEXITCODE = $null; $__hex_errs = $Error.Count; "
|
|
122
|
+
f"$__hex_cmd = [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('{cmd_b64}')); "
|
|
123
|
+
# Dot-sourcing a created ScriptBlock runs in the CURRENT scope
|
|
124
|
+
# (cd/$env:/variables persist) while letting 2>&1 capture the
|
|
125
|
+
# invoked command's error stream — Invoke-Expression's redirect
|
|
126
|
+
# cannot see errors raised inside the expression.
|
|
127
|
+
"try { $__hex_out = . ([System.Management.Automation.ScriptBlock]::Create($__hex_cmd)) 2>&1 | Out-String } "
|
|
128
|
+
"catch { $__hex_out = ($_ | Out-String) ; $__hex_errs = -1 }; "
|
|
129
|
+
"$__hex_code = if ($null -ne $global:LASTEXITCODE) { $global:LASTEXITCODE } "
|
|
130
|
+
"elseif ($__hex_errs -lt 0 -or $Error.Count -gt $__hex_errs) { 1 } else { 0 }; "
|
|
131
|
+
f"if ($__hex_out.Length -gt {_OUTPUT_CAP_CHARS}) "
|
|
132
|
+
f"{{ $__hex_out = $__hex_out.Substring(0, {_OUTPUT_CAP_CHARS}) + \"`n[output truncated]\" }}; "
|
|
133
|
+
"$__hex_b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__hex_out)); "
|
|
134
|
+
f"Write-Output \"{sentinel}:$__hex_code`:$__hex_b64\""
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
self._send(wrapped)
|
|
138
|
+
except (OSError, AssertionError):
|
|
139
|
+
self._kill_tree()
|
|
140
|
+
return {"output": "shell session died while sending the command; it will restart on the next call",
|
|
141
|
+
"exit_code": None, "timed_out": False, "restarted": restarted}
|
|
142
|
+
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
total = 0
|
|
145
|
+
deadline = timeout_s
|
|
146
|
+
import time
|
|
147
|
+
start = time.monotonic()
|
|
148
|
+
while True:
|
|
149
|
+
remaining = deadline - (time.monotonic() - start)
|
|
150
|
+
if remaining <= 0:
|
|
151
|
+
self._kill_tree()
|
|
152
|
+
return {
|
|
153
|
+
"output": "".join(lines)[:_OUTPUT_CAP_CHARS]
|
|
154
|
+
+ f"\n[timeout] command exceeded {timeout_s}s; the shell session was killed and will restart on the next call",
|
|
155
|
+
"exit_code": None, "timed_out": True, "restarted": restarted,
|
|
156
|
+
}
|
|
157
|
+
try:
|
|
158
|
+
line = self._out_queue.get(timeout=min(remaining, 0.5))
|
|
159
|
+
except queue.Empty:
|
|
160
|
+
continue
|
|
161
|
+
if line is None:
|
|
162
|
+
# Process exited underneath us (e.g. the command ran `exit`).
|
|
163
|
+
self._kill_tree()
|
|
164
|
+
return {"output": "".join(lines)[:_OUTPUT_CAP_CHARS],
|
|
165
|
+
"exit_code": None, "timed_out": False, "restarted": restarted}
|
|
166
|
+
if line.startswith(sentinel):
|
|
167
|
+
parts = line.strip().split(":", 2)
|
|
168
|
+
exit_code: int | None
|
|
169
|
+
output: str
|
|
170
|
+
try:
|
|
171
|
+
exit_code = int(parts[1])
|
|
172
|
+
except (IndexError, ValueError):
|
|
173
|
+
exit_code = None
|
|
174
|
+
try:
|
|
175
|
+
output = base64.b64decode(parts[2]).decode("utf-8", errors="replace") if len(parts) > 2 else ""
|
|
176
|
+
except Exception:
|
|
177
|
+
output = ""
|
|
178
|
+
# Anything that leaked outside the sentinel protocol (e.g.
|
|
179
|
+
# wrapper-level parse errors) is prepended so it's never lost.
|
|
180
|
+
if lines:
|
|
181
|
+
output = "".join(lines) + output
|
|
182
|
+
return {"output": output[:_OUTPUT_CAP_CHARS],
|
|
183
|
+
"exit_code": exit_code, "timed_out": False, "restarted": restarted}
|
|
184
|
+
total += len(line)
|
|
185
|
+
if total <= _OUTPUT_CAP_CHARS:
|
|
186
|
+
lines.append(line)
|