generic-ml-cache-cli 0.2.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,869 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # PYTHON_ARGCOMPLETE_OK
4
+ """Command-line interface for generic-ml-cache.
5
+
6
+ gmlcache run -- resolve a request (record on miss, replay on hit)
7
+ gmlcache doctor -- report which configured clients are present (advisory)
8
+ gmlcache models -- list a client's available models (advisory; relayed)
9
+ gmlcache status -- show the resolved configuration and where it came from
10
+ gmlcache init -- create the config file in the default location (if absent)
11
+ gmlcache inspect -- pretty-print a stored execution
12
+
13
+ Replay fidelity: in the default (quiet) mode, ``run`` reproduces the client's
14
+ stdout, stderr and exit code exactly. Cache diagnostics appear only with
15
+ ``-v/--verbose`` and are written to stderr with a ``gmlc:`` prefix, which by
16
+ design breaks byte-exact stderr fidelity -- use quiet mode when that matters.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Dict, List, Optional
27
+
28
+ try:
29
+ import argcomplete
30
+ except ImportError: # completion is a convenience; never let its absence break the CLI
31
+ argcomplete = None
32
+
33
+ from generic_ml_cache_core.adapter.inbound.composition import build_use_cases
34
+ from generic_ml_cache_core.adapter.out.client.registry import registered_names
35
+ from generic_ml_cache_core.application.domain.model.execution.artifact import ArtifactType
36
+ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
37
+ from generic_ml_cache_core.application.domain.model.execution.execution_state import ExecutionState
38
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
39
+ from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
40
+ RunManagedLocalExecutionCommand,
41
+ )
42
+ from generic_ml_cache_core.application.port.out.base import ClientAdapter
43
+ from generic_ml_cache_core.common.errors import CacheError, CacheMiss, ConfigError, RunInterrupted
44
+
45
+ from . import __version__, config
46
+
47
+ #: capabilities a caller may open with --grant, sourced from the adapter seam so
48
+ #: the CLI choices, the help, and what the adapters implement can never drift.
49
+ GRANT_CHOICES: List[str] = list(ClientAdapter.GRANTS)
50
+ _GRANT_HELP = (
51
+ "open a capability for the client -- enablement, not restriction. One of "
52
+ "{net, read, write, shell, web-search}: net reaches the web, read/write/shell "
53
+ "widen file and command access, web-search enables the search tool. Part of "
54
+ "the key (a granted call is its own execution) and cacheable like any call; use "
55
+ "--force for a live re-fetch. Repeatable."
56
+ )
57
+
58
+
59
+ def _read_text_arg(inline: Optional[str], path: Optional[str], name: str) -> str:
60
+ if inline is not None and path is not None:
61
+ raise SystemExit(f"error: pass only one of --{name} / --{name}-file")
62
+ if path is not None:
63
+ return Path(path).read_text(encoding="utf-8")
64
+ return inline if inline is not None else ""
65
+
66
+
67
+ def _resolve_input_file_paths(raw_paths) -> List[str]:
68
+ """Declared input files, resolved to absolute (path-sensitive keying). The
69
+ use case's fingerprint adapter validates readability and raises on a bad one."""
70
+ return [str(Path(raw).resolve()) for raw in (raw_paths or [])]
71
+
72
+
73
+ def _resolve_allow_paths(raw_paths) -> List[str]:
74
+ """Declared scan folders: validated directories, normalised to absolute."""
75
+ resolved: List[str] = []
76
+ for raw in raw_paths or []:
77
+ path = Path(raw)
78
+ if not path.is_dir():
79
+ raise SystemExit(f"error: allow-path is not a directory: {raw}")
80
+ resolved.append(str(path.resolve()))
81
+ return resolved
82
+
83
+
84
+ def _artifact_text(execution: MlExecution, artifact_type: ArtifactType) -> str:
85
+ for artifact in execution.artifacts:
86
+ if artifact.artifact_type is artifact_type:
87
+ return (artifact.content or b"").decode("utf-8", errors="replace")
88
+ return ""
89
+
90
+
91
+ def _run_exit_code(execution: MlExecution) -> int:
92
+ if execution.failure is not None and execution.failure.exit_code is not None:
93
+ return execution.failure.exit_code
94
+ return 0 if execution.execution_state is ExecutionState.SUCCESS else 1
95
+
96
+
97
+ def _apply_output_files(execution: MlExecution, output_dir: Path) -> None:
98
+ """Write captured output files into ``output_dir``, mirroring a real client.
99
+ Any attempt to escape the directory (``..`` / absolute) is refused."""
100
+ output_dir.mkdir(parents=True, exist_ok=True)
101
+ base = output_dir.resolve()
102
+ for artifact in execution.artifacts:
103
+ if artifact.artifact_type is not ArtifactType.OUTPUT_FILE or artifact.name is None:
104
+ continue
105
+ target = (output_dir / Path(artifact.name)).resolve()
106
+ if base != target and base not in target.parents:
107
+ raise ValueError(f"refusing to write outside output dir: {artifact.name!r}")
108
+ target.parent.mkdir(parents=True, exist_ok=True)
109
+ target.write_bytes(artifact.content or b"")
110
+
111
+
112
+ def _print_run_json(execution: MlExecution, command: RunManagedLocalExecutionCommand) -> int:
113
+ import json
114
+
115
+ usage = execution.token_usage
116
+ files = [a for a in execution.artifacts if a.artifact_type is ArtifactType.OUTPUT_FILE]
117
+ status = "success" if execution.execution_state is ExecutionState.SUCCESS else "failed"
118
+ payload = {
119
+ "status": status,
120
+ "exit": _run_exit_code(execution),
121
+ "client": command.client,
122
+ "model": command.model,
123
+ "effort": command.effort,
124
+ "files": len(files),
125
+ "usage": usage.to_dict() if usage is not None else None,
126
+ "stdout": _artifact_text(execution, ArtifactType.STDOUT),
127
+ }
128
+ print(json.dumps(payload, indent=2))
129
+ sys.stderr.write(_artifact_text(execution, ArtifactType.STDERR))
130
+ sys.stderr.flush()
131
+ return _run_exit_code(execution)
132
+
133
+
134
+ def _cmd_run(args: argparse.Namespace) -> int:
135
+ context = _read_text_arg(args.context, args.context_file, "context")
136
+ prompt = _read_text_arg(args.prompt, args.prompt_file, "prompt")
137
+ if not prompt:
138
+ raise SystemExit("error: a non-empty --prompt or --prompt-file is required")
139
+ system_prompt = (
140
+ _read_text_arg(args.system_prompt, args.system_prompt_file, "system-prompt") or None
141
+ )
142
+
143
+ try:
144
+ file_cfg = config.load()
145
+ settings = config.resolve_settings(file_cfg, mode_flag=args.mode, timeout_flag=args.timeout)
146
+ except ConfigError as exc:
147
+ print(f"gmlc: {exc}", file=sys.stderr)
148
+ return 4
149
+
150
+ store_root = Path(str(settings["store"][0]))
151
+ timeout = settings["timeout"][0]
152
+ trust_scan = bool(settings["trust_scan"][0])
153
+ # --offline / --force are explicit flags and win over the resolved mode.
154
+ if args.offline:
155
+ cache_mode = CacheMode.OFFLINE
156
+ elif args.force:
157
+ cache_mode = CacheMode.REFRESH
158
+ else:
159
+ cache_mode = CacheMode(str(settings["mode"][0]))
160
+
161
+ command = RunManagedLocalExecutionCommand(
162
+ client=args.client,
163
+ model=args.model,
164
+ effort=args.effort,
165
+ context=context,
166
+ prompt=prompt,
167
+ user_system_prompt=system_prompt,
168
+ input_file_paths=_resolve_input_file_paths(args.input_file),
169
+ allow_paths=_resolve_allow_paths(args.allow_path),
170
+ scan_trust=trust_scan,
171
+ client_args=list(getattr(args, "client_arg", None) or []),
172
+ grants=list(getattr(args, "grant", None) or []),
173
+ cache_mode=cache_mode,
174
+ record_on_error=args.record_on_error,
175
+ )
176
+
177
+ def executable_override(client: str):
178
+ return config.executable_for(file_cfg, client, flag=args.executable)
179
+
180
+ wired = build_use_cases(store_root, executable_override, timeout)
181
+
182
+ try:
183
+ execution = wired.run_managed.execute(command)
184
+ except RunInterrupted as exc:
185
+ # A requested stop, not a failure: nothing was recorded. Exit 130 is the
186
+ # conventional "terminated by Ctrl-C".
187
+ print(f"gmlc: {exc}", file=sys.stderr)
188
+ return 130
189
+ except subprocess.TimeoutExpired as exc:
190
+ # The real call ran past --timeout and was killed before any record. Exit
191
+ # 124 is the timeout(1) convention, distinct from miss (3) and error (4).
192
+ print(
193
+ f"gmlc: real call exceeded the {exc.timeout}s timeout and was killed; nothing recorded",
194
+ file=sys.stderr,
195
+ )
196
+ return 124
197
+ except CacheMiss as exc:
198
+ print(f"gmlc: {exc}", file=sys.stderr)
199
+ return 3
200
+ except CacheError as exc:
201
+ print(f"gmlc: {exc}", file=sys.stderr)
202
+ return 4
203
+
204
+ # Materialise captured files into the cwd, exactly as the real client would.
205
+ _apply_output_files(execution, Path.cwd())
206
+
207
+ if getattr(args, "json", False):
208
+ return _print_run_json(execution, command)
209
+
210
+ sys.stdout.write(_artifact_text(execution, ArtifactType.STDOUT))
211
+ sys.stdout.flush()
212
+ sys.stderr.write(_artifact_text(execution, ArtifactType.STDERR))
213
+ sys.stderr.flush()
214
+ return _run_exit_code(execution)
215
+
216
+
217
+ def _cmd_check(args: argparse.Namespace) -> int:
218
+ import json
219
+
220
+ from generic_ml_cache_core.application.domain.model.probe.probe_status import ProbeStatus
221
+ from generic_ml_cache_core.application.port.inbound.probe_command import ProbeCommand
222
+
223
+ context = _read_text_arg(args.context, args.context_file, "context")
224
+ prompt = _read_text_arg(args.prompt, args.prompt_file, "prompt")
225
+ if not prompt:
226
+ raise SystemExit("error: a non-empty --prompt or --prompt-file is required")
227
+ try:
228
+ settings = config.resolve_settings(config.load())
229
+ except ConfigError as exc:
230
+ print(f"gmlc: {exc}", file=sys.stderr)
231
+ return 4
232
+
233
+ store_root = Path(str(settings["store"][0]))
234
+ command = ProbeCommand(
235
+ client=args.client,
236
+ model=args.model,
237
+ effort=args.effort,
238
+ context=context,
239
+ prompt=prompt,
240
+ input_file_paths=_resolve_input_file_paths(args.input_file),
241
+ allow_paths=_resolve_allow_paths(args.allow_path),
242
+ scan_trust=bool(settings["trust_scan"][0]),
243
+ client_args=list(getattr(args, "client_arg", None) or []),
244
+ grants=list(getattr(args, "grant", None) or []),
245
+ )
246
+ report = build_use_cases(store_root).probe.execute(command)
247
+ execution = report.execution
248
+ usage = execution.token_usage if execution is not None else None
249
+ file_count = (
250
+ len([a for a in execution.artifacts if a.artifact_type is ArtifactType.OUTPUT_FILE])
251
+ if execution is not None
252
+ else 0
253
+ )
254
+
255
+ if args.json:
256
+ payload = {
257
+ "status": report.status.value,
258
+ "cached": report.status is ProbeStatus.HIT,
259
+ "client": args.client,
260
+ "model": args.model,
261
+ "effort": args.effort,
262
+ "key": report.execution_key,
263
+ }
264
+ if execution is not None:
265
+ payload["files"] = file_count
266
+ payload["usage"] = usage.to_dict() if usage is not None else None
267
+ print(json.dumps(payload, indent=2))
268
+ return 0
269
+
270
+ status_styles = {
271
+ ProbeStatus.HIT: (_GREEN, _BOLD),
272
+ ProbeStatus.MISS: (_AMBER, _BOLD),
273
+ ProbeStatus.NON_CACHEABLE: (_GREY,),
274
+ }
275
+ print(f"status : {_paint(report.status.value, *status_styles.get(report.status, ()))}")
276
+ print(f"client : {args.client}")
277
+ print(f"model : {args.model}")
278
+ print(f"effort : {args.effort}")
279
+ print(f"key : {report.execution_key}")
280
+ if report.status is ProbeStatus.HIT and execution is not None:
281
+ print(f"files : {file_count}")
282
+ if usage is None:
283
+ print("usage : (none captured)")
284
+ else:
285
+ print(f"usage : {_usage_summary(usage)}")
286
+ elif report.status is ProbeStatus.NON_CACHEABLE:
287
+ print("note : declares allow-path folders the cache cannot fingerprint, so this")
288
+ print(" call always runs fresh and is never cached.")
289
+ return 0
290
+
291
+
292
+ def _cmd_inspect(args: argparse.Namespace) -> int:
293
+ try:
294
+ settings = config.resolve_settings(config.load())
295
+ except ConfigError as exc:
296
+ print(f"gmlc: {exc}", file=sys.stderr)
297
+ return 4
298
+
299
+ store_root = Path(str(settings["store"][0]))
300
+ matches = build_use_cases(store_root).repository.find_current_by_key_prefix(args.execution)
301
+ if not matches:
302
+ print(f"gmlc: no current execution matches key {args.execution!r}", file=sys.stderr)
303
+ return 4
304
+ if len(matches) > 1:
305
+ print(
306
+ f"gmlc: key {args.execution!r} is ambiguous — matches {len(matches)} executions:",
307
+ file=sys.stderr,
308
+ )
309
+ for ambiguous in matches:
310
+ print(f" {ambiguous.call_identity.generate_key()}", file=sys.stderr)
311
+ return 4
312
+
313
+ execution = matches[0]
314
+ print(f"key : {execution.call_identity.generate_key()}")
315
+ print(f"kind : {execution.execution_kind.value}")
316
+ print(f"state : {execution.execution_state.value}")
317
+ output_files = [a for a in execution.artifacts if a.artifact_type is ArtifactType.OUTPUT_FILE]
318
+ print(f"files : {len(output_files)}")
319
+ for artifact in output_files:
320
+ print(f" - {artifact.name} ({artifact.encoding}, {artifact.size_bytes} bytes)")
321
+ usage = execution.token_usage
322
+ if usage is None:
323
+ print("usage : (none captured)")
324
+ else:
325
+ print(f"usage : {_usage_summary(usage)}")
326
+ if usage.cost_usd is not None:
327
+ print(f" cost ~ ${usage.cost_usd:.4f} (client estimate, not authoritative)")
328
+ return 0
329
+
330
+
331
+ def _cmd_doctor(args: argparse.Namespace) -> int:
332
+ from dataclasses import asdict
333
+
334
+ from generic_ml_cache_core.adapter.out.client.discover import probe_all
335
+
336
+ try:
337
+ file_cfg = config.load()
338
+ except ConfigError as exc:
339
+ print(f"gmlc: {exc}", file=sys.stderr)
340
+ return 4
341
+
342
+ statuses = probe_all(timeout=args.timeout, executables=file_cfg.executables)
343
+
344
+ if args.json:
345
+ import json
346
+
347
+ print(json.dumps([asdict(s) for s in statuses], indent=2))
348
+ return 0
349
+
350
+ if not statuses:
351
+ print("no client adapters are registered")
352
+ return 0
353
+ print("configured clients (advisory — discovery never chooses or gates a run):")
354
+ for s in statuses:
355
+ if s.present:
356
+ print(f" {s.name:<8} present {(s.version or 'version unknown'):<28} {s.executable}")
357
+ else:
358
+ print(f" {s.name:<8} missing {s.detail or ''}")
359
+ return 0
360
+
361
+
362
+ def _cmd_models(args: argparse.Namespace) -> int:
363
+ from dataclasses import asdict
364
+
365
+ from generic_ml_cache_core.adapter.out.client.discover import list_models, list_models_all
366
+
367
+ try:
368
+ file_cfg = config.load()
369
+ except ConfigError as exc:
370
+ print(f"gmlc: {exc}", file=sys.stderr)
371
+ return 4
372
+
373
+ if args.client:
374
+ executable = config.executable_for(file_cfg, args.client, flag=args.executable)
375
+ listings = [list_models(args.client, executable=executable, timeout=args.timeout)]
376
+ else:
377
+ listings = list_models_all(timeout=args.timeout, executables=file_cfg.executables)
378
+
379
+ if args.json:
380
+ import json
381
+
382
+ # Always valid JSON on every path (absent / unsupported / listed), so a
383
+ # caller can parse the output unconditionally.
384
+ print(json.dumps([asdict(m) for m in listings], indent=2))
385
+ return 0
386
+
387
+ for ml in listings:
388
+ if not ml.present:
389
+ print(f" {ml.name:<8} absent {ml.reason or ''}")
390
+ continue
391
+ if not ml.supported:
392
+ print(f" {ml.name:<8} — {ml.reason or 'model listing not supported'}")
393
+ continue
394
+ if ml.models is None:
395
+ print(f" {ml.name:<8} — {ml.reason or 'could not list models'}")
396
+ continue
397
+ print(f" {ml.name:<8} {len(ml.models)} model(s) (advisory; relayed from the client):")
398
+ for m in ml.models:
399
+ marker = " (default)" if m.default else (" (current)" if m.current else "")
400
+ print(f" {m.id:<34} {m.name}{marker}")
401
+ return 0
402
+
403
+
404
+ def _cmd_status(args: argparse.Namespace) -> int:
405
+ try:
406
+ file_cfg = config.load()
407
+ settings = config.resolve_settings(file_cfg) # no run flags: env > file > default
408
+ except ConfigError as exc:
409
+ print(f"gmlc: {exc}", file=sys.stderr)
410
+ return 4
411
+
412
+ path = config.resolve_config_path()
413
+ loaded = file_cfg.source is not None
414
+
415
+ if args.json:
416
+ import json
417
+
418
+ print(
419
+ json.dumps(
420
+ {
421
+ "config_file": str(path),
422
+ "loaded": loaded,
423
+ "settings": {k: {"value": v[0], "source": v[1]} for k, v in settings.items()},
424
+ "executables": dict(file_cfg.executables),
425
+ },
426
+ indent=2,
427
+ )
428
+ )
429
+ return 0
430
+
431
+ print(f"config file : {path} ({'loaded' if loaded else 'not present'})")
432
+ print("effective settings (no run flags applied):")
433
+ for key in ("mode", "store", "timeout", "trust_scan", "max_size"):
434
+ value, source = settings[key]
435
+ shown = "none" if value is None else value
436
+ if isinstance(shown, bool):
437
+ shown = "true" if shown else "false"
438
+ print(f" {key:<10} {str(shown):<14} (from {source})")
439
+ if file_cfg.executables:
440
+ print("executables (from config; --executable still overrides per call):")
441
+ for client, exe in file_cfg.executables.items():
442
+ print(f" {client:<8} {exe}")
443
+ else:
444
+ print("executables : none configured (clients resolved on PATH)")
445
+ return 0
446
+
447
+
448
+ def _cmd_init(args: argparse.Namespace) -> int:
449
+ try:
450
+ path, created = config.write_default_config()
451
+ except OSError as exc:
452
+ print(f"gmlc: could not create config: {exc}", file=sys.stderr)
453
+ return 4
454
+ if created:
455
+ print(f"created config: {path}")
456
+ else:
457
+ print(f"config already present: {path} (left unchanged)")
458
+ try:
459
+ settings = config.resolve_settings(config.load())
460
+ except ConfigError as exc:
461
+ print(f"gmlc: {exc}", file=sys.stderr)
462
+ return 4
463
+ value, source = settings["store"]
464
+ print(f"store: {value} (from {source})")
465
+ return 0
466
+
467
+
468
+ def _token_str(count: "int | None") -> str:
469
+ """A token count for display: the number, or ``?`` when unknown (None)."""
470
+ return "?" if count is None else str(count)
471
+
472
+
473
+ def _usage_summary(usage) -> str:
474
+ """One-line token summary; unknown counts show as ``?`` (never 0)."""
475
+ return (
476
+ f"input={_token_str(usage.input_tokens)} "
477
+ f"output={_token_str(usage.output_tokens)} "
478
+ f"cache-read={_token_str(usage.cache_read_tokens)} "
479
+ f"cache-write={_token_str(usage.cache_write_tokens)} "
480
+ f"reasoning={_token_str(usage.reasoning_tokens)}"
481
+ )
482
+
483
+
484
+ def _cmd_stats(args: argparse.Namespace) -> int:
485
+ import json
486
+
487
+ try:
488
+ settings = config.resolve_settings(config.load())
489
+ except ConfigError as exc:
490
+ print(f"gmlc: {exc}", file=sys.stderr)
491
+ return 4
492
+
493
+ wired = build_use_cases(Path(str(settings["store"][0])))
494
+ summaries = wired.repository.current_execution_summaries()
495
+ access = wired.metrics.event_counts()
496
+ by_client_model: Dict[tuple, int] = {}
497
+ for summary in summaries:
498
+ by_client_model[(summary.client, summary.model)] = (
499
+ by_client_model.get((summary.client, summary.model), 0) + 1
500
+ )
501
+
502
+ if args.json:
503
+ print(
504
+ json.dumps(
505
+ {
506
+ "executions": len(summaries),
507
+ "by_client_model": [
508
+ {"client": client, "model": model, "executions": count}
509
+ for (client, model), count in sorted(by_client_model.items())
510
+ ],
511
+ "access_events": access,
512
+ },
513
+ indent=2,
514
+ )
515
+ )
516
+ return 0
517
+
518
+ print(f"executions : {_paint(str(len(summaries)), _TEAL, _BOLD)}")
519
+ if by_client_model:
520
+ print("by client / model:")
521
+ for (client, model), count in sorted(by_client_model.items()):
522
+ print(f" {client:<8} {model:<26} {count:>5}")
523
+ if access:
524
+ event_styles = {"hit": (_GREEN,), "miss": (_AMBER,), "record": (_TEAL,)}
525
+ parts = ", ".join(
526
+ f"{_paint(event, *event_styles.get(event, ()))}={count}"
527
+ for event, count in sorted(access.items())
528
+ )
529
+ print(f"access : {parts}")
530
+ else:
531
+ print("access : (no events recorded yet)")
532
+ return 0
533
+
534
+
535
+ def _cmd_list(args: argparse.Namespace) -> int:
536
+ import json
537
+
538
+ try:
539
+ settings = config.resolve_settings(config.load())
540
+ except ConfigError as exc:
541
+ print(f"gmlc: {exc}", file=sys.stderr)
542
+ return 4
543
+
544
+ wired = build_use_cases(Path(str(settings["store"][0])))
545
+ hit_counts = wired.metrics.hit_counts_by_key()
546
+ entries = [
547
+ {
548
+ "client": summary.client,
549
+ "model": summary.model,
550
+ "kind": summary.kind,
551
+ "key": summary.execution_key,
552
+ "hits": hit_counts.get(summary.execution_key, 0),
553
+ }
554
+ for summary in wired.repository.current_execution_summaries()
555
+ if (not args.client or summary.client == args.client)
556
+ and (not args.model or summary.model == args.model)
557
+ ]
558
+
559
+ if args.json:
560
+ print(json.dumps({"executions": entries}, indent=2))
561
+ return 0
562
+
563
+ if not entries:
564
+ print("no current executions")
565
+ return 0
566
+
567
+ print(f"executions : {_paint(str(len(entries)), _TEAL, _BOLD)}")
568
+ for entry in sorted(entries, key=lambda item: (item["client"], item["model"], item["key"])):
569
+ hits = entry["hits"]
570
+ hits_text = _paint(str(hits), _GREEN) if hits else _paint(str(hits), _GREY)
571
+ print(
572
+ f" {entry['client']:<8} {entry['model']:<20} {entry['kind']:<18} "
573
+ f"{_paint(entry['key'][:12], _GREY)} hits:{hits_text}"
574
+ )
575
+ return 0
576
+
577
+
578
+ def _use_color() -> bool:
579
+ """Colour only when writing to a real terminal and NO_COLOR is unset, so piped
580
+ or redirected output never carries escape codes (the conventional contract)."""
581
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
582
+
583
+
584
+ # ANSI palette (256-colour). One place for the escape codes, shared by the banner
585
+ # and the status/stats colouring.
586
+ _RESET = "\x1b[0m"
587
+ _BOLD = "\x1b[1m"
588
+ _TEAL = "\x1b[38;5;37m" # accent / box rule
589
+ _TEAL_BRIGHT = "\x1b[38;5;43m" # version
590
+ _GREEN = "\x1b[38;5;42m" # a hit
591
+ _AMBER = "\x1b[38;5;214m" # a miss
592
+ _GREY = "\x1b[38;5;245m" # secondary / dim
593
+
594
+
595
+ def _paint(text: str, *codes: str) -> str:
596
+ """Wrap ``text`` in ANSI codes when colour is enabled (a real TTY with NO_COLOR
597
+ unset), else return it unchanged -- so piped output never carries escape codes.
598
+ Only gmlcache's own UI is ever painted; a client's answer is printed verbatim."""
599
+ if not codes or not _use_color():
600
+ return text
601
+ return "".join(codes) + text + _RESET
602
+
603
+
604
+ def render_banner(color: bool = False) -> str:
605
+ """The boxed gmlcache banner. Width is derived from the content, so any version
606
+ string or tagline stays aligned. ``color`` adds teal ANSI; off yields plain text."""
607
+ title = "gmlcache"
608
+ ver = __version__
609
+ tag = "record · replay · check · tokens"
610
+
611
+ if color:
612
+ rule = _TEAL # teal box
613
+ name = _BOLD # bold title
614
+ vers = _TEAL_BRIGHT # bright-teal version
615
+ sub = _GREY # dim-grey tagline
616
+ off = _RESET
617
+ else:
618
+ rule = name = vers = sub = off = ""
619
+
620
+ left_top = f"─ {title} "
621
+ right_top = f" {ver} ─"
622
+ inner = max(len(left_top) + 6 + len(right_top), len(tag) + 4)
623
+ top_dashes = inner - len(left_top) - len(right_top)
624
+ pad_right = inner - 2 - len(tag)
625
+
626
+ top = (
627
+ f"{rule}┌─ {off}{name}{title}{off}"
628
+ f"{rule} {'─' * top_dashes} {off}{vers}{ver}{off}{rule} ─┐{off}"
629
+ )
630
+ mid = f"{rule}│{off} {sub}{tag}{off}{' ' * pad_right}{rule}│{off}"
631
+ bot = f"{rule}└{'─' * inner}┘{off}"
632
+ return "\n".join([top, mid, bot])
633
+
634
+
635
+ class _BannerParser(argparse.ArgumentParser):
636
+ """An ArgumentParser whose full help is fronted by the banner, so the banner
637
+ shows on ``-h`` and on a bare invocation (but not on terse usage/error lines)."""
638
+
639
+ def format_help(self) -> str:
640
+ return render_banner(_use_color()) + "\n\n" + super().format_help()
641
+
642
+
643
+ def build_parser() -> argparse.ArgumentParser:
644
+ parser = _BannerParser(
645
+ prog="gmlcache",
646
+ description="Content-addressed cache/proxy for agentic CLI calls.",
647
+ )
648
+ parser.add_argument("--version", action="version", version=f"gmlcache {__version__}")
649
+ sub = parser.add_subparsers(dest="command", required=False)
650
+
651
+ run = sub.add_parser("run", help="resolve a request (record on miss, replay on hit)")
652
+ run.add_argument("--client", required=True, choices=registered_names())
653
+ run.add_argument("--model", required=True)
654
+ run.add_argument(
655
+ "--effort",
656
+ default="",
657
+ help=(
658
+ "reasoning effort (optional); omit to use the client's own default. "
659
+ "For Cursor, leave this off when the model id already encodes effort."
660
+ ),
661
+ )
662
+ run.add_argument("--prompt")
663
+ run.add_argument("--prompt-file")
664
+ run.add_argument("--context")
665
+ run.add_argument("--context-file")
666
+ run.add_argument("--system-prompt")
667
+ run.add_argument("--system-prompt-file")
668
+ run.add_argument(
669
+ "--input-file",
670
+ action="append",
671
+ dest="input_file",
672
+ metavar="PATH",
673
+ help=(
674
+ "a specific file the client will read in place; its content is "
675
+ "fingerprinted into the cache key and the client is granted read "
676
+ "access to it. Repeatable, any file type. The key watches content, "
677
+ "not the name."
678
+ ),
679
+ )
680
+ run.add_argument(
681
+ "--allow-path",
682
+ action="append",
683
+ dest="allow_path",
684
+ metavar="PATH",
685
+ help=(
686
+ "a folder the client may scan/read whose contents the cache cannot "
687
+ "fingerprint. Declaring any allow-path makes the call run fresh and "
688
+ "store nothing (non-cacheable). The client is granted read access to "
689
+ "it via the prime directive (and --add-dir on Claude). Repeatable."
690
+ ),
691
+ )
692
+ run.add_argument(
693
+ "--client-arg",
694
+ action="append",
695
+ dest="client_arg",
696
+ metavar="ARG",
697
+ help=(
698
+ "an extra argument appended verbatim to the client launch -- an escape "
699
+ "hatch for client features the cache does not model. Part of the key "
700
+ "(different args = different execution); only its fingerprint is stored, "
701
+ "never the raw value. Repeatable; order is significant. Pass a "
702
+ "dash-leading value with the =form: --client-arg=--flag."
703
+ ),
704
+ )
705
+ run.add_argument(
706
+ "--grant",
707
+ action="append",
708
+ dest="grant",
709
+ choices=GRANT_CHOICES,
710
+ help=_GRANT_HELP,
711
+ )
712
+ run.add_argument(
713
+ "--json",
714
+ action="store_true",
715
+ help=(
716
+ "emit a machine-readable JSON envelope (status, exit, files, normalized "
717
+ "usage, stdout) instead of the raw answer -- for a parent process such "
718
+ "as the workflow engine reading usage. Files are still written to the cwd."
719
+ ),
720
+ )
721
+ run.add_argument(
722
+ "--mode",
723
+ choices=[m.value for m in CacheMode],
724
+ default=None,
725
+ help="resolution mode (default: cache, or config/env)",
726
+ )
727
+ run.add_argument("--offline", action="store_true", help="shortcut for --mode offline")
728
+ run.add_argument("--force", action="store_true", help="shortcut for --mode refresh")
729
+ run.add_argument(
730
+ "--record-on-error",
731
+ action="store_true",
732
+ help="also cache a call that fails (non-zero exit); default is to store only successes",
733
+ )
734
+ run.add_argument("--executable", help="override the client executable (the seam)")
735
+ run.add_argument(
736
+ "--timeout", type=float, default=None, help="seconds before the real call is killed"
737
+ )
738
+ run.add_argument(
739
+ "-v",
740
+ "--verbose",
741
+ action="store_true",
742
+ help="print cache diagnostics to stderr (breaks exact fidelity)",
743
+ )
744
+ run.set_defaults(func=_cmd_run)
745
+
746
+ inspect = sub.add_parser("inspect", help="show a stored execution by its (short) key")
747
+ inspect.add_argument("execution", help="an execution key, or a short prefix as shown by `list`")
748
+ inspect.set_defaults(func=_cmd_inspect)
749
+
750
+ check = sub.add_parser(
751
+ "check",
752
+ help="probe whether a call is already cached (read-only; launches and records nothing)",
753
+ )
754
+ check.add_argument("--client", required=True, choices=registered_names())
755
+ check.add_argument("--model", required=True)
756
+ check.add_argument(
757
+ "--effort",
758
+ default="",
759
+ help="reasoning effort (optional); must match the run you would make",
760
+ )
761
+ check.add_argument("--prompt")
762
+ check.add_argument("--prompt-file")
763
+ check.add_argument("--context")
764
+ check.add_argument("--context-file")
765
+ check.add_argument(
766
+ "--input-file",
767
+ action="append",
768
+ dest="input_file",
769
+ metavar="PATH",
770
+ help="an input file whose content is fingerprinted into the key (repeatable)",
771
+ )
772
+ check.add_argument(
773
+ "--allow-path",
774
+ action="append",
775
+ dest="allow_path",
776
+ metavar="PATH",
777
+ help="a scan folder; declaring any makes the call non-cacheable (repeatable)",
778
+ )
779
+ check.add_argument(
780
+ "--client-arg",
781
+ action="append",
782
+ dest="client_arg",
783
+ metavar="ARG",
784
+ help="extra arg keyed into the call, to probe a passthrough launch (repeatable)",
785
+ )
786
+ check.add_argument(
787
+ "--grant",
788
+ action="append",
789
+ dest="grant",
790
+ choices=GRANT_CHOICES,
791
+ help="open a capability (net/read/write/shell/web-search), keyed into the call, to probe a granted launch (repeatable)",
792
+ )
793
+ check.add_argument("--json", action="store_true", help="emit machine-readable JSON")
794
+ check.set_defaults(func=_cmd_check)
795
+
796
+ doctor = sub.add_parser(
797
+ "doctor",
798
+ help="report which configured clients are present + their versions (advisory)",
799
+ )
800
+ doctor.add_argument(
801
+ "--timeout", type=float, default=10.0, help="seconds before a version check is killed"
802
+ )
803
+ doctor.add_argument("--json", action="store_true", help="emit machine-readable JSON")
804
+ doctor.set_defaults(func=_cmd_doctor)
805
+
806
+ models = sub.add_parser(
807
+ "models",
808
+ help="list the models a client reports it can use (advisory; relayed from the client)",
809
+ )
810
+ models.add_argument(
811
+ "client",
812
+ nargs="?",
813
+ choices=registered_names(),
814
+ help="one client; omit to query every registered client",
815
+ )
816
+ models.add_argument("--executable", help="override the client executable (the seam)")
817
+ models.add_argument(
818
+ "--timeout", type=float, default=30.0, help="seconds before the listing call is killed"
819
+ )
820
+ models.add_argument("--json", action="store_true", help="emit machine-readable JSON")
821
+ models.set_defaults(func=_cmd_models)
822
+
823
+ status = sub.add_parser(
824
+ "status",
825
+ help="show the resolved configuration (which file loaded, effective settings)",
826
+ )
827
+ status.add_argument("--json", action="store_true", help="emit machine-readable JSON")
828
+ status.set_defaults(func=_cmd_status)
829
+
830
+ stats = sub.add_parser(
831
+ "stats",
832
+ help="show how many executions are stored, their total size split by client/model, "
833
+ "and access counts",
834
+ )
835
+ stats.add_argument("--json", action="store_true", help="emit machine-readable JSON")
836
+ stats.set_defaults(func=_cmd_stats)
837
+
838
+ listp = sub.add_parser(
839
+ "list", help="list stored executions, grouped by client/model (read-only)"
840
+ )
841
+ listp.add_argument("--client", help="only executions recorded for this client")
842
+ listp.add_argument("--model", help="only executions recorded for this model")
843
+ listp.add_argument("--json", action="store_true", help="emit machine-readable JSON")
844
+ listp.set_defaults(func=_cmd_list)
845
+
846
+ init = sub.add_parser(
847
+ "init",
848
+ help="create the config file in the default location (if absent), then show the store",
849
+ )
850
+ init.set_defaults(func=_cmd_init)
851
+
852
+ return parser
853
+
854
+
855
+ def main(argv: Optional[List[str]] = None) -> int:
856
+ parser = build_parser()
857
+ if argcomplete is not None:
858
+ # A no-op unless the shell is requesting completions (it sets _ARGCOMPLETE);
859
+ # in that case it emits candidates and exits, so it never affects normal runs.
860
+ argcomplete.autocomplete(parser)
861
+ args = parser.parse_args(argv)
862
+ if not hasattr(args, "func"):
863
+ parser.print_help()
864
+ return 0
865
+ return args.func(args)
866
+
867
+
868
+ if __name__ == "__main__":
869
+ raise SystemExit(main())