skimread 0.1.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.
skimread/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """skimread: skim before you read.
2
+
3
+ A zero-dependency, local, lossless context guard for AI coding agents.
4
+ It shrinks the two biggest token sinks in agentic coding, big file reads and
5
+ noisy command output, while keeping every byte one call away.
6
+ """
7
+
8
+ __version__ = "0.1.0"
skimread/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
skimread/ansi.py ADDED
@@ -0,0 +1,55 @@
1
+ """Terminal output normalisation: ANSI escapes, carriage returns, backspaces."""
2
+
3
+ import re
4
+
5
+ _CSI = re.compile(r"\x1b\[[0-9;?<>=!]*[ -/]*[@-~]")
6
+ _OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
7
+ _ESC_OTHER = re.compile(r"\x1b[@-Z\\-_]")
8
+ _CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
9
+
10
+
11
+ def strip_ansi(text: str) -> str:
12
+ text = _OSC.sub("", text)
13
+ text = _CSI.sub("", text)
14
+ text = _ESC_OTHER.sub("", text)
15
+ return text
16
+
17
+
18
+ def _resolve_cr(line: str) -> str:
19
+ """Emulate a terminal: '\r' returns to column 0 and later text overwrites."""
20
+ if "\r" not in line:
21
+ return line
22
+ parts = line.split("\r")
23
+ # Keep the last non-empty segment; progress bars rewrite the same line.
24
+ out = ""
25
+ for seg in parts:
26
+ if not seg:
27
+ continue
28
+ if len(seg) >= len(out):
29
+ out = seg
30
+ else:
31
+ out = seg + out[len(seg):]
32
+ return out
33
+
34
+
35
+ def normalize(raw: str) -> str:
36
+ """Return plain text with one logical line per terminal line."""
37
+ text = raw.replace("\r\n", "\n")
38
+ text = strip_ansi(text)
39
+ lines = []
40
+ for line in text.split("\n"):
41
+ line = _resolve_cr(line)
42
+ if "\x08" in line:
43
+ buf = []
44
+ for ch in line:
45
+ if ch == "\x08":
46
+ if buf:
47
+ buf.pop()
48
+ else:
49
+ buf.append(ch)
50
+ line = "".join(buf)
51
+ line = _CTRL.sub("", line)
52
+ lines.append(line.rstrip())
53
+ while lines and not lines[-1]:
54
+ lines.pop()
55
+ return "\n".join(lines)
skimread/cli.py ADDED
@@ -0,0 +1,368 @@
1
+ """skimread command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ from . import __version__, store
14
+ from .digest import build_digest, run_command
15
+ from .outline import analyze, render_outline
16
+ from .tokens import estimate_tokens, fmt_bytes, fmt_tokens
17
+
18
+ SUBCOMMANDS = {"run", "pipe", "show", "list", "outline", "read", "stats", "hook", "setup", "mcp", "config",
19
+ "prune", "version", "-h", "--help", "--version"}
20
+
21
+
22
+ # --- helpers shared with the MCP server ------------------------------------------------
23
+
24
+ def parse_range(spec: Optional[str], n: int) -> Optional[tuple]:
25
+ if not spec:
26
+ return None
27
+ m = re.match(r"^\s*(\d*)\s*[-:]\s*(\d*)\s*$", spec)
28
+ if m:
29
+ a = int(m.group(1)) if m.group(1) else 1
30
+ b = int(m.group(2)) if m.group(2) else n
31
+ return max(1, a), min(n, b)
32
+ if spec.strip().isdigit():
33
+ a = int(spec)
34
+ return max(1, a), min(n, a)
35
+ raise SystemExit(f"skimread: bad range {spec!r} (use A-B, A:, :B or A)")
36
+
37
+
38
+ def _numbered(lines: List[str], start: int) -> str:
39
+ w = len(str(start + len(lines)))
40
+ return "\n".join(f"{start + i:>{w}}| {t}" for i, t in enumerate(lines))
41
+
42
+
43
+ def format_show(rid: int, rng: Optional[str] = None, grep: Optional[str] = None, context: int = 2,
44
+ show_all: bool = False, cap: int = 400) -> str:
45
+ run = store.load_run(rid)
46
+ if run is None:
47
+ return f"skimread: no stored output with id {rid} (see `skimread list`)"
48
+ lines = run["text"].split("\n")
49
+ n = len(lines)
50
+ head = f"# skimread #{rid}: $ {run.get('cmd', '')} · exit {run.get('exit')} · {n} lines"
51
+ if grep:
52
+ try:
53
+ rx = re.compile(grep, re.I)
54
+ except re.error as e:
55
+ return f"skimread: bad regex: {e}"
56
+ hits = [i for i, t in enumerate(lines) if rx.search(t)]
57
+ if not hits:
58
+ return head + f"\n(no lines match /{grep}/)"
59
+ keep = set()
60
+ for i in hits:
61
+ keep.update(range(max(0, i - context), min(n, i + context + 1)))
62
+ out = [head + f" · {len(hits)} matches for /{grep}/"]
63
+ prev = -1
64
+ shown = 0
65
+ for i in sorted(keep):
66
+ if prev >= 0 and i != prev + 1:
67
+ out.append(" …")
68
+ out.append(f"{i + 1:>5}| {lines[i]}")
69
+ prev = i
70
+ shown += 1
71
+ if shown >= cap * 2 and not show_all:
72
+ out.append(f" … stopped after {shown} lines; narrow the pattern or pass --all")
73
+ break
74
+ return "\n".join(out)
75
+ r = parse_range(rng, n)
76
+ if r:
77
+ a, b = r
78
+ sel = lines[a - 1:b]
79
+ note = ""
80
+ if len(sel) > cap * 4 and not show_all:
81
+ sel = sel[: cap * 4]
82
+ note = f"\n … range truncated to {cap * 4} lines; pass --all to print everything"
83
+ return head + "\n" + _numbered(sel, a) + note
84
+ if n > cap and not show_all:
85
+ return (head + f"\n" + _numbered(lines[:cap // 2], 1) +
86
+ f"\n … {n - cap} lines omitted ({cap // 2 + 1}-{n - cap // 2}); use a range, --grep, or --all …\n" +
87
+ _numbered(lines[n - cap // 2:], n - cap // 2 + 1))
88
+ return head + "\n" + _numbered(lines, 1)
89
+
90
+
91
+ def read_range(path: str, start: Optional[int] = None, end: Optional[int] = None) -> str:
92
+ try:
93
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
94
+ except OSError as e:
95
+ return f"skimread: {e}"
96
+ lines = text.split("\n")
97
+ if text.endswith("\n"):
98
+ lines.pop()
99
+ n = len(lines)
100
+ a = max(1, int(start or 1))
101
+ b = min(n, int(end or n))
102
+ if a > b:
103
+ return f"skimread: empty range {a}-{b} (file has {n} lines)"
104
+ return f"# {path} lines {a}-{b} of {n}\n" + _numbered(lines[a - 1:b], a)
105
+
106
+
107
+ def format_list(limit: int = 20) -> str:
108
+ runs = store.list_runs(limit)
109
+ if not runs:
110
+ return "skimread: no stored runs yet"
111
+ out = [" id exit lines tokens when command"]
112
+ for m in runs:
113
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(m.get("ts", 0)))
114
+ cmd = (m.get("cmd") or "")[:70]
115
+ out.append(f"{m.get('id', 0):>5} {str(m.get('exit')):>4} {m.get('lines', 0):>6} {fmt_tokens(m.get('tokens_in', 0)):>7} {when} {cmd}")
116
+ return "\n".join(out)
117
+
118
+
119
+ def format_stats(price: Optional[float] = None, as_json: bool = False) -> str:
120
+ cfg = store.load_config()
121
+ price = price if price is not None else float(cfg["price_per_mtok"])
122
+ runs = 0
123
+ run_in = run_out = 0
124
+ guards = 0
125
+ guard_full = guard_outline = slices = full_reads = 0
126
+ by_fmt: dict = {}
127
+ for ev in store.events():
128
+ k = ev.get("kind")
129
+ if k == "run":
130
+ runs += 1
131
+ run_in += int(ev.get("tokens_in", 0))
132
+ run_out += int(ev.get("tokens_out", 0))
133
+ f = ev.get("fmt", "generic")
134
+ d = by_fmt.setdefault(f, [0, 0, 0])
135
+ d[0] += 1
136
+ d[1] += int(ev.get("tokens_in", 0))
137
+ d[2] += int(ev.get("tokens_out", 0))
138
+ elif k == "read_guard":
139
+ guards += 1
140
+ guard_full += int(ev.get("tokens", 0))
141
+ guard_outline += int(ev.get("outline_tokens", 0))
142
+ elif k == "read_slice":
143
+ slices += int(ev.get("tokens", 0))
144
+ elif k == "read_full":
145
+ full_reads += int(ev.get("tokens", 0))
146
+ run_saved = max(0, run_in - run_out)
147
+ read_spent = guard_outline + slices + full_reads
148
+ read_saved = max(0, guard_full - read_spent)
149
+ total = run_saved + read_saved
150
+ usd = total / 1e6 * price
151
+ if as_json:
152
+ return json.dumps({"runs": runs, "run_tokens_in": run_in, "run_tokens_out": run_out, "run_saved": run_saved,
153
+ "read_guards": guards, "read_tokens_full": guard_full, "read_tokens_spent": read_spent,
154
+ "read_saved": read_saved, "total_saved": total, "usd_saved": round(usd, 4),
155
+ "price_per_mtok": price, "by_format": by_fmt}, indent=2)
156
+ lines = [
157
+ "skimread stats (all time)",
158
+ f" commands digested : {runs:>7} {fmt_tokens(run_in):>7} tok → {fmt_tokens(run_out):>7} tok saved {fmt_tokens(run_saved)}"
159
+ + (f" ({100 * run_saved / run_in:.0f}%)" if run_in else ""),
160
+ f" big reads guarded : {guards:>7} {fmt_tokens(guard_full):>7} tok → {fmt_tokens(read_spent):>7} tok saved {fmt_tokens(read_saved)}"
161
+ + (f" ({100 * read_saved / guard_full:.0f}%)" if guard_full else ""),
162
+ f" total saved : ~{fmt_tokens(total)} tokens ≈ ${usd:.2f} at ${price:g}/M input tokens",
163
+ ]
164
+ if by_fmt:
165
+ lines.append(" by format:")
166
+ for f, (c, i, o) in sorted(by_fmt.items(), key=lambda kv: -kv[1][1]):
167
+ pct = f"{100 * (i - o) / i:.0f}%" if i else "-"
168
+ lines.append(f" {f:<10} {c:>5} runs {fmt_tokens(i):>7} → {fmt_tokens(o):>7} ({pct})")
169
+ return "\n".join(lines)
170
+
171
+
172
+ # --- subcommands ---------------------------------------------------------------------------
173
+
174
+ def cmd_run(args: argparse.Namespace) -> int:
175
+ if args.c:
176
+ cmd = args.c
177
+ elif args.argv:
178
+ argv = args.argv[1:] if args.argv and args.argv[0] == "--" else args.argv
179
+ import shlex
180
+ cmd = " ".join(shlex.quote(a) for a in argv)
181
+ else:
182
+ print("skimread run: give a command (skimread run -c 'pytest -q' or skimread run -- pytest -q)", file=sys.stderr)
183
+ return 2
184
+ res = run_command(cmd, cwd=args.cwd, timeout=args.timeout)
185
+ d = build_digest(res["raw"], cmd=cmd, exit_code=res["exit"], duration=res["duration"], budget=args.budget,
186
+ cwd=args.cwd or "")
187
+ sys.stdout.write(d.text + "\n")
188
+ return int(res["exit"] or 0)
189
+
190
+
191
+ def cmd_pipe(args: argparse.Namespace) -> int:
192
+ raw = sys.stdin.read()
193
+ exit_code = None
194
+ m = re.search(r"\n?__SKIMREAD_EXIT=(\d+)\s*$", raw)
195
+ if m:
196
+ exit_code = int(m.group(1))
197
+ raw = raw[: m.start()]
198
+ d = build_digest(raw, cmd=args.cmd or "", exit_code=exit_code, budget=args.budget)
199
+ sys.stdout.write(d.text + "\n")
200
+ return exit_code or 0
201
+
202
+
203
+ def cmd_show(args: argparse.Namespace) -> int:
204
+ print(format_show(args.id, args.range, args.grep, args.context, args.all))
205
+ return 0
206
+
207
+
208
+ def cmd_outline(args: argparse.Namespace) -> int:
209
+ for p in args.paths:
210
+ if not Path(p).is_file():
211
+ print(f"skimread: {p}: not a file", file=sys.stderr)
212
+ continue
213
+ print(render_outline(analyze(p), max_entries=100000 if args.all else args.max))
214
+ return 0
215
+
216
+
217
+ def cmd_read(args: argparse.Namespace) -> int:
218
+ r = parse_range(args.range, 10 ** 9) if args.range else None
219
+ print(read_range(args.path, r[0] if r else None, r[1] if r else None))
220
+ return 0
221
+
222
+
223
+ def cmd_hook(args: argparse.Namespace) -> int:
224
+ from .hooks import handle
225
+ try:
226
+ payload = json.loads(sys.stdin.read() or "{}")
227
+ except ValueError:
228
+ return 0
229
+ try:
230
+ out = handle(payload)
231
+ except Exception as e: # never break the agent's tool call
232
+ print(f"skimread hook error: {e}", file=sys.stderr)
233
+ return 0
234
+ if out:
235
+ sys.stdout.write(json.dumps(out))
236
+ return 0
237
+
238
+
239
+ def cmd_setup(args: argparse.Namespace) -> int:
240
+ from . import setup as st
241
+ if args.target == "claude":
242
+ scope = "project" if args.project else ("local" if args.local else "global")
243
+ _, msg = st.install(scope=scope, wrap=args.wrap, uninstall=args.uninstall)
244
+ print(msg)
245
+ elif args.target == "print":
246
+ print(st.snippet(args.wrap))
247
+ elif args.target == "agents":
248
+ print(st.agents_snippet())
249
+ elif args.target == "mcp":
250
+ print(st.mcp_snippet())
251
+ return 0
252
+
253
+
254
+ def cmd_config(args: argparse.Namespace) -> int:
255
+ cfg = store.load_config()
256
+ if args.key and args.value is not None:
257
+ if args.key not in cfg:
258
+ print(f"skimread: unknown key {args.key}; keys: {', '.join(cfg)}", file=sys.stderr)
259
+ return 2
260
+ cur = cfg[args.key]
261
+ try:
262
+ cfg[args.key] = type(cur)(args.value) if not isinstance(cur, bool) else args.value.lower() in ("1", "true", "yes")
263
+ except ValueError:
264
+ print(f"skimread: {args.key} expects {type(cur).__name__}", file=sys.stderr)
265
+ return 2
266
+ store.save_config(cfg)
267
+ if args.key:
268
+ print(f"{args.key} = {cfg[args.key]}")
269
+ else:
270
+ for k, v in cfg.items():
271
+ print(f"{k} = {v}")
272
+ print(f"(config file: {store.home() / 'config.json'})")
273
+ return 0
274
+
275
+
276
+ def build_parser() -> argparse.ArgumentParser:
277
+ p = argparse.ArgumentParser(prog="skimread", description="Skim before you read: lossless context guard for AI coding agents.")
278
+ p.add_argument("--version", action="version", version=f"skimread {__version__}")
279
+ sub = p.add_subparsers(dest="cmd")
280
+
281
+ r = sub.add_parser("run", help="run a command and print a lossless digest of its output")
282
+ r.add_argument("-c", help="command line to run through the shell")
283
+ r.add_argument("--cwd")
284
+ r.add_argument("--timeout", type=float)
285
+ r.add_argument("--budget", type=int, help="digest size in characters")
286
+ r.add_argument("argv", nargs=argparse.REMAINDER)
287
+ r.set_defaults(fn=cmd_run)
288
+
289
+ pi = sub.add_parser("pipe", help="digest stdin (cmd 2>&1 | skimread pipe)")
290
+ pi.add_argument("--cmd", help="command name, for format detection and the header")
291
+ pi.add_argument("--budget", type=int)
292
+ pi.set_defaults(fn=cmd_pipe)
293
+
294
+ s = sub.add_parser("show", help="show stored full output: all, a range, or grep matches")
295
+ s.add_argument("id", type=int)
296
+ s.add_argument("range", nargs="?")
297
+ s.add_argument("--grep", "-g")
298
+ s.add_argument("-C", "--context", type=int, default=2)
299
+ s.add_argument("--all", action="store_true")
300
+ s.set_defaults(fn=cmd_show)
301
+
302
+ l = sub.add_parser("list", help="list stored runs")
303
+ l.add_argument("-n", type=int, default=20)
304
+ l.set_defaults(fn=lambda a: (print(format_list(a.n)), 0)[1])
305
+
306
+ o = sub.add_parser("outline", help="structural outline of a file with line ranges")
307
+ o.add_argument("paths", nargs="+")
308
+ o.add_argument("--max", type=int, default=70)
309
+ o.add_argument("--all", action="store_true")
310
+ o.set_defaults(fn=cmd_outline)
311
+
312
+ rd = sub.add_parser("read", help="print a line range of a file with line numbers")
313
+ rd.add_argument("path")
314
+ rd.add_argument("range", nargs="?")
315
+ rd.set_defaults(fn=cmd_read)
316
+
317
+ stt = sub.add_parser("stats", help="tokens and money saved so far")
318
+ stt.add_argument("--price", type=float, help="USD per million input tokens")
319
+ stt.add_argument("--json", action="store_true")
320
+ stt.set_defaults(fn=lambda a: (print(format_stats(a.price, a.json)), 0)[1])
321
+
322
+ h = sub.add_parser("hook", help="Claude Code PreToolUse hook (JSON on stdin)")
323
+ h.set_defaults(fn=cmd_hook)
324
+
325
+ se = sub.add_parser("setup", help="install into Claude Code, or print snippets")
326
+ se.add_argument("target", choices=["claude", "print", "agents", "mcp"])
327
+ se.add_argument("--project", action="store_true", help="write .claude/settings.json in the current project")
328
+ se.add_argument("--local", action="store_true", help="write .claude/settings.local.json (not committed)")
329
+ se.add_argument("--wrap", choices=["noisy", "all", "off"], default="noisy",
330
+ help="which Bash commands to route through skimread (default: noisy)")
331
+ se.add_argument("--uninstall", action="store_true")
332
+ se.set_defaults(fn=cmd_setup)
333
+
334
+ m = sub.add_parser("mcp", help="run as an MCP server on stdio")
335
+ m.set_defaults(fn=lambda a: __import__("skimread.mcp", fromlist=["serve"]).serve())
336
+
337
+ c = sub.add_parser("config", help="show or set configuration")
338
+ c.add_argument("key", nargs="?")
339
+ c.add_argument("value", nargs="?")
340
+ c.set_defaults(fn=cmd_config)
341
+
342
+ pr = sub.add_parser("prune", help="delete old stored outputs")
343
+ pr.set_defaults(fn=lambda a: (store.prune(), print("pruned"), 0)[2])
344
+
345
+ v = sub.add_parser("version")
346
+ v.set_defaults(fn=lambda a: (print(f"skimread {__version__}"), 0)[1])
347
+ return p
348
+
349
+
350
+ def main(argv: Optional[List[str]] = None) -> int:
351
+ argv = list(sys.argv[1:] if argv is None else argv)
352
+ if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"):
353
+ argv = ["run", "--"] + argv # `skimread pytest -q` == `skimread run -- pytest -q`
354
+ parser = build_parser()
355
+ args = parser.parse_args(argv)
356
+ if not args.cmd:
357
+ parser.print_help()
358
+ return 0
359
+ try:
360
+ return int(args.fn(args) or 0)
361
+ except KeyboardInterrupt:
362
+ return 130
363
+ except BrokenPipeError:
364
+ return 0
365
+
366
+
367
+ if __name__ == "__main__":
368
+ raise SystemExit(main())
skimread/digest.py ADDED
@@ -0,0 +1,134 @@
1
+ """Turn one command's raw output into a budgeted, lossless digest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Dict, List, Optional
11
+
12
+ from . import formats, store
13
+ from .ansi import normalize
14
+ from .reduce import Line, fit_budget, render, to_lines
15
+ from .tokens import estimate_tokens, fmt_bytes, fmt_tokens
16
+
17
+ MAX_CAPTURE = 32 * 1024 * 1024
18
+
19
+
20
+ @dataclass
21
+ class Digest:
22
+ text: str
23
+ run_id: Optional[int]
24
+ fmt: str
25
+ lines_in: int
26
+ bytes_in: int
27
+ tokens_in: int
28
+ tokens_out: int
29
+
30
+
31
+ def build_digest(raw: str, cmd: str = "", exit_code: Optional[int] = None, duration: Optional[float] = None,
32
+ budget: Optional[int] = None, save: bool = True, cwd: str = "") -> Digest:
33
+ cfg = store.load_config()
34
+ budget = budget or int(cfg["budget"])
35
+ norm = normalize(raw)
36
+ lines: List[Line] = to_lines(norm)
37
+ n_lines = len(lines)
38
+ bytes_in = len(raw.encode("utf-8", errors="replace"))
39
+ tokens_in = estimate_tokens(norm)
40
+
41
+ run_id: Optional[int] = None
42
+ if save:
43
+ run_id = store.save_run(norm, {
44
+ "cmd": cmd, "cwd": cwd, "exit": exit_code, "lines": n_lines, "bytes": bytes_in,
45
+ "tokens_in": tokens_in, "duration": duration,
46
+ })
47
+ handle = f"skimread show {run_id}" if run_id else "skimread show <id>"
48
+
49
+ fmt, reducer = formats.detect(cmd, lines)
50
+ ctx = formats.Ctx(cmd=cmd, budget=budget, handle=handle)
51
+ reduced = reducer(list(lines), ctx)
52
+ fitted = fit_budget(reduced, budget, handle=handle)
53
+ body = render(fitted)
54
+
55
+ tokens_out = estimate_tokens(body)
56
+ status = ""
57
+ if exit_code is not None:
58
+ status = f"exit {exit_code}"
59
+ if duration is not None:
60
+ status += f" in {duration:.1f}s"
61
+ shown = len(fitted)
62
+ shown_cmd = cmd if len(cmd) <= 300 else cmd[:280] + " …"
63
+ header_bits = [f"$ {shown_cmd}" if cmd else "$ (stdin)"]
64
+ if status:
65
+ header_bits.append(status)
66
+ header = " · ".join(header_bits)
67
+ if n_lines <= shown and body == norm:
68
+ footer = f"[skimread #{run_id}: {n_lines} lines, shown in full]" if run_id else ""
69
+ else:
70
+ footer = (f"[skimread #{run_id}: {n_lines} lines, ~{fmt_tokens(tokens_in)} tok → {fmt_tokens(tokens_out)}. "
71
+ f"Full/slice/search: {handle} [A-B | --grep RE]]") if run_id else f"[skimread: {n_lines} lines → digest]"
72
+ text = header + "\n" + body + ("\n" + footer if footer else "")
73
+ if run_id:
74
+ try:
75
+ store.log_event({"kind": "run", "id": run_id, "tokens_in": tokens_in, "lines": n_lines,
76
+ "tokens_out": estimate_tokens(text), "fmt": fmt, "cmd": cmd, "exit": exit_code})
77
+ except OSError:
78
+ pass
79
+ return Digest(text, run_id, fmt, n_lines, bytes_in, tokens_in, estimate_tokens(text))
80
+
81
+
82
+ def run_command(cmd: str, cwd: Optional[str] = None, timeout: Optional[float] = None,
83
+ shell: bool = True) -> Dict:
84
+ """Run a command with merged stdout/stderr, colours off. Returns raw output + exit code."""
85
+ import os
86
+ env = dict(os.environ)
87
+ env.update({"NO_COLOR": "1", "FORCE_COLOR": "0", "TERM": "dumb", "CLICOLOR": "0", "PY_COLORS": "0",
88
+ "CARGO_TERM_COLOR": "never", "SKIMREAD_ACTIVE": "1"})
89
+ env.pop("COLORTERM", None)
90
+ start = time.time()
91
+ try:
92
+ if shell:
93
+ bash = _bash()
94
+ proc = subprocess.Popen(cmd if bash is None else [bash, "-c", cmd], shell=bash is None,
95
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
96
+ cwd=cwd, env=env)
97
+ else:
98
+ proc = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
99
+ stdin=subprocess.DEVNULL, cwd=cwd, env=env)
100
+ except FileNotFoundError as e:
101
+ return {"raw": f"skimread: cannot run command: {e}\n", "exit": 127, "duration": 0.0, "timed_out": False}
102
+ chunks: List[bytes] = []
103
+ size = 0
104
+ timed_out = False
105
+ assert proc.stdout is not None
106
+ try:
107
+ while True:
108
+ if timeout and time.time() - start > timeout:
109
+ proc.kill()
110
+ timed_out = True
111
+ break
112
+ chunk = proc.stdout.read1(65536) if hasattr(proc.stdout, "read1") else proc.stdout.read(65536)
113
+ if not chunk:
114
+ break
115
+ chunks.append(chunk)
116
+ size += len(chunk)
117
+ if size > MAX_CAPTURE:
118
+ # keep the tail; the head is almost never what an agent needs
119
+ while size > MAX_CAPTURE // 2 and len(chunks) > 1:
120
+ size -= len(chunks.pop(0))
121
+ finally:
122
+ proc.wait()
123
+ raw = b"".join(chunks).decode("utf-8", errors="replace")
124
+ if timed_out:
125
+ raw += f"\nskimread: command timed out after {timeout:.0f}s and was killed\n"
126
+ return {"raw": raw, "exit": proc.returncode if not timed_out else 124,
127
+ "duration": time.time() - start, "timed_out": timed_out}
128
+
129
+
130
+ def _bash() -> Optional[str]:
131
+ import shutil
132
+ if sys.platform.startswith("win"):
133
+ return None
134
+ return shutil.which("bash") or shutil.which("sh")