cli-guru 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.
- cli_guru/__init__.py +3 -0
- cli_guru/backend.py +127 -0
- cli_guru/cli.py +288 -0
- cli_guru/config.py +152 -0
- cli_guru/context.py +244 -0
- cli_guru/danger.py +174 -0
- cli_guru/install.py +115 -0
- cli_guru/manpage.py +181 -0
- cli_guru/prompts.py +63 -0
- cli_guru/sanitise.py +132 -0
- cli_guru/shell/cli-guru.bash +78 -0
- cli_guru/shell/cli-guru.ps1 +32 -0
- cli_guru/shell/cli-guru.zsh +67 -0
- cli_guru-0.2.0.dist-info/METADATA +716 -0
- cli_guru-0.2.0.dist-info/RECORD +19 -0
- cli_guru-0.2.0.dist-info/WHEEL +5 -0
- cli_guru-0.2.0.dist-info/entry_points.txt +2 -0
- cli_guru-0.2.0.dist-info/licenses/LICENSE +21 -0
- cli_guru-0.2.0.dist-info/top_level.txt +1 -0
cli_guru/context.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Assemble cheap, bounded context about where the user is standing.
|
|
2
|
+
|
|
3
|
+
Never reads file contents. Never reads the process environment. Every
|
|
4
|
+
subprocess is capped with a timeout so a keypress cannot hang.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
# Presence-checked allowlist, so the model only suggests tools that exist here.
|
|
18
|
+
# Allowlist only — never enumerate $PATH.
|
|
19
|
+
TOOLS = [
|
|
20
|
+
"rg", "fd", "jq", "git", "docker", "kubectl", "systemctl", "ss", "curl",
|
|
21
|
+
"wget", "tar", "zip", "unzip", "rsync", "ffmpeg", "python3", "node", "awk",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# (pattern, replacement) pairs. Order matters: forms that carry a VALUE come
|
|
25
|
+
# first so the value is consumed too — a pattern matching only the key name
|
|
26
|
+
# leaves the secret sitting in the text.
|
|
27
|
+
_SECRET_PATTERNS = [
|
|
28
|
+
# Authorization headers: redact the whole value, WHATEVER the scheme. Basic
|
|
29
|
+
# is base64 of user:password, which is encoding, not protection.
|
|
30
|
+
(re.compile(r"authorization\s*:\s*\w+\s+\S+", re.IGNORECASE), "Authorization: <redacted>"),
|
|
31
|
+
# A bare `Bearer <token>` with no header name in front of it.
|
|
32
|
+
(re.compile(r"\bbearer\s+\S+", re.IGNORECASE), "<redacted>"),
|
|
33
|
+
# curl -u user:pass / --user user:pass. The negative lookahead keeps
|
|
34
|
+
# `docker run -u 1000:1000` (a uid:gid, not a credential) intact.
|
|
35
|
+
(re.compile(r"(--?u(?:ser)?)(?:[=\s]+)(?![\d.:\[\]]+(?:\s|$))\S*:\S+", re.IGNORECASE),
|
|
36
|
+
r"\1=<redacted>"),
|
|
37
|
+
# openssl-style `pass:SECRET`, `env:VAR`, `file:path` argument forms.
|
|
38
|
+
(re.compile(r"\bpass:\S+", re.IGNORECASE), "pass:<redacted>"),
|
|
39
|
+
# KEY=value / KEY: value, where the key name looks secret-ish.
|
|
40
|
+
(re.compile(
|
|
41
|
+
r"\b([\w.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key"
|
|
42
|
+
r"|private[_-]?key|credential)[\w.-]*)\s*[=:]\s*\S+",
|
|
43
|
+
re.IGNORECASE), r"\1=<redacted>"),
|
|
44
|
+
# --password=x, --token x, --api-key x
|
|
45
|
+
(re.compile(r"(--?(?:password|passwd|token|api[_-]?key|secret)[\w-]*)(?:[=\s]+\S+)?",
|
|
46
|
+
re.IGNORECASE), r"\1=<redacted>"),
|
|
47
|
+
# mysql-style attached password: -pSECRET (no space, so `-p 5432` is left alone).
|
|
48
|
+
(re.compile(r"(-p)\S{6,}"), r"\1<redacted>"),
|
|
49
|
+
# The spaced form `-p SECRET` (docker login, psql). Purely numeric or
|
|
50
|
+
# colon-separated values are ports or port maps — `-p 8080:80` survives.
|
|
51
|
+
(re.compile(r"(-p)\s+(?![\d.:\[\]]+(?:\s|$))(\S{6,})"), r"\1 <redacted>"),
|
|
52
|
+
# Bare AWS access key ids.
|
|
53
|
+
(re.compile(r"\bAKIA[0-9A-Z]{12,}\b"), "<redacted>"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# A repository carries its own .git/config, and git will happily run commands
|
|
58
|
+
# named in it — `core.fsmonitor` fires on `git status`. Since context is
|
|
59
|
+
# assembled wherever the user is standing, cd'ing into an untrusted checkout
|
|
60
|
+
# (one unpacked from an archive, say — `git clone` does not copy config) would
|
|
61
|
+
# execute it on the next keypress. Disable it explicitly; `safe.directory` does
|
|
62
|
+
# not help, because it only guards repositories owned by somebody else.
|
|
63
|
+
_GIT = ["git", "-c", "core.fsmonitor="]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run(cmd: List[str], timeout: float = 2.0, cwd: Optional[str] = None) -> str:
|
|
67
|
+
try:
|
|
68
|
+
res = subprocess.run(
|
|
69
|
+
cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd,
|
|
70
|
+
env={**os.environ, "LANG": "C", "LC_ALL": "C"},
|
|
71
|
+
)
|
|
72
|
+
return res.stdout.strip() if res.returncode == 0 else ""
|
|
73
|
+
except (OSError, subprocess.SubprocessError):
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def redact(text: str) -> str:
|
|
78
|
+
"""Blank out secret-shaped fragments, INCLUDING their values, before they
|
|
79
|
+
can enter a prompt."""
|
|
80
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
81
|
+
text = pattern.sub(replacement, text)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def listing(cwd: Path, max_files: int) -> str:
|
|
86
|
+
try:
|
|
87
|
+
entries = sorted(
|
|
88
|
+
os.scandir(cwd), key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower())
|
|
89
|
+
)
|
|
90
|
+
except OSError:
|
|
91
|
+
return "(unreadable)"
|
|
92
|
+
names: List[str] = []
|
|
93
|
+
for entry in entries:
|
|
94
|
+
if len(names) >= max_files:
|
|
95
|
+
names.append(f"... {len(entries) - max_files} more")
|
|
96
|
+
break
|
|
97
|
+
try:
|
|
98
|
+
names.append(entry.name + "/" if entry.is_dir(follow_symlinks=False) else entry.name)
|
|
99
|
+
except OSError:
|
|
100
|
+
names.append(entry.name)
|
|
101
|
+
return " ".join(names) if names else "(empty)"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox",
|
|
105
|
+
"target", "dist", "build", ".mypy_cache", ".pytest_cache"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def file_types(cwd: Path, max_entries: int = 2000) -> str:
|
|
109
|
+
"""Recursive extension histogram.
|
|
110
|
+
|
|
111
|
+
A top-level listing alone leaves the model blind: asked to "count lines of
|
|
112
|
+
python in this project" with only `src/ tests/ CLAUDE.md` visible, it
|
|
113
|
+
grepped CLAUDE.md. This is bounded and cheap, and fixes that class of miss.
|
|
114
|
+
"""
|
|
115
|
+
counts: Dict[str, int] = {}
|
|
116
|
+
seen = 0
|
|
117
|
+
for root, dirs, files in os.walk(cwd):
|
|
118
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS and not d.startswith(".")]
|
|
119
|
+
for name in files:
|
|
120
|
+
seen += 1
|
|
121
|
+
if seen > max_entries:
|
|
122
|
+
break
|
|
123
|
+
ext = Path(name).suffix.lower() or "(no ext)"
|
|
124
|
+
counts[ext] = counts.get(ext, 0) + 1
|
|
125
|
+
if seen > max_entries:
|
|
126
|
+
break
|
|
127
|
+
if not counts:
|
|
128
|
+
return ""
|
|
129
|
+
top = sorted(counts.items(), key=lambda kv: -kv[1])[:10]
|
|
130
|
+
return " ".join(f"{n} {ext}" for ext, n in top)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def git_info(cwd: Path) -> str:
|
|
134
|
+
if not _run(_GIT + ["rev-parse", "--is-inside-work-tree"], cwd=str(cwd)) == "true":
|
|
135
|
+
return ""
|
|
136
|
+
branch = _run(_GIT + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=str(cwd)) or "?"
|
|
137
|
+
# `git config user.name` used to go in here. It is the user's real name, it
|
|
138
|
+
# is no help in writing a shell command, and it was not in the documented
|
|
139
|
+
# context list. Do not put it back.
|
|
140
|
+
porcelain = _run(_GIT + ["status", "--porcelain"], cwd=str(cwd))
|
|
141
|
+
if not porcelain:
|
|
142
|
+
return f"git: on {branch}, clean"
|
|
143
|
+
staged = untracked = modified = 0
|
|
144
|
+
for line in porcelain.splitlines():
|
|
145
|
+
if line.startswith("??"):
|
|
146
|
+
untracked += 1
|
|
147
|
+
elif line[:1] not in (" ", ""):
|
|
148
|
+
staged += 1
|
|
149
|
+
else:
|
|
150
|
+
modified += 1
|
|
151
|
+
return (
|
|
152
|
+
f"git: on {branch}, {staged} staged, {modified} modified, "
|
|
153
|
+
f"{untracked} untracked"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def coreutils_flavour() -> str:
|
|
158
|
+
"""GNU vs BSD changes real flags (`sed -i`, `date`, `stat`, `find`).
|
|
159
|
+
|
|
160
|
+
A command correct on Linux and silently wrong on macOS is this tool's most
|
|
161
|
+
likely quality bug, so the flavour goes in the prompt.
|
|
162
|
+
"""
|
|
163
|
+
if platform.system() == "Windows":
|
|
164
|
+
return "windows"
|
|
165
|
+
try:
|
|
166
|
+
res = subprocess.run(
|
|
167
|
+
["sed", "--version"], capture_output=True, text=True, timeout=2.0
|
|
168
|
+
)
|
|
169
|
+
if res.returncode == 0 and "GNU" in res.stdout:
|
|
170
|
+
return "GNU coreutils"
|
|
171
|
+
except (OSError, subprocess.SubprocessError):
|
|
172
|
+
pass
|
|
173
|
+
return "BSD userland (macOS)" if platform.system() == "Darwin" else "non-GNU userland"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def os_name() -> str:
|
|
177
|
+
system = platform.system()
|
|
178
|
+
if system == "Linux":
|
|
179
|
+
try:
|
|
180
|
+
for line in Path("/etc/os-release").read_text().splitlines():
|
|
181
|
+
if line.startswith("PRETTY_NAME="):
|
|
182
|
+
return line.split("=", 1)[1].strip().strip('"')
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
return "Linux"
|
|
186
|
+
if system == "Darwin":
|
|
187
|
+
ver = _run(["sw_vers", "-productVersion"])
|
|
188
|
+
return f"macOS {ver}".strip()
|
|
189
|
+
if system == "Windows":
|
|
190
|
+
return "Windows " + (platform.win32_ver()[0] or "")
|
|
191
|
+
return system or "unknown"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def history(limit: int) -> str:
|
|
195
|
+
"""Read from $CLI_GURU_HISTORY only.
|
|
196
|
+
|
|
197
|
+
The shell adapter captures this with `fc -ln`, because a subprocess reading
|
|
198
|
+
$HISTFILE sees a stale file that the interactive shell has not flushed.
|
|
199
|
+
"""
|
|
200
|
+
raw = os.environ.get("CLI_GURU_HISTORY", "")
|
|
201
|
+
if not raw:
|
|
202
|
+
return ""
|
|
203
|
+
lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
|
|
204
|
+
return "\n".join(redact(ln) for ln in lines[-limit:])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def collect(cfg: Dict) -> Dict[str, str]:
|
|
208
|
+
cwd = Path.cwd()
|
|
209
|
+
try:
|
|
210
|
+
display = "~/" + str(cwd.relative_to(Path.home()))
|
|
211
|
+
except ValueError:
|
|
212
|
+
display = str(cwd)
|
|
213
|
+
present = [t for t in TOOLS if shutil.which(t)]
|
|
214
|
+
return {
|
|
215
|
+
"cwd": str(cwd),
|
|
216
|
+
"cwd_display": display,
|
|
217
|
+
"files": listing(cwd, int(cfg.get("max_files", 50))),
|
|
218
|
+
"file_types": file_types(cwd),
|
|
219
|
+
"git": git_info(cwd),
|
|
220
|
+
"history": history(int(cfg.get("history_lines", 10))),
|
|
221
|
+
"os": os_name(),
|
|
222
|
+
"kernel": platform.release(),
|
|
223
|
+
"shell": Path(os.environ.get("SHELL", "")).name or "unknown",
|
|
224
|
+
"userland": coreutils_flavour(),
|
|
225
|
+
"tools": " ".join(present),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render(ctx: Dict[str, str], include_tools: bool = False) -> str:
|
|
230
|
+
parts = [
|
|
231
|
+
f"System: {ctx['os']} (kernel {ctx['kernel']}), {ctx['userland']}, shell {ctx['shell']}",
|
|
232
|
+
f"Working directory: {ctx['cwd_display']}",
|
|
233
|
+
f"Files here: {ctx['files']}",
|
|
234
|
+
]
|
|
235
|
+
if ctx.get("file_types"):
|
|
236
|
+
parts.append(f"File types in tree: {ctx['file_types']}")
|
|
237
|
+
if ctx.get("git"):
|
|
238
|
+
parts.append(ctx["git"])
|
|
239
|
+
# Off by default — see config.include_tools for the measurement.
|
|
240
|
+
if include_tools and ctx.get("tools"):
|
|
241
|
+
parts.append(f"Available tools: {ctx['tools']}")
|
|
242
|
+
if ctx.get("history"):
|
|
243
|
+
parts.append("Recent commands:\n" + ctx["history"])
|
|
244
|
+
return "\n".join(parts)
|
cli_guru/danger.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Deterministic detection of destructive commands.
|
|
2
|
+
|
|
3
|
+
This is a SAFETY feature, so it must not depend on the model noticing. Measured:
|
|
4
|
+
qwen2.5-coder:3b missed 12 of 15 destructive commands when the warning was left
|
|
5
|
+
to the prompt, including `rm -rf /var/log/*`. cli-guru decides this itself and
|
|
6
|
+
prints the banner; the model only writes the explanation underneath.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
# (pattern, what is lost). Ordered most-specific first.
|
|
15
|
+
_RULES: List[Tuple[re.Pattern, str]] = [
|
|
16
|
+
(re.compile(r":\s*\(\s*\)\s*\{.*\|\s*:\s*&.*\}\s*;\s*:"),
|
|
17
|
+
"fork bomb — this will hang the machine until it is rebooted"),
|
|
18
|
+
(re.compile(r"\bmkfs(\.\w+)?\b"),
|
|
19
|
+
"formats the filesystem — every file on that device is destroyed"),
|
|
20
|
+
(re.compile(r"\bdd\b[^|]*\bof=/dev/"),
|
|
21
|
+
"writes directly over a block device — its entire contents are destroyed"),
|
|
22
|
+
(re.compile(r"\bshred\b"), "overwrites files so they cannot be recovered"),
|
|
23
|
+
(re.compile(r"\brm\b[^|]*\s-[a-zA-Z]*[rR][a-zA-Z]*f|\brm\b[^|]*\s-[a-zA-Z]*f[a-zA-Z]*[rR]"),
|
|
24
|
+
"recursively deletes without prompting — there is no undo and no trash"),
|
|
25
|
+
(re.compile(r"\brm\b(?![^|]*\s-[a-zA-Z]*i)"),
|
|
26
|
+
"deletes files permanently — there is no trash"),
|
|
27
|
+
(re.compile(r"\bgit\s+reset\s+--hard\b"),
|
|
28
|
+
"discards all uncommitted changes in the working tree"),
|
|
29
|
+
(re.compile(r"\bgit\s+clean\b[^|]*-[a-zA-Z]*f"),
|
|
30
|
+
"deletes untracked files, which are not recoverable from git"),
|
|
31
|
+
(re.compile(r"\bgit\s+push\b[^|]*(--force(?!-with-lease)|\s-f\b)"),
|
|
32
|
+
"overwrites the remote branch — commits on it can be lost for everyone"),
|
|
33
|
+
(re.compile(r"\btruncate\b[^|]*-s\s*0"), "empties the file — its contents are lost"),
|
|
34
|
+
(re.compile(r"\bfind\b[^|]*-delete\b"), "deletes every matching file"),
|
|
35
|
+
(re.compile(r"\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b", re.I),
|
|
36
|
+
"drops database objects and the data in them"),
|
|
37
|
+
(re.compile(r"\bdocker\s+(system\s+prune|volume\s+rm)\b"),
|
|
38
|
+
"removes docker data, including volumes that may hold the only copy"),
|
|
39
|
+
(re.compile(r"\b(chmod|chown)\b[^|]*\s-[a-zA-Z]*R[a-zA-Z]*\s+[^|]*\s/(?:\s|$)"),
|
|
40
|
+
"recursively changes ownership or permissions from the filesystem root"),
|
|
41
|
+
(re.compile(r"(?<![>\d])>(?!>)\s*(?!/dev/null)[\w./~-]+"),
|
|
42
|
+
"the `>` redirect truncates the target file before writing"),
|
|
43
|
+
(re.compile(r"\bmv\b[^|]*\s-[a-zA-Z]*f"), "overwrites the destination without prompting"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Commands that only read. The exemption is withdrawn the moment the line also
|
|
47
|
+
# does something destructive: `find . -exec rm {} +` is a delete wearing a
|
|
48
|
+
# read-only command's name.
|
|
49
|
+
_SAFE = re.compile(
|
|
50
|
+
r"^\s*(?:sudo\s+|env\s+\S+=\S+\s+)*"
|
|
51
|
+
r"(?:ls|ll|cat|less|more|head|tail|grep|rg|find|df|du|ps|top|"
|
|
52
|
+
r"git\s+(?:log|status|diff|show|branch)|tar\s+-[a-zA-Z]*t|echo|printf|which|man)\b"
|
|
53
|
+
)
|
|
54
|
+
# Withdraws the _SAFE exemption WITHIN a single segment: `find . -exec rm {} +`
|
|
55
|
+
# starts with an allowlisted command but is a delete. Cross-command cases like
|
|
56
|
+
# `ls; rm -rf ~` are handled by segmenting instead, not here.
|
|
57
|
+
_ALWAYS_UNSAFE = re.compile(
|
|
58
|
+
r">(?!>)\s*(?!/dev/null)|(?<![\w-])-delete\b|-exec\s+(?:rm|shred|truncate)\b|"
|
|
59
|
+
r"\bxargs\s+(?:-\S+\s+)*(?:rm|shred)\b"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Separators that end one command and begin another. `>` is deliberately absent:
|
|
63
|
+
# a redirect is part of the command it belongs to.
|
|
64
|
+
_SEPARATORS = ";\n&|"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_substitution(line: str, start: int, closer: str) -> Tuple[str, int]:
|
|
68
|
+
"""Read a command substitution body, returning (body, index after closer)."""
|
|
69
|
+
depth = 1
|
|
70
|
+
out: List[str] = []
|
|
71
|
+
i = start
|
|
72
|
+
while i < len(line):
|
|
73
|
+
ch = line[i]
|
|
74
|
+
if closer == ")" and ch == "(":
|
|
75
|
+
depth += 1
|
|
76
|
+
elif ch == closer:
|
|
77
|
+
depth -= 1
|
|
78
|
+
if depth == 0:
|
|
79
|
+
return "".join(out), i + 1
|
|
80
|
+
out.append(ch)
|
|
81
|
+
i += 1
|
|
82
|
+
return "".join(out), i
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def segments(line: str) -> List[str]:
|
|
86
|
+
"""Split a command line into independently-judged commands.
|
|
87
|
+
|
|
88
|
+
A warning must never be suppressed because the line merely *starts* with
|
|
89
|
+
something read-only: `sudo ls; rm -rf /` is a delete, and judging the line
|
|
90
|
+
as a whole silently cleared it. Substitutions are judged too, so the `rm` in
|
|
91
|
+
`ls $(rm -rf ~)` is seen. Quoted text is never split, so `echo "a; b"` stays
|
|
92
|
+
one command.
|
|
93
|
+
"""
|
|
94
|
+
out: List[str] = []
|
|
95
|
+
buf: List[str] = []
|
|
96
|
+
quote = ""
|
|
97
|
+
i, n = 0, len(line)
|
|
98
|
+
while i < n:
|
|
99
|
+
ch = line[i]
|
|
100
|
+
# Single quotes are literal in shell: nothing expands inside them.
|
|
101
|
+
if quote == "'":
|
|
102
|
+
if ch == quote:
|
|
103
|
+
quote = ""
|
|
104
|
+
buf.append(ch)
|
|
105
|
+
i += 1
|
|
106
|
+
continue
|
|
107
|
+
if ch == "\\" and i + 1 < n:
|
|
108
|
+
buf.append(ch)
|
|
109
|
+
buf.append(line[i + 1])
|
|
110
|
+
i += 2
|
|
111
|
+
continue
|
|
112
|
+
# Substitutions expand even inside double quotes, so these are checked
|
|
113
|
+
# before the quote state: `echo "`rm -rf ~`"` really does delete.
|
|
114
|
+
if ch == "`":
|
|
115
|
+
body, i = _read_substitution(line, i + 1, "`")
|
|
116
|
+
out.extend(segments(body))
|
|
117
|
+
continue
|
|
118
|
+
if ch in "$<>" and i + 1 < n and line[i + 1] == "(":
|
|
119
|
+
body, i = _read_substitution(line, i + 2, ")")
|
|
120
|
+
out.extend(segments(body))
|
|
121
|
+
continue
|
|
122
|
+
if quote == '"':
|
|
123
|
+
if ch == quote:
|
|
124
|
+
quote = ""
|
|
125
|
+
buf.append(ch)
|
|
126
|
+
i += 1
|
|
127
|
+
continue
|
|
128
|
+
if ch in "'\"":
|
|
129
|
+
quote = ch
|
|
130
|
+
buf.append(ch)
|
|
131
|
+
i += 1
|
|
132
|
+
continue
|
|
133
|
+
if ch in _SEPARATORS:
|
|
134
|
+
out.append("".join(buf))
|
|
135
|
+
buf = []
|
|
136
|
+
i += 2 if i + 1 < n and line[i + 1] == ch else 1 # && and || are one separator
|
|
137
|
+
continue
|
|
138
|
+
buf.append(ch)
|
|
139
|
+
i += 1
|
|
140
|
+
out.append("".join(buf))
|
|
141
|
+
return [s.strip() for s in out if s.strip()]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_segment(segment: str) -> Optional[str]:
|
|
145
|
+
for pattern, harm in _RULES:
|
|
146
|
+
if not pattern.search(segment):
|
|
147
|
+
continue
|
|
148
|
+
# A read-only command that merely contains a scary word is not a risk,
|
|
149
|
+
# but a redirect or an explicit delete always is. Keep looking rather
|
|
150
|
+
# than clearing the segment: an exemption earned against one rule must
|
|
151
|
+
# not mask a different rule that also fires.
|
|
152
|
+
if _SAFE.match(segment) and not _ALWAYS_UNSAFE.search(segment):
|
|
153
|
+
continue
|
|
154
|
+
return harm
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check(command: str) -> Optional[str]:
|
|
159
|
+
"""Return a warning sentence for a destructive command, else None.
|
|
160
|
+
|
|
161
|
+
Every segment of the line is judged separately — see `segments`.
|
|
162
|
+
"""
|
|
163
|
+
if not command or not command.strip():
|
|
164
|
+
return None
|
|
165
|
+
for segment in segments(command):
|
|
166
|
+
harm = _check_segment(segment)
|
|
167
|
+
if harm:
|
|
168
|
+
return harm
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def banner(command: str) -> Optional[str]:
|
|
173
|
+
harm = check(command)
|
|
174
|
+
return f"WARNING: {harm}" if harm else None
|
cli_guru/install.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Dotfile integration.
|
|
2
|
+
|
|
3
|
+
A subcommand rather than a shell script, so it can be tested. Dotfile edits
|
|
4
|
+
happen ONLY here and only when the user runs `cli-guru install` — never as a side
|
|
5
|
+
effect of another command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
BEGIN = "# >>> cli-guru >>>"
|
|
18
|
+
END = "# <<< cli-guru <<<"
|
|
19
|
+
|
|
20
|
+
SHELL_FILES = {
|
|
21
|
+
"bash": ("cli-guru.bash", "~/.bashrc"),
|
|
22
|
+
"zsh": ("cli-guru.zsh", "~/.zshrc"),
|
|
23
|
+
"powershell": ("cli-guru.ps1", None), # resolved from $PROFILE
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_shell() -> str:
|
|
28
|
+
if platform.system() == "Windows":
|
|
29
|
+
return "powershell"
|
|
30
|
+
name = Path(os.environ.get("SHELL", "")).name
|
|
31
|
+
if name in ("zsh", "bash"):
|
|
32
|
+
return name
|
|
33
|
+
if platform.system() == "Darwin":
|
|
34
|
+
return "zsh" # macOS default since Catalina
|
|
35
|
+
return "bash"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def rc_path(shell: str) -> Optional[Path]:
|
|
39
|
+
if shell == "powershell":
|
|
40
|
+
profile = os.environ.get("PROFILE")
|
|
41
|
+
if profile:
|
|
42
|
+
return Path(profile)
|
|
43
|
+
return Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1"
|
|
44
|
+
_, rc = SHELL_FILES[shell]
|
|
45
|
+
return Path(rc).expanduser() if rc else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def block_for(shell: str, script: Path) -> str:
|
|
49
|
+
if shell == "powershell":
|
|
50
|
+
body = f'if (Test-Path "{script}") {{ . "{script}" }}'
|
|
51
|
+
else:
|
|
52
|
+
body = f'[ -f "{script}" ] && . "{script}"'
|
|
53
|
+
return f"{BEGIN}\n{body}\n{END}\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def strip_block(text: str) -> str:
|
|
57
|
+
"""Remove an existing cli-guru block. Idempotent; leaves everything else byte-identical."""
|
|
58
|
+
out = []
|
|
59
|
+
skipping = False
|
|
60
|
+
for line in text.splitlines(keepends=True):
|
|
61
|
+
stripped = line.strip()
|
|
62
|
+
if stripped == BEGIN:
|
|
63
|
+
skipping = True
|
|
64
|
+
continue
|
|
65
|
+
if stripped == END:
|
|
66
|
+
skipping = False
|
|
67
|
+
continue
|
|
68
|
+
if not skipping:
|
|
69
|
+
out.append(line)
|
|
70
|
+
return "".join(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plan(shell: str, script: Path) -> Tuple[Path, str, str]:
|
|
74
|
+
"""Return (rc_file, current_text, new_text) without writing anything."""
|
|
75
|
+
rc = rc_path(shell)
|
|
76
|
+
if rc is None:
|
|
77
|
+
raise ValueError(f"no rc file known for shell {shell!r}")
|
|
78
|
+
current = rc.read_text(encoding="utf-8", errors="replace") if rc.exists() else ""
|
|
79
|
+
stripped = strip_block(current)
|
|
80
|
+
if stripped and not stripped.endswith("\n"):
|
|
81
|
+
stripped += "\n"
|
|
82
|
+
new = stripped + block_for(shell, script)
|
|
83
|
+
return rc, current, new
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def diff(rc: Path, current: str, new: str) -> str:
|
|
87
|
+
return "".join(
|
|
88
|
+
difflib.unified_diff(
|
|
89
|
+
current.splitlines(keepends=True),
|
|
90
|
+
new.splitlines(keepends=True),
|
|
91
|
+
fromfile=str(rc),
|
|
92
|
+
tofile=str(rc) + " (after)",
|
|
93
|
+
)
|
|
94
|
+
) or "(no change)"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write(rc: Path, current: str, new: str, backup: bool = True) -> Optional[Path]:
|
|
98
|
+
"""Write `new`, backing up the original once. Returns the backup path if made."""
|
|
99
|
+
made: Optional[Path] = None
|
|
100
|
+
if backup and rc.exists():
|
|
101
|
+
bak = rc.with_suffix(rc.suffix + ".cli-guru.bak")
|
|
102
|
+
if not bak.exists():
|
|
103
|
+
shutil.copy2(rc, bak)
|
|
104
|
+
made = bak
|
|
105
|
+
rc.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
rc.write_text(new, encoding="utf-8")
|
|
107
|
+
return made
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def uninstall_plan(shell: str) -> Tuple[Path, str, str]:
|
|
111
|
+
rc = rc_path(shell)
|
|
112
|
+
if rc is None:
|
|
113
|
+
raise ValueError(f"no rc file known for shell {shell!r}")
|
|
114
|
+
current = rc.read_text(encoding="utf-8", errors="replace") if rc.exists() else ""
|
|
115
|
+
return rc, current, strip_block(current)
|