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.
@@ -0,0 +1,3 @@
1
+ """token-save-mcp — keep big files out of your agent's context."""
2
+
3
+ __version__ = "0.1.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" ![token-save]({url})\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
+ }'