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