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 +8 -0
- skimread/__main__.py +4 -0
- skimread/ansi.py +55 -0
- skimread/cli.py +368 -0
- skimread/digest.py +134 -0
- skimread/formats.py +462 -0
- skimread/hooks.py +222 -0
- skimread/mcp.py +127 -0
- skimread/outline.py +598 -0
- skimread/reduce.py +225 -0
- skimread/setup.py +119 -0
- skimread/store.py +186 -0
- skimread/tokens.py +33 -0
- skimread-0.1.0.dist-info/METADATA +210 -0
- skimread-0.1.0.dist-info/RECORD +19 -0
- skimread-0.1.0.dist-info/WHEEL +5 -0
- skimread-0.1.0.dist-info/entry_points.txt +2 -0
- skimread-0.1.0.dist-info/licenses/LICENSE +21 -0
- skimread-0.1.0.dist-info/top_level.txt +1 -0
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
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")
|