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.
- generic_ml_cache_cli/__init__.py +28 -0
- generic_ml_cache_cli/__main__.py +10 -0
- generic_ml_cache_cli/cli.py +869 -0
- generic_ml_cache_cli/config.py +347 -0
- generic_ml_cache_cli-0.2.0.dist-info/METADATA +96 -0
- generic_ml_cache_cli-0.2.0.dist-info/RECORD +10 -0
- generic_ml_cache_cli-0.2.0.dist-info/WHEEL +4 -0
- generic_ml_cache_cli-0.2.0.dist-info/entry_points.txt +2 -0
- generic_ml_cache_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_cache_cli-0.2.0.dist-info/licenses/NOTICE +8 -0
|
@@ -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())
|