token-save-mcp 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.
- token_save_mcp/__init__.py +3 -0
- token_save_mcp/cli.py +482 -0
- token_save_mcp/hooks/check-file-size +94 -0
- token_save_mcp/server.py +640 -0
- token_save_mcp-0.1.0.dist-info/METADATA +326 -0
- token_save_mcp-0.1.0.dist-info/RECORD +9 -0
- token_save_mcp-0.1.0.dist-info/WHEEL +4 -0
- token_save_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- token_save_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
token_save_mcp/cli.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
token-save-mcp CLI — install, diagnose, uninstall.
|
|
4
|
+
|
|
5
|
+
The point of `init` is that nobody should hand-edit JSON to try this. One
|
|
6
|
+
command registers the MCP server, optionally installs the enforcement hook,
|
|
7
|
+
and tells you what to do next.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_hook() -> pathlib.Path | None:
|
|
22
|
+
"""Locate the hook script.
|
|
23
|
+
|
|
24
|
+
Installed wheels carry it inside the package; a source checkout keeps it at
|
|
25
|
+
the repo root. Check both rather than assuming one layout — getting this
|
|
26
|
+
wrong means `install-hook` fails for everyone who used pip.
|
|
27
|
+
"""
|
|
28
|
+
for candidate in (
|
|
29
|
+
HERE / "hooks" / "check-file-size", # installed package
|
|
30
|
+
HERE.parent.parent / "hooks" / "check-file-size", # source checkout
|
|
31
|
+
):
|
|
32
|
+
if candidate.exists():
|
|
33
|
+
return candidate
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
HOOK_SRC = _find_hook()
|
|
38
|
+
|
|
39
|
+
PROVIDERS = {
|
|
40
|
+
"ollama": ("OLLAMA_API_KEY", "https://ollama.com/settings/keys", "glm-5.3-flash"),
|
|
41
|
+
"openrouter": ("OPENROUTER_API_KEY", "https://openrouter.ai/keys",
|
|
42
|
+
"deepseek/deepseek-chat"),
|
|
43
|
+
"deepseek": ("DEEPSEEK_API_KEY", "https://platform.deepseek.com/api_keys",
|
|
44
|
+
"deepseek-chat"),
|
|
45
|
+
"groq": ("GROQ_API_KEY", "https://console.groq.com/keys",
|
|
46
|
+
"llama-3.3-70b-versatile"),
|
|
47
|
+
"local": ("", "http://localhost:11434 (no key needed)", "qwen2.5-coder:7b"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
GREEN, YELLOW, RED, DIM, RESET = (
|
|
51
|
+
"\033[32m", "\033[33m", "\033[31m", "\033[2m", "\033[0m"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ok(msg):
|
|
56
|
+
print(f" {GREEN}✓{RESET} {msg}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def warn(msg):
|
|
60
|
+
print(f" {YELLOW}!{RESET} {msg}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def bad(msg):
|
|
64
|
+
print(f" {RED}✗{RESET} {msg}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _claude_settings_path() -> pathlib.Path:
|
|
68
|
+
return pathlib.Path.home() / ".claude" / "settings.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _server_command() -> list[str]:
|
|
72
|
+
"""How to launch the server. Prefer the installed console script."""
|
|
73
|
+
exe = shutil.which("token-save-mcp-server")
|
|
74
|
+
if exe:
|
|
75
|
+
return [exe]
|
|
76
|
+
return [sys.executable, "-m", "token_save_mcp.server"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# init
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def cmd_init(args) -> int:
|
|
84
|
+
provider = args.provider
|
|
85
|
+
key_var, key_url, default_model = PROVIDERS[provider]
|
|
86
|
+
|
|
87
|
+
print(f"\ntoken-save-mcp — setup ({provider})\n")
|
|
88
|
+
|
|
89
|
+
# 1. Key
|
|
90
|
+
key = os.environ.get("TOKENSAVE_API_KEY") or (
|
|
91
|
+
os.environ.get(key_var) if key_var else ""
|
|
92
|
+
)
|
|
93
|
+
if key:
|
|
94
|
+
ok(f"API key found in ${key_var or 'TOKENSAVE_API_KEY'}")
|
|
95
|
+
elif provider == "local":
|
|
96
|
+
ok("local provider — no API key needed")
|
|
97
|
+
else:
|
|
98
|
+
bad(f"no API key in ${key_var}")
|
|
99
|
+
print(f" Get one at: {key_url}")
|
|
100
|
+
print(f" Then: export {key_var}=... (add it to your shell profile)")
|
|
101
|
+
if not args.force:
|
|
102
|
+
print("\n Re-run once the key is set, or pass --force to register anyway.\n")
|
|
103
|
+
return 1
|
|
104
|
+
warn("--force given: registering without a verified key")
|
|
105
|
+
|
|
106
|
+
env = {}
|
|
107
|
+
if provider != "ollama":
|
|
108
|
+
env["TOKENSAVE_PROVIDER"] = provider
|
|
109
|
+
if key:
|
|
110
|
+
env["TOKENSAVE_API_KEY"] = key
|
|
111
|
+
if args.model:
|
|
112
|
+
env["TOKENSAVE_MODEL"] = args.model
|
|
113
|
+
|
|
114
|
+
# 2. Register the MCP server
|
|
115
|
+
cmd = ["claude", "mcp", "add", "--scope", args.scope, "--transport", "stdio"]
|
|
116
|
+
for k, v in env.items():
|
|
117
|
+
cmd += ["--env", f"{k}={v}"]
|
|
118
|
+
cmd += ["token-save", "--"] + _server_command()
|
|
119
|
+
|
|
120
|
+
if shutil.which("claude"):
|
|
121
|
+
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
122
|
+
if res.returncode == 0:
|
|
123
|
+
ok(f"registered MCP server 'token-save' ({args.scope} scope)")
|
|
124
|
+
elif "already exists" in (res.stderr + res.stdout):
|
|
125
|
+
ok("MCP server 'token-save' already registered")
|
|
126
|
+
else:
|
|
127
|
+
bad("could not register the MCP server")
|
|
128
|
+
print((res.stderr or res.stdout).strip()[:400])
|
|
129
|
+
return 1
|
|
130
|
+
else:
|
|
131
|
+
warn("`claude` CLI not found — add this to your MCP config by hand:")
|
|
132
|
+
print(json.dumps({"mcpServers": {"token-save": {
|
|
133
|
+
"type": "stdio",
|
|
134
|
+
"command": _server_command()[0],
|
|
135
|
+
"args": _server_command()[1:],
|
|
136
|
+
"env": env or {},
|
|
137
|
+
}}}, indent=2))
|
|
138
|
+
|
|
139
|
+
# 3. The hook — opt-in, because blocking Read is an intrusive default.
|
|
140
|
+
if args.hook:
|
|
141
|
+
rc = _install_hook(args.min_lines, args.hook_mode)
|
|
142
|
+
if rc != 0:
|
|
143
|
+
return rc
|
|
144
|
+
else:
|
|
145
|
+
print(f"\n {DIM}Hook not installed (enforcement is opt-in).{RESET}")
|
|
146
|
+
print(f" {DIM}Add it later with: token-save-mcp install-hook{RESET}")
|
|
147
|
+
|
|
148
|
+
model = args.model or default_model
|
|
149
|
+
print(f"""
|
|
150
|
+
{GREEN}Done.{RESET} Start a new session, then try:
|
|
151
|
+
|
|
152
|
+
Ask your agent: "use bulk_read to summarise <some big file>"
|
|
153
|
+
|
|
154
|
+
Worker model: {model}
|
|
155
|
+
Check config: token-save-mcp doctor
|
|
156
|
+
""")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# hook install / uninstall
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _hook_dest() -> pathlib.Path:
|
|
165
|
+
d = pathlib.Path.home() / ".claude" / "token-save"
|
|
166
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
return d / "check-file-size"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _install_hook(min_lines: int, mode: str = "block") -> int:
|
|
171
|
+
if HOOK_SRC is None:
|
|
172
|
+
bad("hook script not found in the installed package or the source tree")
|
|
173
|
+
print(" Reinstall with: pip install --force-reinstall token-save-mcp")
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
dest = _hook_dest()
|
|
177
|
+
shutil.copy2(HOOK_SRC, dest)
|
|
178
|
+
dest.chmod(0o755)
|
|
179
|
+
ok(f"hook installed at {dest}")
|
|
180
|
+
|
|
181
|
+
settings = _claude_settings_path()
|
|
182
|
+
settings.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
cfg = json.loads(settings.read_text()) if settings.exists() else {}
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
bad(f"{settings} is not valid JSON — fix it first, nothing was changed")
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
if settings.exists():
|
|
191
|
+
backup = settings.with_suffix(f".json.bak-token-save")
|
|
192
|
+
shutil.copy2(settings, backup)
|
|
193
|
+
ok(f"backed up settings to {backup.name}")
|
|
194
|
+
|
|
195
|
+
pre = cfg.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
196
|
+
already = any(
|
|
197
|
+
e.get("matcher") == "Read"
|
|
198
|
+
and any("check-file-size" in h.get("command", "") for h in e.get("hooks", []))
|
|
199
|
+
for e in pre
|
|
200
|
+
)
|
|
201
|
+
if already:
|
|
202
|
+
ok("hook already registered in settings.json")
|
|
203
|
+
else:
|
|
204
|
+
pre.append({"matcher": "Read",
|
|
205
|
+
"hooks": [{"type": "command", "command": str(dest)}]})
|
|
206
|
+
settings.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
|
207
|
+
ok(f"hook registered for Read (threshold: {min_lines} lines, "
|
|
208
|
+
f"mode: {mode})")
|
|
209
|
+
|
|
210
|
+
env_notes = []
|
|
211
|
+
if min_lines != 350:
|
|
212
|
+
env_notes.append(f"TOKENSAVE_MIN_LINES={min_lines}")
|
|
213
|
+
if mode != "block":
|
|
214
|
+
env_notes.append(f"TOKENSAVE_HOOK_MODE={mode}")
|
|
215
|
+
if env_notes:
|
|
216
|
+
print(f" Set {' and '.join(env_notes)} in your environment to match.")
|
|
217
|
+
if mode == "warn":
|
|
218
|
+
print(f" {DIM}warn mode: large reads are allowed but flagged. "
|
|
219
|
+
f"Switch to block when you're ready.{RESET}")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_install_hook(args) -> int:
|
|
224
|
+
print("\ntoken-save-mcp — installing the enforcement hook\n")
|
|
225
|
+
rc = _install_hook(args.min_lines, args.hook_mode)
|
|
226
|
+
if rc == 0:
|
|
227
|
+
what = ("blocked and redirected to bulk_read" if args.hook_mode == "block"
|
|
228
|
+
else "allowed, but flagged with what they cost")
|
|
229
|
+
print(f"\n{GREEN}Done.{RESET} Reads over the threshold will now be {what}.\n"
|
|
230
|
+
f"Remove it with: token-save-mcp uninstall-hook\n")
|
|
231
|
+
return rc
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_uninstall_hook(args) -> int:
|
|
235
|
+
print("\ntoken-save-mcp — removing the hook\n")
|
|
236
|
+
settings = _claude_settings_path()
|
|
237
|
+
if not settings.exists():
|
|
238
|
+
warn("no settings.json — nothing to remove")
|
|
239
|
+
return 0
|
|
240
|
+
try:
|
|
241
|
+
cfg = json.loads(settings.read_text())
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
bad(f"{settings} is not valid JSON — nothing was changed")
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
pre = cfg.get("hooks", {}).get("PreToolUse", [])
|
|
247
|
+
kept = [
|
|
248
|
+
e for e in pre
|
|
249
|
+
if not (e.get("matcher") == "Read"
|
|
250
|
+
and any("check-file-size" in h.get("command", "")
|
|
251
|
+
for h in e.get("hooks", [])))
|
|
252
|
+
]
|
|
253
|
+
if len(kept) == len(pre):
|
|
254
|
+
warn("hook was not registered")
|
|
255
|
+
else:
|
|
256
|
+
cfg["hooks"]["PreToolUse"] = kept
|
|
257
|
+
settings.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
|
258
|
+
ok("hook removed from settings.json")
|
|
259
|
+
|
|
260
|
+
dest = pathlib.Path.home() / ".claude" / "token-save" / "check-file-size"
|
|
261
|
+
if dest.exists():
|
|
262
|
+
dest.unlink()
|
|
263
|
+
ok(f"deleted {dest}")
|
|
264
|
+
print()
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# doctor
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def cmd_doctor(args) -> int:
|
|
273
|
+
print("\ntoken-save-mcp — diagnostics\n")
|
|
274
|
+
problems = 0
|
|
275
|
+
|
|
276
|
+
# Python + deps
|
|
277
|
+
ok(f"python {sys.version.split()[0]}")
|
|
278
|
+
for mod in ("mcp", "openai"):
|
|
279
|
+
try:
|
|
280
|
+
__import__(mod)
|
|
281
|
+
ok(f"{mod} importable")
|
|
282
|
+
except ImportError:
|
|
283
|
+
bad(f"{mod} NOT installed — pip install token-save-mcp")
|
|
284
|
+
problems += 1
|
|
285
|
+
|
|
286
|
+
# Config — import the server module to resolve exactly as it would
|
|
287
|
+
try:
|
|
288
|
+
from token_save_mcp import server as S
|
|
289
|
+
ok(f"provider: {S.PROVIDER} -> {S.BASE_URL}")
|
|
290
|
+
ok(f"worker model: {S.DEFAULT_MODEL}")
|
|
291
|
+
ok(f"api key: {'set' if S.API_KEY else 'MISSING'}")
|
|
292
|
+
ok(f"threshold: {S.MIN_LINES} lines")
|
|
293
|
+
except SystemExit as exc:
|
|
294
|
+
bad(f"configuration error: {exc}")
|
|
295
|
+
problems += 1
|
|
296
|
+
S = None
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
bad(f"cannot load server: {type(exc).__name__}: {exc}")
|
|
299
|
+
problems += 1
|
|
300
|
+
S = None
|
|
301
|
+
|
|
302
|
+
# jq — the hook needs it
|
|
303
|
+
if shutil.which("jq"):
|
|
304
|
+
ok("jq present (required by the hook)")
|
|
305
|
+
else:
|
|
306
|
+
warn("jq not found — the hook will pass everything through. "
|
|
307
|
+
"Install it: apt install jq / brew install jq")
|
|
308
|
+
|
|
309
|
+
# MCP registration
|
|
310
|
+
if shutil.which("claude"):
|
|
311
|
+
res = subprocess.run(["claude", "mcp", "list"], capture_output=True, text=True)
|
|
312
|
+
if "token-save" in res.stdout:
|
|
313
|
+
line = next((l for l in res.stdout.splitlines() if "token-save" in l), "")
|
|
314
|
+
if "✔" in line or "Connected" in line:
|
|
315
|
+
ok("MCP server registered and connected")
|
|
316
|
+
else:
|
|
317
|
+
warn(f"MCP registered but not connected: {line.strip()[:120]}")
|
|
318
|
+
problems += 1
|
|
319
|
+
else:
|
|
320
|
+
warn("MCP server not registered — run: token-save-mcp init")
|
|
321
|
+
else:
|
|
322
|
+
warn("`claude` CLI not found — cannot check MCP registration")
|
|
323
|
+
|
|
324
|
+
# Hook
|
|
325
|
+
settings = _claude_settings_path()
|
|
326
|
+
if settings.exists():
|
|
327
|
+
try:
|
|
328
|
+
cfg = json.loads(settings.read_text())
|
|
329
|
+
pre = cfg.get("hooks", {}).get("PreToolUse", [])
|
|
330
|
+
if any("check-file-size" in h.get("command", "")
|
|
331
|
+
for e in pre for h in e.get("hooks", [])):
|
|
332
|
+
ok("enforcement hook installed")
|
|
333
|
+
else:
|
|
334
|
+
warn("hook not installed (optional) — token-save-mcp install-hook")
|
|
335
|
+
except json.JSONDecodeError:
|
|
336
|
+
bad(f"{settings} is not valid JSON")
|
|
337
|
+
problems += 1
|
|
338
|
+
|
|
339
|
+
# Live probe
|
|
340
|
+
if S is not None and not args.offline:
|
|
341
|
+
print("\n probing the worker…")
|
|
342
|
+
import asyncio
|
|
343
|
+
try:
|
|
344
|
+
res = asyncio.run(S._call(S.DEFAULT_MODEL, "Reply with: ok",
|
|
345
|
+
"Reply with: ok", "low"))
|
|
346
|
+
if res["ok"]:
|
|
347
|
+
ok(f"worker replied in {res['seconds']:.1f}s "
|
|
348
|
+
f"({res['in_tokens']} in / {res['out_tokens']} out)")
|
|
349
|
+
else:
|
|
350
|
+
bad(f"worker call failed: {res['text'][:200]}")
|
|
351
|
+
problems += 1
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
bad(f"worker call raised: {type(exc).__name__}: {exc}")
|
|
354
|
+
problems += 1
|
|
355
|
+
|
|
356
|
+
if problems:
|
|
357
|
+
print(f"\n{RED}{problems} problem(s) found.{RESET}\n")
|
|
358
|
+
return 1
|
|
359
|
+
print(f"\n{GREEN}All checks passed.{RESET}\n")
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# stats
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def _ledger_path() -> pathlib.Path:
|
|
368
|
+
return pathlib.Path(os.environ.get(
|
|
369
|
+
"TOKENSAVE_LEDGER", pathlib.Path.home() / ".token-save" / "ledger.jsonl"))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def cmd_stats(args) -> int:
|
|
373
|
+
"""Read back the local ledger. Nothing here ever left the machine."""
|
|
374
|
+
path = _ledger_path()
|
|
375
|
+
if not path.exists():
|
|
376
|
+
print(f"\nNo calls recorded yet ({path}).")
|
|
377
|
+
print("Run a bulk_read, then check back.\n")
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
rows = []
|
|
381
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
382
|
+
line = line.strip()
|
|
383
|
+
if not line:
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
rows.append(json.loads(line))
|
|
387
|
+
except json.JSONDecodeError:
|
|
388
|
+
continue # a truncated last line after a crash is not fatal
|
|
389
|
+
|
|
390
|
+
if args.since:
|
|
391
|
+
import time as _t
|
|
392
|
+
cutoff = _t.time() - args.since * 86400
|
|
393
|
+
rows = [r for r in rows if r.get("ts", 0) >= cutoff]
|
|
394
|
+
|
|
395
|
+
if not rows:
|
|
396
|
+
print("\nNo calls in that window.\n")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
direct = sum(r.get("direct_tokens", 0) for r in rows)
|
|
400
|
+
kept = sum(r.get("context_tokens", 0) for r in rows)
|
|
401
|
+
saved = direct - kept
|
|
402
|
+
lines = sum(r.get("lines", 0) for r in rows)
|
|
403
|
+
secs = sum(r.get("seconds", 0.0) for r in rows)
|
|
404
|
+
pct = (saved / direct * 100) if direct else 0.0
|
|
405
|
+
window = f"last {args.since} days" if args.since else "all time"
|
|
406
|
+
|
|
407
|
+
print(f"""
|
|
408
|
+
token-save-mcp — {window}
|
|
409
|
+
|
|
410
|
+
{len(rows):,} call{"" if len(rows) == 1 else "s"} · {lines:,} lines of code read by a worker
|
|
411
|
+
context saved: {GREEN}{saved:,} tokens{RESET} ({pct:.0f}%)
|
|
412
|
+
worker time: {secs:.0f}s total
|
|
413
|
+
""")
|
|
414
|
+
|
|
415
|
+
if args.badge:
|
|
416
|
+
label, message = "context saved", f"{_human(saved)} tokens"
|
|
417
|
+
url = (f"https://img.shields.io/badge/"
|
|
418
|
+
f"{label.replace(' ', '%20')}-{message.replace(' ', '%20')}-brightgreen")
|
|
419
|
+
print(" Markdown badge:\n")
|
|
420
|
+
print(f" \n")
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _human(n: int) -> str:
|
|
425
|
+
for unit, div in (("B", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)):
|
|
426
|
+
if n >= div:
|
|
427
|
+
return f"{n / div:.1f}{unit}".replace(".0", "")
|
|
428
|
+
return str(n)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def main(argv=None) -> int:
|
|
434
|
+
ap = argparse.ArgumentParser(
|
|
435
|
+
prog="token-save-mcp",
|
|
436
|
+
description="Keep big files out of your agent's context.",
|
|
437
|
+
)
|
|
438
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
439
|
+
|
|
440
|
+
p = sub.add_parser("init", help="register the MCP server (one command setup)")
|
|
441
|
+
p.add_argument("--provider", choices=sorted(PROVIDERS), default="ollama",
|
|
442
|
+
help="worker provider (default: ollama)")
|
|
443
|
+
p.add_argument("--model", help="worker model id (default: provider's preset)")
|
|
444
|
+
p.add_argument("--scope", choices=["user", "project", "local"], default="user")
|
|
445
|
+
p.add_argument("--hook", action="store_true",
|
|
446
|
+
help="also install the enforcement hook (blocks large Reads)")
|
|
447
|
+
p.add_argument("--min-lines", type=int, default=350)
|
|
448
|
+
p.add_argument("--hook-mode", choices=["block", "warn"], default="block",
|
|
449
|
+
help="block refuses a large Read; warn allows it but flags "
|
|
450
|
+
"the cost (default: block)")
|
|
451
|
+
p.add_argument("--force", action="store_true",
|
|
452
|
+
help="register even without a verified API key")
|
|
453
|
+
p.set_defaults(func=cmd_init)
|
|
454
|
+
|
|
455
|
+
p = sub.add_parser("install-hook", help="install the Read-blocking hook")
|
|
456
|
+
p.add_argument("--min-lines", type=int, default=350)
|
|
457
|
+
p.add_argument("--hook-mode", choices=["block", "warn"], default="block",
|
|
458
|
+
help="block refuses a large Read; warn allows it but flags "
|
|
459
|
+
"the cost (default: block)")
|
|
460
|
+
p.set_defaults(func=cmd_install_hook)
|
|
461
|
+
|
|
462
|
+
p = sub.add_parser("uninstall-hook", help="remove the hook")
|
|
463
|
+
p.set_defaults(func=cmd_uninstall_hook)
|
|
464
|
+
|
|
465
|
+
p = sub.add_parser("stats", help="how much context you have saved so far")
|
|
466
|
+
p.add_argument("--since", type=int, metavar="DAYS",
|
|
467
|
+
help="only count the last N days")
|
|
468
|
+
p.add_argument("--badge", action="store_true",
|
|
469
|
+
help="also print a markdown badge for your README")
|
|
470
|
+
p.set_defaults(func=cmd_stats)
|
|
471
|
+
|
|
472
|
+
p = sub.add_parser("doctor", help="diagnose the installation")
|
|
473
|
+
p.add_argument("--offline", action="store_true",
|
|
474
|
+
help="skip the live worker call")
|
|
475
|
+
p.set_defaults(func=cmd_doctor)
|
|
476
|
+
|
|
477
|
+
args = ap.parse_args(argv)
|
|
478
|
+
return args.func(args)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
if __name__ == "__main__":
|
|
482
|
+
sys.exit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# shunt — optional PreToolUse hook for Read.
|
|
3
|
+
#
|
|
4
|
+
# Blocks full-file reads above a line threshold and points Claude at the
|
|
5
|
+
# bulk_read MCP tool instead. OFF by default: it only does anything once it is
|
|
6
|
+
# registered in settings.json (see README). Run the tool by hand for a while
|
|
7
|
+
# first; turn this on only if you want the enforcement.
|
|
8
|
+
#
|
|
9
|
+
# Adapted from spotify/portal-ai-plugins (Apache-2.0), whose routing rules —
|
|
10
|
+
# let targeted reads through, let small files through, let missing files fall
|
|
11
|
+
# to Read's own error — are the part worth keeping.
|
|
12
|
+
|
|
13
|
+
MIN_LINES="${TOKENSAVE_MIN_LINES:-350}"
|
|
14
|
+
case "$MIN_LINES" in ''|*[!0-9]*) MIN_LINES=350 ;; esac
|
|
15
|
+
|
|
16
|
+
input=$(cat)
|
|
17
|
+
|
|
18
|
+
allow() { echo '{"decision": "allow"}'; exit 0; }
|
|
19
|
+
|
|
20
|
+
command -v jq >/dev/null 2>&1 || allow # no jq: never block, just get out of the way
|
|
21
|
+
|
|
22
|
+
# Malformed input is not our problem to report — stay silent and allow.
|
|
23
|
+
printf '%s' "$input" | jq -e . >/dev/null 2>&1 || allow
|
|
24
|
+
|
|
25
|
+
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
26
|
+
offset=$(printf '%s' "$input" | jq -r '.tool_input.offset // empty' 2>/dev/null)
|
|
27
|
+
limit=$(printf '%s' "$input" | jq -r '.tool_input.limit // empty' 2>/dev/null)
|
|
28
|
+
|
|
29
|
+
# A targeted read means Claude already knows what it wants — and is usually
|
|
30
|
+
# about to edit, which needs exact content. Never block those.
|
|
31
|
+
[ -n "$offset" ] || [ -n "$limit" ] && allow
|
|
32
|
+
|
|
33
|
+
# Empty path, missing file, or an unreadable one: let Read report it.
|
|
34
|
+
[ -z "$file_path" ] && allow
|
|
35
|
+
[ -f "$file_path" ] || allow
|
|
36
|
+
[ -r "$file_path" ] || allow
|
|
37
|
+
|
|
38
|
+
# Never block a binary: bulk_read would be useless on it anyway.
|
|
39
|
+
if command -v file >/dev/null 2>&1; then
|
|
40
|
+
case "$(file -b --mime-encoding "$file_path" 2>/dev/null)" in
|
|
41
|
+
binary) allow ;;
|
|
42
|
+
esac
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
lines=$(wc -l < "$file_path" 2>/dev/null | tr -d ' ')
|
|
46
|
+
case "$lines" in ''|*[!0-9]*) allow ;; esac # unreadable count: don't block
|
|
47
|
+
|
|
48
|
+
# Byte size matters as much as line count: a minified bundle or a one-line JSON
|
|
49
|
+
# blob is 1 line and megabytes of context. Block on either dimension.
|
|
50
|
+
bytes=$(wc -c < "$file_path" 2>/dev/null | tr -d ' ')
|
|
51
|
+
case "$bytes" in ''|*[!0-9]*) bytes=0 ;; esac
|
|
52
|
+
MAX_BYTES="${TOKENSAVE_HOOK_MAX_BYTES:-100000}"
|
|
53
|
+
case "$MAX_BYTES" in ''|*[!0-9]*) MAX_BYTES=100000 ;; esac
|
|
54
|
+
|
|
55
|
+
if [ "$lines" -le "$MIN_LINES" ] && [ "$bytes" -le "$MAX_BYTES" ]; then
|
|
56
|
+
allow
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Mode: "block" refuses the read outright; "warn" lets it through but tells the
|
|
60
|
+
# agent what it just cost. Blocking is the point of the tool, but it is also the
|
|
61
|
+
# fastest way to wreck someone's workflow — so the gentler mode is one env var
|
|
62
|
+
# away, and the message is identical either way.
|
|
63
|
+
MODE="${TOKENSAVE_HOOK_MODE:-block}"
|
|
64
|
+
case "$MODE" in block|warn) ;; *) MODE=block ;; esac
|
|
65
|
+
|
|
66
|
+
# Build the JSON with jq, never by interpolating into a heredoc: a file path may
|
|
67
|
+
# legally contain a quote, a backslash or a newline, any of which would produce
|
|
68
|
+
# malformed JSON. Claude Code cannot parse that, so the decision would silently
|
|
69
|
+
# be dropped — the hook would fail open exactly on the weirdest paths.
|
|
70
|
+
if [ "$MODE" = "warn" ]; then
|
|
71
|
+
jq -n --arg path "$file_path" --arg lines "$lines" '
|
|
72
|
+
{
|
|
73
|
+
decision: "allow",
|
|
74
|
+
reason: (
|
|
75
|
+
"token-save: reading \($lines) lines into context. bulk_read would " +
|
|
76
|
+
"have delegated this to a worker instead. (warn mode — set " +
|
|
77
|
+
"TOKENSAVE_HOOK_MODE=block to enforce.)"
|
|
78
|
+
)
|
|
79
|
+
}'
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
jq -n --arg path "$file_path" --arg lines "$lines" --arg min "$MIN_LINES" '
|
|
84
|
+
{
|
|
85
|
+
decision: "block",
|
|
86
|
+
reason: (
|
|
87
|
+
"This file is \($lines) lines (threshold: \($min)). Use the bulk_read " +
|
|
88
|
+
"MCP tool to delegate the read to a worker model instead of spending " +
|
|
89
|
+
"that context: bulk_read(question=\"<what you need to know>\", " +
|
|
90
|
+
"paths=[\"\($path)\"]). If you need exact content in order to EDIT this " +
|
|
91
|
+
"file, re-read it with an offset/limit for just the section you are " +
|
|
92
|
+
"changing — that is allowed through."
|
|
93
|
+
)
|
|
94
|
+
}'
|