@ictechgy/context-guard 0.4.9 → 0.4.11
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.
- package/CHANGELOG.md +28 -0
- package/README.ko.md +59 -31
- package/README.md +85 -36
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +3 -0
- package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
- package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
- package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +30 -6
- package/package.json +4 -6
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +20 -14
- package/plugins/context-guard/README.md +26 -17
- package/plugins/context-guard/bin/context-guard +147 -25
- package/plugins/context-guard/bin/context-guard-artifact +884 -79
- package/plugins/context-guard/bin/context-guard-audit +33 -2
- package/plugins/context-guard/bin/context-guard-bench +1542 -31
- package/plugins/context-guard/bin/context-guard-cache-score +665 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +790 -6
- package/plugins/context-guard/bin/context-guard-experiments +463 -26
- package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +892 -49
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
- package/plugins/context-guard/bin/context-guard-trim-output +288 -41
- package/plugins/context-guard/brief/README.md +5 -5
- package/plugins/context-guard/lib/context_guard_commands.py +230 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -4348
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- package/context-guard-kit/trim_command_output.py +0 -1449
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
# (미지정 시 자기 옆 디렉토리만 사용; PATH 탐색 안 함)
|
|
22
22
|
set -u
|
|
23
23
|
|
|
24
|
+
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
25
|
+
printf 'ContextGuard helper: context-guard-statusline-merged\n'
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
24
29
|
statusline_input_tmp=''
|
|
25
30
|
|
|
26
31
|
statusline_tmp_base() {
|
|
@@ -23,15 +23,26 @@ from typing import Any, NoReturn
|
|
|
23
23
|
|
|
24
24
|
TOOL_NAME = "context-guard-tool-prune"
|
|
25
25
|
SCHEMA_VERSION = "contextguard.tool-prune.v1"
|
|
26
|
+
DEFER_SCHEMA_VERSION = "contextguard.tool-prune.defer.v1"
|
|
26
27
|
DEFAULT_STORE_DIR = ".context-guard/tool-prune"
|
|
27
28
|
DEFAULT_TOP = 5
|
|
29
|
+
DEFAULT_CORE_TOP = 3
|
|
30
|
+
DEFAULT_DEFERRED_TOP = 20
|
|
31
|
+
DEFAULT_NAMESPACE_TOP = 20
|
|
28
32
|
DEFAULT_BUDGET_BYTES = 12_000
|
|
29
33
|
DEFAULT_MAX_CATALOG_BYTES = 1_000_000
|
|
30
34
|
DEFAULT_MAX_OUTPUT_BYTES = 65_536
|
|
31
35
|
DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576
|
|
32
36
|
DEFAULT_MAX_RECEIPT_BYTES = 16_384
|
|
33
37
|
MAX_TOP = 200
|
|
38
|
+
MAX_DEFERRED_TOP = 1_000
|
|
39
|
+
MAX_NAMESPACE_TOP = 200
|
|
34
40
|
MAX_LABEL_CHARS = 160
|
|
41
|
+
NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
|
|
42
|
+
DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
|
|
43
|
+
DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
|
|
44
|
+
DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
|
|
45
|
+
DIR_FD_UNLINK_SUPPORTED = bool(os.supports_dir_fd and os.unlink in os.supports_dir_fd)
|
|
35
46
|
MAX_DESCRIPTION_CHARS = 360
|
|
36
47
|
MAX_OMITTED_TOOLS = 30
|
|
37
48
|
TOKEN_PROXY_CHARS_PER_TOKEN = 4
|
|
@@ -94,13 +105,17 @@ def byte_len_json(data: Any) -> int:
|
|
|
94
105
|
return byte_len_text(json_bytes(data))
|
|
95
106
|
|
|
96
107
|
|
|
108
|
+
def proxy_tokens(chars: int) -> int:
|
|
109
|
+
return max(0, (int(chars) + TOKEN_PROXY_CHARS_PER_TOKEN - 1) // TOKEN_PROXY_CHARS_PER_TOKEN)
|
|
110
|
+
|
|
111
|
+
|
|
97
112
|
def sha256_text(text: str) -> str:
|
|
98
113
|
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
99
114
|
|
|
100
115
|
|
|
101
116
|
def bounded_int(value: object, *, default: int, minimum: int, maximum: int, name: str) -> int:
|
|
102
117
|
try:
|
|
103
|
-
number = int(value)
|
|
118
|
+
number = int(default if value is None else value)
|
|
104
119
|
except (TypeError, ValueError, OverflowError):
|
|
105
120
|
fail(f"{name} must be an integer")
|
|
106
121
|
if number < minimum:
|
|
@@ -191,12 +206,26 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
|
|
|
191
206
|
|
|
192
207
|
|
|
193
208
|
def read_limited_path(path: Path, max_bytes: int) -> str:
|
|
209
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
210
|
+
fail("catalog reads require O_NOFOLLOW support")
|
|
211
|
+
reject_parent_traversal(path, label="catalog")
|
|
212
|
+
# Preserve clear diagnostics for stable symlink paths, then anchor the real
|
|
213
|
+
# read to an opened no-follow parent fd so parent/leaf swaps after this
|
|
214
|
+
# precheck still fail closed.
|
|
194
215
|
reject_symlink_components(path)
|
|
195
|
-
|
|
216
|
+
parent_fd = open_private_directory_no_follow(path.parent, label="catalog directory", create=False)
|
|
217
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
218
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
219
|
+
flags |= os.O_CLOEXEC
|
|
220
|
+
leaf = path.name
|
|
221
|
+
if leaf in {"", ".", ".."}:
|
|
222
|
+
os.close(parent_fd)
|
|
223
|
+
fail("catalog must name a regular file")
|
|
196
224
|
try:
|
|
197
|
-
fd = os.open(
|
|
225
|
+
fd = os.open(leaf, flags, dir_fd=parent_fd)
|
|
198
226
|
except OSError as exc:
|
|
199
|
-
|
|
227
|
+
os.close(parent_fd)
|
|
228
|
+
fail(f"catalog read failed: {os_error_detail(exc)}")
|
|
200
229
|
try:
|
|
201
230
|
st = os.fstat(fd)
|
|
202
231
|
if not stat.S_ISREG(st.st_mode):
|
|
@@ -206,6 +235,7 @@ def read_limited_path(path: Path, max_bytes: int) -> str:
|
|
|
206
235
|
data = os.read(fd, max_bytes + 1)
|
|
207
236
|
finally:
|
|
208
237
|
os.close(fd)
|
|
238
|
+
os.close(parent_fd)
|
|
209
239
|
if len(data) > max_bytes:
|
|
210
240
|
fail(f"catalog exceeds --max-catalog-bytes: > {max_bytes}")
|
|
211
241
|
return data.decode("utf-8", errors="replace")
|
|
@@ -399,15 +429,142 @@ def reject_symlink_components(path: Path) -> None:
|
|
|
399
429
|
fail(f"refusing path through non-directory component: {current}")
|
|
400
430
|
|
|
401
431
|
|
|
432
|
+
def dir_fd_replace_supported() -> bool:
|
|
433
|
+
try:
|
|
434
|
+
import inspect
|
|
435
|
+
|
|
436
|
+
signature = inspect.signature(os.replace)
|
|
437
|
+
except (TypeError, ValueError):
|
|
438
|
+
return True
|
|
439
|
+
return "src_dir_fd" in signature.parameters and "dst_dir_fd" in signature.parameters
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
DIR_FD_REPLACE_SUPPORTED = dir_fd_replace_supported()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def reject_parent_traversal(path: Path, *, label: str) -> None:
|
|
446
|
+
if ".." in path.parts:
|
|
447
|
+
fail(f"{label} must not contain parent traversal")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def os_error_detail(exc: OSError) -> str:
|
|
451
|
+
detail = exc.strerror or str(exc) or exc.__class__.__name__
|
|
452
|
+
if exc.errno is not None:
|
|
453
|
+
return f"{detail} (errno {exc.errno})"
|
|
454
|
+
return detail
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def no_follow_dir_flags() -> int:
|
|
458
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
459
|
+
fail("private store IO requires O_NOFOLLOW support")
|
|
460
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
461
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
462
|
+
flags |= os.O_CLOEXEC
|
|
463
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
464
|
+
flags |= os.O_DIRECTORY
|
|
465
|
+
return flags
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def private_temp_file_flags() -> int:
|
|
469
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
470
|
+
fail("private store IO requires O_NOFOLLOW support")
|
|
471
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
|
|
472
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
473
|
+
flags |= os.O_CLOEXEC
|
|
474
|
+
if hasattr(os, "O_NOCTTY"):
|
|
475
|
+
flags |= os.O_NOCTTY
|
|
476
|
+
return flags
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def open_private_directory_no_follow(path: Path, *, label: str, create: bool) -> int:
|
|
480
|
+
reject_parent_traversal(path, label=label)
|
|
481
|
+
path = normalize_allowed_first_absolute_symlink(path.expanduser())
|
|
482
|
+
if not DIR_FD_OPEN_SUPPORTED:
|
|
483
|
+
fail(f"{label} requires dir_fd open support")
|
|
484
|
+
if create and not DIR_FD_MKDIR_SUPPORTED:
|
|
485
|
+
fail(f"{label} requires dir_fd mkdir support")
|
|
486
|
+
flags = no_follow_dir_flags()
|
|
487
|
+
if path.is_absolute():
|
|
488
|
+
root_flags = os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0)
|
|
489
|
+
current_fd = os.open(path.anchor or os.sep, root_flags)
|
|
490
|
+
parts = path.parts[1:]
|
|
491
|
+
else:
|
|
492
|
+
current_fd = os.open(".", flags)
|
|
493
|
+
parts = path.parts
|
|
494
|
+
try:
|
|
495
|
+
for part in parts:
|
|
496
|
+
if part in {"", "."}:
|
|
497
|
+
continue
|
|
498
|
+
if part == "..":
|
|
499
|
+
fail(f"{label} must not contain parent traversal")
|
|
500
|
+
try:
|
|
501
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
502
|
+
except FileNotFoundError:
|
|
503
|
+
if not create:
|
|
504
|
+
raise
|
|
505
|
+
os.mkdir(part, 0o700, dir_fd=current_fd)
|
|
506
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
507
|
+
try:
|
|
508
|
+
if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
|
|
509
|
+
fail(f"{label} must not traverse non-directory components")
|
|
510
|
+
except Exception:
|
|
511
|
+
os.close(next_fd)
|
|
512
|
+
raise
|
|
513
|
+
os.close(current_fd)
|
|
514
|
+
current_fd = next_fd
|
|
515
|
+
owned_fd = current_fd
|
|
516
|
+
current_fd = -1
|
|
517
|
+
return owned_fd
|
|
518
|
+
except OSError as exc:
|
|
519
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
520
|
+
finally:
|
|
521
|
+
if current_fd >= 0:
|
|
522
|
+
os.close(current_fd)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def precheck_private_leaf(parent_fd: int, leaf: str, *, label: str) -> None:
|
|
526
|
+
if not DIR_FD_STAT_SUPPORTED:
|
|
527
|
+
fail(f"{label} requires dir_fd stat support")
|
|
528
|
+
try:
|
|
529
|
+
st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
|
|
530
|
+
except FileNotFoundError:
|
|
531
|
+
return
|
|
532
|
+
except OSError as exc:
|
|
533
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
534
|
+
if not stat.S_ISREG(st.st_mode):
|
|
535
|
+
fail(f"{label} must be missing or a regular file")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def write_all_fd(fd: int, data: bytes) -> None:
|
|
539
|
+
view = memoryview(data)
|
|
540
|
+
offset = 0
|
|
541
|
+
while offset < len(view):
|
|
542
|
+
written = os.write(fd, view[offset:])
|
|
543
|
+
if written <= 0:
|
|
544
|
+
raise OSError("short write")
|
|
545
|
+
offset += written
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def fsync_best_effort(fd: int) -> None:
|
|
549
|
+
try:
|
|
550
|
+
os.fsync(fd)
|
|
551
|
+
except OSError:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
|
|
402
555
|
def ensure_private_dir(path: Path) -> None:
|
|
403
|
-
path =
|
|
404
|
-
reject_symlink_components(path)
|
|
556
|
+
reject_parent_traversal(path, label="store directory")
|
|
405
557
|
try:
|
|
406
|
-
path
|
|
407
|
-
reject_symlink_components(path)
|
|
408
|
-
os.chmod(path, 0o700)
|
|
558
|
+
fd = open_private_directory_no_follow(path, label="store directory", create=True)
|
|
409
559
|
except OSError as exc:
|
|
410
560
|
fail(f"store directory unavailable: {exc}")
|
|
561
|
+
try:
|
|
562
|
+
try:
|
|
563
|
+
os.fchmod(fd, 0o700)
|
|
564
|
+
except OSError:
|
|
565
|
+
pass
|
|
566
|
+
finally:
|
|
567
|
+
os.close(fd)
|
|
411
568
|
|
|
412
569
|
|
|
413
570
|
def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: int, label: str) -> int:
|
|
@@ -415,43 +572,77 @@ def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: in
|
|
|
415
572
|
size = byte_len_text(text)
|
|
416
573
|
if size > max_bytes:
|
|
417
574
|
fail(f"{label} exceeds size cap: {size} > {max_bytes}")
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
575
|
+
reject_parent_traversal(path, label=label)
|
|
576
|
+
if not DIR_FD_REPLACE_SUPPORTED:
|
|
577
|
+
fail(f"{label} write requires dir_fd replace support")
|
|
578
|
+
if not DIR_FD_UNLINK_SUPPORTED:
|
|
579
|
+
fail(f"{label} write requires dir_fd unlink support")
|
|
580
|
+
if not DIR_FD_STAT_SUPPORTED:
|
|
581
|
+
fail(f"{label} write requires dir_fd stat support")
|
|
582
|
+
parent_fd = open_private_directory_no_follow(path.parent, label="store directory", create=True)
|
|
583
|
+
fd = -1
|
|
584
|
+
temp_leaf: str | None = None
|
|
421
585
|
try:
|
|
422
|
-
fd = os.open(str(tmp), flags, 0o600)
|
|
423
|
-
except OSError as exc:
|
|
424
|
-
fail(f"{label} write failed: {exc}")
|
|
425
|
-
try:
|
|
426
|
-
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
427
|
-
handle.write(text)
|
|
428
|
-
handle.flush()
|
|
429
|
-
try:
|
|
430
|
-
os.fsync(handle.fileno())
|
|
431
|
-
except OSError:
|
|
432
|
-
pass
|
|
433
|
-
os.replace(tmp, path)
|
|
434
586
|
try:
|
|
435
|
-
os.
|
|
587
|
+
os.fchmod(parent_fd, 0o700)
|
|
436
588
|
except OSError:
|
|
437
589
|
pass
|
|
590
|
+
leaf = path.name
|
|
591
|
+
if leaf in {"", ".", ".."}:
|
|
592
|
+
fail(f"{label} must name a regular file")
|
|
593
|
+
precheck_private_leaf(parent_fd, leaf, label=label)
|
|
594
|
+
for _attempt in range(20):
|
|
595
|
+
candidate = f".{leaf}.{os.getpid()}.{time.time_ns()}.tmp"
|
|
596
|
+
try:
|
|
597
|
+
fd = os.open(candidate, private_temp_file_flags(), 0o600, dir_fd=parent_fd)
|
|
598
|
+
temp_leaf = candidate
|
|
599
|
+
break
|
|
600
|
+
except FileExistsError:
|
|
601
|
+
continue
|
|
602
|
+
if fd < 0 or temp_leaf is None:
|
|
603
|
+
fail(f"{label} write failed: could not create temporary file")
|
|
604
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
605
|
+
fail(f"{label} temporary file must be a regular file")
|
|
606
|
+
os.fchmod(fd, 0o600)
|
|
607
|
+
write_all_fd(fd, text.encode("utf-8"))
|
|
608
|
+
fsync_best_effort(fd)
|
|
609
|
+
os.close(fd)
|
|
610
|
+
fd = -1
|
|
611
|
+
fsync_best_effort(parent_fd)
|
|
612
|
+
os.replace(temp_leaf, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
613
|
+
temp_leaf = None
|
|
614
|
+
fsync_best_effort(parent_fd)
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
fail(f"{label} write failed: {os_error_detail(exc)}")
|
|
438
617
|
except Exception:
|
|
439
|
-
try:
|
|
440
|
-
tmp.unlink()
|
|
441
|
-
except OSError:
|
|
442
|
-
pass
|
|
443
618
|
raise
|
|
619
|
+
finally:
|
|
620
|
+
if fd >= 0:
|
|
621
|
+
os.close(fd)
|
|
622
|
+
if temp_leaf is not None:
|
|
623
|
+
try:
|
|
624
|
+
os.unlink(temp_leaf, dir_fd=parent_fd)
|
|
625
|
+
except OSError:
|
|
626
|
+
pass
|
|
627
|
+
os.close(parent_fd)
|
|
444
628
|
return size
|
|
445
629
|
|
|
446
630
|
|
|
447
631
|
def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, int]:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
flags = os.O_RDONLY |
|
|
632
|
+
reject_parent_traversal(path, label=label)
|
|
633
|
+
parent_fd = open_private_directory_no_follow(path.parent, label=f"{label} directory", create=False)
|
|
634
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
635
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
636
|
+
flags |= os.O_CLOEXEC
|
|
637
|
+
leaf = path.name
|
|
638
|
+
if leaf in {"", ".", ".."}:
|
|
639
|
+
os.close(parent_fd)
|
|
640
|
+
fail(f"{label} must name a regular file")
|
|
451
641
|
try:
|
|
452
|
-
fd = os.open(
|
|
642
|
+
fd = os.open(leaf, flags, dir_fd=parent_fd)
|
|
453
643
|
except OSError as exc:
|
|
454
|
-
|
|
644
|
+
os.close(parent_fd)
|
|
645
|
+
fail(f"{label} read failed: {os_error_detail(exc)}")
|
|
455
646
|
try:
|
|
456
647
|
st = os.fstat(fd)
|
|
457
648
|
if not stat.S_ISREG(st.st_mode):
|
|
@@ -461,32 +652,16 @@ def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, i
|
|
|
461
652
|
data = os.read(fd, max_bytes + 1)
|
|
462
653
|
finally:
|
|
463
654
|
os.close(fd)
|
|
655
|
+
os.close(parent_fd)
|
|
464
656
|
if len(data) > max_bytes:
|
|
465
657
|
fail(f"{label} exceeds trusted size cap: > {max_bytes}")
|
|
466
658
|
return data.decode("utf-8", errors="replace"), len(data)
|
|
467
659
|
|
|
468
660
|
|
|
469
661
|
def read_private_json(path: Path, *, max_bytes: int, label: str) -> dict[str, Any]:
|
|
470
|
-
|
|
471
|
-
fail(f"{label} must not be a symlink")
|
|
472
|
-
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
662
|
+
text, _size = read_private_text(path, max_bytes=max_bytes, label=label)
|
|
473
663
|
try:
|
|
474
|
-
|
|
475
|
-
except OSError as exc:
|
|
476
|
-
fail(f"{label} read failed: {exc}")
|
|
477
|
-
try:
|
|
478
|
-
st = os.fstat(fd)
|
|
479
|
-
if not stat.S_ISREG(st.st_mode):
|
|
480
|
-
fail(f"{label} must be a regular file")
|
|
481
|
-
if st.st_size > max_bytes:
|
|
482
|
-
fail(f"{label} exceeds trusted size cap: {st.st_size} > {max_bytes}")
|
|
483
|
-
data = os.read(fd, max_bytes + 1)
|
|
484
|
-
finally:
|
|
485
|
-
os.close(fd)
|
|
486
|
-
if len(data) > max_bytes:
|
|
487
|
-
fail(f"{label} exceeds trusted size cap: > {max_bytes}")
|
|
488
|
-
try:
|
|
489
|
-
parsed = json.loads(data.decode("utf-8", errors="replace"))
|
|
664
|
+
parsed = json.loads(text)
|
|
490
665
|
except json.JSONDecodeError as exc:
|
|
491
666
|
fail(f"{label} is malformed JSON: {exc.msg}")
|
|
492
667
|
if not isinstance(parsed, dict):
|
|
@@ -583,6 +758,86 @@ def selected_tool_record(cand: Candidate, receipt_id: str, budget_left: int, *,
|
|
|
583
758
|
return record, 0
|
|
584
759
|
|
|
585
760
|
|
|
761
|
+
def deferred_tool_record(cand: Candidate, receipt_id: str, *, store_dir: str) -> dict[str, Any]:
|
|
762
|
+
return {
|
|
763
|
+
"name": cand.name,
|
|
764
|
+
"server": cand.server,
|
|
765
|
+
"score": cand.score,
|
|
766
|
+
"rank": cand.rank,
|
|
767
|
+
"description": cand.description,
|
|
768
|
+
"schema_bytes": byte_len_json(cand.schema),
|
|
769
|
+
"reason": "deferred_after_core_top",
|
|
770
|
+
"retrieval": retrieval_command(receipt_id, store_dir=store_dir, tool_name=cand.name),
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def namespace_records(
|
|
775
|
+
ranked: list[Candidate],
|
|
776
|
+
core_names: set[str],
|
|
777
|
+
deferred_names: set[str],
|
|
778
|
+
receipt_id: str,
|
|
779
|
+
*,
|
|
780
|
+
store_dir: str,
|
|
781
|
+
namespace_top: int,
|
|
782
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
783
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
784
|
+
for cand in ranked:
|
|
785
|
+
namespace = cand.server or "local"
|
|
786
|
+
item = grouped.setdefault(
|
|
787
|
+
namespace,
|
|
788
|
+
{
|
|
789
|
+
"namespace": namespace,
|
|
790
|
+
"tool_count": 0,
|
|
791
|
+
"core_count": 0,
|
|
792
|
+
"listed_deferred_count": 0,
|
|
793
|
+
"sample_tools": [],
|
|
794
|
+
"retrieval": retrieval_command(receipt_id, store_dir=store_dir),
|
|
795
|
+
},
|
|
796
|
+
)
|
|
797
|
+
item["tool_count"] += 1
|
|
798
|
+
if cand.name in core_names:
|
|
799
|
+
item["core_count"] += 1
|
|
800
|
+
if cand.name in deferred_names:
|
|
801
|
+
item["listed_deferred_count"] += 1
|
|
802
|
+
samples = item["sample_tools"]
|
|
803
|
+
if isinstance(samples, list) and len(samples) < 8:
|
|
804
|
+
samples.append(cand.name)
|
|
805
|
+
records = sorted(grouped.values(), key=lambda item: (-int(item["listed_deferred_count"]), str(item["namespace"])))
|
|
806
|
+
return records[:namespace_top], max(0, len(records) - namespace_top)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def build_receipt_and_payload(ranked: list[Candidate], safe_query: str, total_redactions: int, *, store_dir_arg: str, max_payload_bytes: int, max_receipt_bytes: int) -> tuple[str, dict[str, Any], dict[str, Any], Path, Path, Path, int, int]:
|
|
810
|
+
payload_without_id = build_payload("pending", ranked, safe_query, total_redactions)
|
|
811
|
+
receipt_id = build_receipt_id(payload_without_id)
|
|
812
|
+
payload = build_payload(receipt_id, ranked, safe_query, total_redactions)
|
|
813
|
+
payload_text = json_bytes(payload, indent=2) + "\n"
|
|
814
|
+
payload_bytes = byte_len_text(payload_text)
|
|
815
|
+
if payload_bytes > max_payload_bytes:
|
|
816
|
+
fail(f"payload exceeds --max-payload-bytes: {payload_bytes} > {max_payload_bytes}")
|
|
817
|
+
payload_sha = sha256_text(payload_text.rstrip("\n"))
|
|
818
|
+
|
|
819
|
+
store_dir, receipt_path, payload_path = store_paths(store_dir_arg, receipt_id)
|
|
820
|
+
receipt = {
|
|
821
|
+
"tool": TOOL_NAME,
|
|
822
|
+
"schema_version": SCHEMA_VERSION,
|
|
823
|
+
"receipt_id": receipt_id,
|
|
824
|
+
"created_at_unix": int(time.time()),
|
|
825
|
+
"path": display_path(receipt_path),
|
|
826
|
+
"payload_path": display_path(payload_path),
|
|
827
|
+
"payload_sha256": payload_sha,
|
|
828
|
+
"payload_bytes": payload_bytes,
|
|
829
|
+
"contains": "compact_metadata_plus_sanitized_payload",
|
|
830
|
+
"tool_count": len(ranked),
|
|
831
|
+
"tools": [cand.name for cand in ranked[:50]],
|
|
832
|
+
"tools_truncated": len(ranked) > 50,
|
|
833
|
+
"retrieval_hint": retrieval_command(receipt_id, store_dir=store_dir_arg, tool_name="<name>"),
|
|
834
|
+
}
|
|
835
|
+
receipt_size = byte_len_text(json_bytes(receipt, indent=2) + "\n")
|
|
836
|
+
if receipt_size > max_receipt_bytes:
|
|
837
|
+
fail(f"receipt exceeds --max-receipt-bytes: {receipt_size} > {max_receipt_bytes}")
|
|
838
|
+
return receipt_id, payload, receipt, store_dir, receipt_path, payload_path, payload_bytes, receipt_size
|
|
839
|
+
|
|
840
|
+
|
|
586
841
|
def shrink_result_for_output(result: dict[str, Any], max_output_bytes: int) -> str:
|
|
587
842
|
candidate = json_bytes(result, indent=2) + "\n"
|
|
588
843
|
if byte_len_text(candidate) <= max_output_bytes:
|
|
@@ -591,6 +846,7 @@ def shrink_result_for_output(result: dict[str, Any], max_output_bytes: int) -> s
|
|
|
591
846
|
result = json.loads(json_bytes(result))
|
|
592
847
|
omitted = result.get("omitted_tools")
|
|
593
848
|
while isinstance(omitted, list) and len(omitted) > 0:
|
|
849
|
+
# The list is halved on each pass, so even a one-item list converges.
|
|
594
850
|
keep = max(0, len(omitted) // 2)
|
|
595
851
|
result["omitted_tools"] = omitted[:keep]
|
|
596
852
|
result["omitted_tools_truncated"] = True
|
|
@@ -699,6 +955,160 @@ def select_catalog(args: argparse.Namespace) -> str:
|
|
|
699
955
|
return rendered
|
|
700
956
|
|
|
701
957
|
|
|
958
|
+
def defer_report(args: argparse.Namespace) -> str:
|
|
959
|
+
max_catalog_bytes = bounded_int(args.max_catalog_bytes, default=DEFAULT_MAX_CATALOG_BYTES, minimum=1, maximum=100_000_000, name="--max-catalog-bytes")
|
|
960
|
+
max_output_bytes = bounded_int(args.max_output_bytes, default=DEFAULT_MAX_OUTPUT_BYTES, minimum=1, maximum=10_000_000, name="--max-output-bytes")
|
|
961
|
+
max_payload_bytes = bounded_int(args.max_payload_bytes, default=DEFAULT_MAX_PAYLOAD_BYTES, minimum=1, maximum=100_000_000, name="--max-payload-bytes")
|
|
962
|
+
max_receipt_bytes = bounded_int(args.max_receipt_bytes, default=DEFAULT_MAX_RECEIPT_BYTES, minimum=1, maximum=10_000_000, name="--max-receipt-bytes")
|
|
963
|
+
core_top = bounded_int(args.core_top, default=DEFAULT_CORE_TOP, minimum=1, maximum=MAX_TOP, name="--core-top")
|
|
964
|
+
deferred_top = bounded_int(args.deferred_top, default=DEFAULT_DEFERRED_TOP, minimum=0, maximum=MAX_DEFERRED_TOP, name="--deferred-top")
|
|
965
|
+
namespace_top = bounded_int(args.namespace_top, default=DEFAULT_NAMESPACE_TOP, minimum=0, maximum=MAX_NAMESPACE_TOP, name="--namespace-top")
|
|
966
|
+
budget_bytes = bounded_int(args.budget_bytes, default=DEFAULT_BUDGET_BYTES, minimum=0, maximum=100_000_000, name="--budget-bytes")
|
|
967
|
+
|
|
968
|
+
text = read_limited_path(Path(args.catalog), max_catalog_bytes) if args.catalog else read_limited_stdin(max_catalog_bytes)
|
|
969
|
+
raw, redactions = parse_catalog_text(text)
|
|
970
|
+
raw_query = args.query or ""
|
|
971
|
+
safe_query, query_redactions = redact_string(raw_query)
|
|
972
|
+
total_redactions = redactions + query_redactions
|
|
973
|
+
ranked = rank_candidates(normalize_catalog(raw), raw_query)
|
|
974
|
+
(
|
|
975
|
+
receipt_id,
|
|
976
|
+
payload,
|
|
977
|
+
receipt,
|
|
978
|
+
store_dir,
|
|
979
|
+
receipt_path,
|
|
980
|
+
payload_path,
|
|
981
|
+
payload_bytes,
|
|
982
|
+
receipt_size,
|
|
983
|
+
) = build_receipt_and_payload(
|
|
984
|
+
ranked,
|
|
985
|
+
safe_query,
|
|
986
|
+
total_redactions,
|
|
987
|
+
store_dir_arg=args.store_dir,
|
|
988
|
+
max_payload_bytes=max_payload_bytes,
|
|
989
|
+
max_receipt_bytes=max_receipt_bytes,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
core_candidates = ranked[:core_top]
|
|
993
|
+
deferred_candidates = ranked[core_top:core_top + deferred_top]
|
|
994
|
+
core_tools: list[dict[str, Any]] = []
|
|
995
|
+
core_schema_bytes = 0
|
|
996
|
+
for cand in core_candidates:
|
|
997
|
+
record, used = selected_tool_record(cand, receipt_id, budget_bytes - core_schema_bytes, store_dir=args.store_dir)
|
|
998
|
+
core_schema_bytes += used
|
|
999
|
+
core_tools.append(record)
|
|
1000
|
+
deferred_tools = [deferred_tool_record(cand, receipt_id, store_dir=args.store_dir) for cand in deferred_candidates]
|
|
1001
|
+
core_names = {cand.name for cand in core_candidates}
|
|
1002
|
+
deferred_names = {cand.name for cand in deferred_candidates}
|
|
1003
|
+
deferred_namespaces, deferred_namespaces_truncated_count = namespace_records(
|
|
1004
|
+
ranked,
|
|
1005
|
+
core_names,
|
|
1006
|
+
deferred_names,
|
|
1007
|
+
receipt_id,
|
|
1008
|
+
store_dir=args.store_dir,
|
|
1009
|
+
namespace_top=namespace_top,
|
|
1010
|
+
)
|
|
1011
|
+
all_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked)
|
|
1012
|
+
listed_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in deferred_candidates)
|
|
1013
|
+
total_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked[core_top:])
|
|
1014
|
+
tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
|
|
1015
|
+
all_schema_tokens = proxy_tokens(all_schema_bytes)
|
|
1016
|
+
inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
|
|
1017
|
+
listed_deferred_schema_tokens = proxy_tokens(listed_deferred_schema_bytes)
|
|
1018
|
+
total_deferred_schema_tokens = proxy_tokens(total_deferred_schema_bytes)
|
|
1019
|
+
tool_stub_report_tokens = proxy_tokens(tool_stub_report_bytes)
|
|
1020
|
+
result = {
|
|
1021
|
+
"tool": TOOL_NAME,
|
|
1022
|
+
"schema_version": DEFER_SCHEMA_VERSION,
|
|
1023
|
+
"mode": "defer-report",
|
|
1024
|
+
"query": safe_query,
|
|
1025
|
+
"core_top": core_top,
|
|
1026
|
+
"deferred_top": deferred_top,
|
|
1027
|
+
"namespace_top": namespace_top,
|
|
1028
|
+
"candidate_count": len(ranked),
|
|
1029
|
+
"native_provider_integration": False,
|
|
1030
|
+
"core_tools": core_tools,
|
|
1031
|
+
"deferred_tools": deferred_tools,
|
|
1032
|
+
"listed_deferred_count": len(deferred_tools),
|
|
1033
|
+
"total_deferred_count": max(0, len(ranked) - core_top),
|
|
1034
|
+
"deferred_tools_truncated_count": max(0, len(ranked) - core_top - len(deferred_tools)),
|
|
1035
|
+
"deferred_namespaces": deferred_namespaces,
|
|
1036
|
+
"deferred_namespaces_truncated_count": deferred_namespaces_truncated_count,
|
|
1037
|
+
"deferred_schema_retrieval_required_before_use": True,
|
|
1038
|
+
"receipt": {
|
|
1039
|
+
**receipt,
|
|
1040
|
+
"bytes": receipt_size,
|
|
1041
|
+
},
|
|
1042
|
+
"token_proxy": {
|
|
1043
|
+
"measurement": "estimated",
|
|
1044
|
+
"method": "char4_proxy",
|
|
1045
|
+
"chars_per_token": TOKEN_PROXY_CHARS_PER_TOKEN,
|
|
1046
|
+
"all_schema_bytes": all_schema_bytes,
|
|
1047
|
+
"inline_core_schema_bytes": core_schema_bytes,
|
|
1048
|
+
"listed_deferred_schema_bytes": listed_deferred_schema_bytes,
|
|
1049
|
+
"total_deferred_schema_bytes": total_deferred_schema_bytes,
|
|
1050
|
+
"tool_stub_report_bytes": tool_stub_report_bytes,
|
|
1051
|
+
"all_schema_tokens_estimated": all_schema_tokens,
|
|
1052
|
+
"inline_core_schema_tokens_estimated": inline_core_schema_tokens,
|
|
1053
|
+
"listed_deferred_schema_tokens_estimated": listed_deferred_schema_tokens,
|
|
1054
|
+
"total_deferred_schema_tokens_estimated": total_deferred_schema_tokens,
|
|
1055
|
+
"tool_stub_report_tokens_estimated": tool_stub_report_tokens,
|
|
1056
|
+
"gross_listed_deferred_schema_tokens_avoided": listed_deferred_schema_tokens,
|
|
1057
|
+
"gross_total_deferred_schema_tokens_avoided": total_deferred_schema_tokens,
|
|
1058
|
+
"net_initial_report_tokens_delta": tool_stub_report_tokens - all_schema_tokens,
|
|
1059
|
+
"net_initial_report_tokens_delta_semantics": "tool_stub_report_tokens_estimated_minus_all_schema_tokens_estimated",
|
|
1060
|
+
"estimated_initial_schema_tokens_avoided": max(0, all_schema_tokens - tool_stub_report_tokens),
|
|
1061
|
+
"estimated_initial_schema_tokens_avoided_semantics": "max(0, all_schema_tokens_estimated - tool_stub_report_tokens_estimated)",
|
|
1062
|
+
"claim_boundary": "proxy_only_not_provider_billed_tokens",
|
|
1063
|
+
},
|
|
1064
|
+
"provider_patterns": [
|
|
1065
|
+
{
|
|
1066
|
+
"provider": "openai",
|
|
1067
|
+
"pattern": "Keep only core tool schemas inline; retrieve deferred schemas through app/tool-search plumbing or the local receipt before invoking a deferred tool.",
|
|
1068
|
+
"native_provider_integration": False,
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
"provider": "anthropic",
|
|
1072
|
+
"pattern": "Keep stable, frequently used tool definitions in the cacheable prefix; treat deferred tools as application-managed retrieval, not Claude-native lazy loading.",
|
|
1073
|
+
"native_provider_integration": False,
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
"provider": "gemini",
|
|
1077
|
+
"pattern": "Group large tool catalogs by namespace and load only the task-relevant subset before the model call; verify any platform-native tool retrieval separately.",
|
|
1078
|
+
"native_provider_integration": False,
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
"claim_boundary": {
|
|
1082
|
+
"advisory_only": True,
|
|
1083
|
+
"native_provider_integration": False,
|
|
1084
|
+
"provider_tool_search_configured": False,
|
|
1085
|
+
"hosted_api_token_or_cost_savings_claim_allowed": False,
|
|
1086
|
+
"requires_provider_measured_matched_tasks_for_savings_claims": True,
|
|
1087
|
+
"deferred_schema_retrieval_required_before_use": True,
|
|
1088
|
+
},
|
|
1089
|
+
"redaction": {"redacted_values": total_redactions},
|
|
1090
|
+
"caveats": [
|
|
1091
|
+
"Deferred loading is an application strategy report, not a native provider integration.",
|
|
1092
|
+
"Token proxy values are char/4 estimates over sanitized local JSON, not billed provider tokens.",
|
|
1093
|
+
"Deferred schema token fields are initial-prompt proxy accounting; full schemas must be retrieved before deferred tool use.",
|
|
1094
|
+
"Use receipt get commands to retrieve full sanitized schemas before using deferred tools.",
|
|
1095
|
+
],
|
|
1096
|
+
}
|
|
1097
|
+
rendered = json_bytes(result, indent=2) + "\n"
|
|
1098
|
+
if byte_len_text(rendered) > max_output_bytes:
|
|
1099
|
+
fail(f"defer report exceeds --max-output-bytes: {byte_len_text(rendered)} > {max_output_bytes}")
|
|
1100
|
+
|
|
1101
|
+
# Only write after every size gate has passed, so failures leave no success receipt.
|
|
1102
|
+
ensure_private_dir(store_dir)
|
|
1103
|
+
written_payload_bytes = write_private_json_atomic(payload_path, payload, max_bytes=max_payload_bytes, label="payload")
|
|
1104
|
+
if written_payload_bytes != payload_bytes:
|
|
1105
|
+
fail("payload byte size changed during write")
|
|
1106
|
+
written_receipt_bytes = write_private_json_atomic(receipt_path, receipt, max_bytes=max_receipt_bytes, label="receipt")
|
|
1107
|
+
if written_receipt_bytes != receipt_size:
|
|
1108
|
+
fail("receipt byte size changed during write")
|
|
1109
|
+
return rendered
|
|
1110
|
+
|
|
1111
|
+
|
|
702
1112
|
def payload_path_from_receipt(store_dir: Path, receipt_id: str, receipt: dict[str, Any]) -> Path:
|
|
703
1113
|
expected_name = f"{receipt_id}.payload.json"
|
|
704
1114
|
raw = str(receipt.get("payload_path") or "")
|
|
@@ -803,6 +1213,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
803
1213
|
select.add_argument("--store-dir", default=DEFAULT_STORE_DIR, help=f"receipt/payload directory (default: {DEFAULT_STORE_DIR})")
|
|
804
1214
|
select.add_argument("--json", action="store_true", help="emit JSON (default and only stable output contract)")
|
|
805
1215
|
|
|
1216
|
+
defer = sub.add_parser("defer-report", help="split a local catalog into core inline tools plus deferred receipt-backed tools")
|
|
1217
|
+
defer.add_argument("--catalog", help="catalog JSON path; stdin is used when omitted")
|
|
1218
|
+
defer.add_argument("--query", default="", help="task query used for lexical ranking")
|
|
1219
|
+
defer.add_argument("--core-top", default=DEFAULT_CORE_TOP, help=f"number of core inline tools (default: {DEFAULT_CORE_TOP})")
|
|
1220
|
+
defer.add_argument("--deferred-top", default=DEFAULT_DEFERRED_TOP, help=f"number of deferred tool stubs to list (default: {DEFAULT_DEFERRED_TOP})")
|
|
1221
|
+
defer.add_argument("--namespace-top", default=DEFAULT_NAMESPACE_TOP, help=f"number of deferred namespace summaries to list (default: {DEFAULT_NAMESPACE_TOP})")
|
|
1222
|
+
defer.add_argument("--budget-bytes", default=DEFAULT_BUDGET_BYTES, help=f"inline core schema byte budget (default: {DEFAULT_BUDGET_BYTES})")
|
|
1223
|
+
defer.add_argument("--max-catalog-bytes", default=DEFAULT_MAX_CATALOG_BYTES, help=f"maximum catalog JSON bytes (default: {DEFAULT_MAX_CATALOG_BYTES})")
|
|
1224
|
+
defer.add_argument("--max-output-bytes", default=DEFAULT_MAX_OUTPUT_BYTES, help=f"maximum rendered defer JSON bytes (default: {DEFAULT_MAX_OUTPUT_BYTES})")
|
|
1225
|
+
defer.add_argument("--max-payload-bytes", default=DEFAULT_MAX_PAYLOAD_BYTES, help=f"maximum sanitized payload bytes (default: {DEFAULT_MAX_PAYLOAD_BYTES})")
|
|
1226
|
+
defer.add_argument("--max-receipt-bytes", default=DEFAULT_MAX_RECEIPT_BYTES, help=f"maximum compact receipt bytes (default: {DEFAULT_MAX_RECEIPT_BYTES})")
|
|
1227
|
+
defer.add_argument("--store-dir", default=DEFAULT_STORE_DIR, help=f"receipt/payload directory (default: {DEFAULT_STORE_DIR})")
|
|
1228
|
+
defer.add_argument("--json", action="store_true", help="emit JSON (default and only stable output contract)")
|
|
1229
|
+
|
|
806
1230
|
get = sub.add_parser("get", help="retrieve a full sanitized schema from a receipt payload")
|
|
807
1231
|
get.add_argument("receipt_id", help="receipt id returned by select")
|
|
808
1232
|
get.add_argument("--tool", help="tool name to retrieve; omit to list available names")
|
|
@@ -821,6 +1245,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
821
1245
|
if args.command == "select":
|
|
822
1246
|
sys.stdout.write(select_catalog(args))
|
|
823
1247
|
return 0
|
|
1248
|
+
if args.command == "defer-report":
|
|
1249
|
+
sys.stdout.write(defer_report(args))
|
|
1250
|
+
return 0
|
|
824
1251
|
if args.command == "get":
|
|
825
1252
|
sys.stdout.write(get_schema(args))
|
|
826
1253
|
return 0
|