ctxoptimizer 0.4.1__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.
- ctxoptimizer/__init__.py +5 -0
- ctxoptimizer/__main__.py +14 -0
- ctxoptimizer/admin_security.py +130 -0
- ctxoptimizer/analysis/__init__.py +1 -0
- ctxoptimizer/analysis/baseline.py +69 -0
- ctxoptimizer/analysis/break_even.py +49 -0
- ctxoptimizer/analysis/drift.py +151 -0
- ctxoptimizer/analysis/edits.py +148 -0
- ctxoptimizer/analysis/markers.py +34 -0
- ctxoptimizer/analysis/recommend.py +114 -0
- ctxoptimizer/analysis/segments.py +57 -0
- ctxoptimizer/analysis/session.py +62 -0
- ctxoptimizer/analysis/state.py +239 -0
- ctxoptimizer/analysis/tokens.py +62 -0
- ctxoptimizer/analysis/waste.py +322 -0
- ctxoptimizer/cli.py +873 -0
- ctxoptimizer/config.py +512 -0
- ctxoptimizer/dashboard/__init__.py +1 -0
- ctxoptimizer/dashboard/api.py +537 -0
- ctxoptimizer/dashboard/service.py +157 -0
- ctxoptimizer/dashboard/static/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/doctor.html +46 -0
- ctxoptimizer/dashboard/templates/live.html +91 -0
- ctxoptimizer/dashboard/templates/overview.html +172 -0
- ctxoptimizer/dashboard/templates/pricing.html +115 -0
- ctxoptimizer/dashboard/templates/request.html +111 -0
- ctxoptimizer/dashboard/templates/session.html +67 -0
- ctxoptimizer/dashboard/templates/settings.html +79 -0
- ctxoptimizer/dashboard/templates/table.html +56 -0
- ctxoptimizer/dashboard/templates/transforms.html +118 -0
- ctxoptimizer/dashboard/templates/waste.html +120 -0
- ctxoptimizer/dashboard/views.py +429 -0
- ctxoptimizer/data/cache_limits.json +23 -0
- ctxoptimizer/data/default_config.toml +185 -0
- ctxoptimizer/data/pricing.json +421 -0
- ctxoptimizer/doctor.py +129 -0
- ctxoptimizer/guard.py +139 -0
- ctxoptimizer/ids.py +73 -0
- ctxoptimizer/log.py +73 -0
- ctxoptimizer/mcp.py +124 -0
- ctxoptimizer/models.py +145 -0
- ctxoptimizer/pipeline.py +126 -0
- ctxoptimizer/profiles.py +152 -0
- ctxoptimizer/providers/__init__.py +37 -0
- ctxoptimizer/providers/anthropic.py +105 -0
- ctxoptimizer/providers/base.py +191 -0
- ctxoptimizer/providers/gemini.py +143 -0
- ctxoptimizer/providers/ollama.py +133 -0
- ctxoptimizer/providers/openai_chat.py +135 -0
- ctxoptimizer/providers/openai_responses.py +121 -0
- ctxoptimizer/proxy.py +665 -0
- ctxoptimizer/routing.py +259 -0
- ctxoptimizer/server.py +319 -0
- ctxoptimizer/service.py +150 -0
- ctxoptimizer/telemetry/__init__.py +1 -0
- ctxoptimizer/telemetry/blobs.py +57 -0
- ctxoptimizer/telemetry/expand.py +62 -0
- ctxoptimizer/telemetry/pricing.py +168 -0
- ctxoptimizer/telemetry/redact.py +53 -0
- ctxoptimizer/telemetry/sse.py +145 -0
- ctxoptimizer/telemetry/store.py +829 -0
- ctxoptimizer/telemetry/usage.py +435 -0
- ctxoptimizer/transforms/__init__.py +87 -0
- ctxoptimizer/transforms/ansi.py +21 -0
- ctxoptimizer/transforms/anthropic_cache.py +100 -0
- ctxoptimizer/transforms/backref.py +74 -0
- ctxoptimizer/transforms/base.py +93 -0
- ctxoptimizer/transforms/cache_serve.py +146 -0
- ctxoptimizer/transforms/dedupe_lines.py +69 -0
- ctxoptimizer/transforms/eligibility.py +24 -0
- ctxoptimizer/transforms/openai_hints.py +84 -0
- ctxoptimizer/transforms/progress.py +52 -0
- ctxoptimizer/transforms/stale_result_elision.py +109 -0
- ctxoptimizer/transforms/truncate.py +100 -0
- ctxoptimizer/transforms/volatile.py +177 -0
- ctxoptimizer/transforms/whitespace.py +25 -0
- ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
- ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
- ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
- ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
- ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
- ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
ctxoptimizer/cli.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Command line interface (SPEC section 15).
|
|
2
|
+
|
|
3
|
+
P1 commands: run, status, env, doctor, stats, export, purge.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import shlex
|
|
12
|
+
import socket
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
from datetime import UTC, date, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated, Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import typer
|
|
21
|
+
import uvicorn
|
|
22
|
+
|
|
23
|
+
from ctxoptimizer import __version__
|
|
24
|
+
from ctxoptimizer.admin_security import load_tokens, save_tokens
|
|
25
|
+
from ctxoptimizer.analysis.recommend import recommendations
|
|
26
|
+
from ctxoptimizer.config import (
|
|
27
|
+
LOOPBACK_HOSTS,
|
|
28
|
+
Config,
|
|
29
|
+
data_dir,
|
|
30
|
+
default_config_path,
|
|
31
|
+
ensure_config_file,
|
|
32
|
+
load_config,
|
|
33
|
+
)
|
|
34
|
+
from ctxoptimizer.dashboard.service import findings_from_rows
|
|
35
|
+
from ctxoptimizer.models import Endpoint, Mode, Provider, RequestContext
|
|
36
|
+
from ctxoptimizer.pipeline import run_pipeline
|
|
37
|
+
from ctxoptimizer.providers import adapter_for
|
|
38
|
+
from ctxoptimizer.server import create_app
|
|
39
|
+
from ctxoptimizer.service import install_service, uninstall_service
|
|
40
|
+
from ctxoptimizer.telemetry.blobs import BlobStore
|
|
41
|
+
from ctxoptimizer.telemetry.pricing import load_pricing
|
|
42
|
+
from ctxoptimizer.telemetry.redact import redact_text
|
|
43
|
+
from ctxoptimizer.telemetry.store import default_db_path
|
|
44
|
+
from ctxoptimizer.transforms import build_transforms
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
add_completion=False,
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
help="Local, deterministic cost optimizer for AI coding agents.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_SHELLS = ("bash", "zsh", "fish", "powershell")
|
|
53
|
+
_Request = dict[str, Any]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _version_callback(value: bool) -> None:
|
|
57
|
+
if value:
|
|
58
|
+
typer.echo(__version__)
|
|
59
|
+
raise typer.Exit(code=0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.callback()
|
|
63
|
+
def main(
|
|
64
|
+
version: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--version",
|
|
68
|
+
is_eager=True,
|
|
69
|
+
callback=_version_callback,
|
|
70
|
+
help="Show the ctxoptimizer version and exit.",
|
|
71
|
+
),
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""ctxoptimizer command line."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def run(
|
|
79
|
+
host: Annotated[str | None, typer.Option("--host")] = None,
|
|
80
|
+
port: Annotated[int | None, typer.Option("--port")] = None,
|
|
81
|
+
mode: Annotated[str | None, typer.Option("--mode")] = None,
|
|
82
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Start the proxy in the foreground."""
|
|
85
|
+
if config_path is None:
|
|
86
|
+
ensure_config_file(default_config_path())
|
|
87
|
+
overrides: dict[str, object] = {}
|
|
88
|
+
if host:
|
|
89
|
+
overrides["server.host"] = host
|
|
90
|
+
if port:
|
|
91
|
+
overrides["server.port"] = port
|
|
92
|
+
if mode:
|
|
93
|
+
overrides["mode.default"] = mode
|
|
94
|
+
config = _load(config_path, overrides or None)
|
|
95
|
+
typer.echo(
|
|
96
|
+
f"ctxoptimizer {__version__} listening on "
|
|
97
|
+
f"http://{config.server.host}:{config.server.port} ({config.mode.default})"
|
|
98
|
+
)
|
|
99
|
+
if config.server.allow_remote:
|
|
100
|
+
typer.echo("warning: remote admin access is enabled; keep the token private")
|
|
101
|
+
app = create_app(config, config_path=config_path)
|
|
102
|
+
save_tokens(app.state.admin_token, app.state.csrf_token)
|
|
103
|
+
typer.echo(
|
|
104
|
+
"dashboard: "
|
|
105
|
+
f"http://{config.server.host}:{config.server.port}/_ctxoptimizer/"
|
|
106
|
+
f"?token={app.state.admin_token}"
|
|
107
|
+
)
|
|
108
|
+
uvicorn.run(
|
|
109
|
+
app,
|
|
110
|
+
host=config.server.host,
|
|
111
|
+
port=config.server.port,
|
|
112
|
+
log_level=config.server.log_level,
|
|
113
|
+
access_log=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def status(
|
|
119
|
+
url: Annotated[str | None, typer.Option("--url")] = None,
|
|
120
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Query a running proxy's health endpoint."""
|
|
123
|
+
config = _load(config_path)
|
|
124
|
+
target = url or f"http://{config.server.host}:{config.server.port}"
|
|
125
|
+
try:
|
|
126
|
+
response = httpx.get(f"{target}/_ctxoptimizer/health", timeout=2.0)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
typer.echo(f"error: cannot reach {target}: {exc}", err=True)
|
|
130
|
+
raise typer.Exit(code=1) from exc
|
|
131
|
+
data = response.json()
|
|
132
|
+
for key in (
|
|
133
|
+
"status",
|
|
134
|
+
"version",
|
|
135
|
+
"mode",
|
|
136
|
+
"uptime_s",
|
|
137
|
+
"requests",
|
|
138
|
+
"completed",
|
|
139
|
+
"client_disconnect",
|
|
140
|
+
"upstream_errors",
|
|
141
|
+
"dropped_telemetry",
|
|
142
|
+
"stored_requests",
|
|
143
|
+
"last_model",
|
|
144
|
+
"last_cost_usd",
|
|
145
|
+
"last_usage_source",
|
|
146
|
+
):
|
|
147
|
+
typer.echo(f"{key}: {data.get(key)}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def mode(
|
|
152
|
+
value: Annotated[str, typer.Argument(help="passthrough | shadow | active")],
|
|
153
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Switch mode at runtime via the admin API."""
|
|
156
|
+
if value not in {"passthrough", "shadow", "active"}:
|
|
157
|
+
raise typer.BadParameter("mode must be passthrough, shadow or active")
|
|
158
|
+
config = _load(config_path)
|
|
159
|
+
try:
|
|
160
|
+
data = _admin_request(config, "POST", "/_ctxoptimizer/api/mode", {"mode": value})
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
typer.echo(f"error: {exc}", err=True)
|
|
163
|
+
raise typer.Exit(code=1) from exc
|
|
164
|
+
typer.echo(f"mode: {data.get('mode')}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command()
|
|
168
|
+
def env(
|
|
169
|
+
shell: Annotated[str, typer.Option("--shell")] = "bash",
|
|
170
|
+
host: Annotated[str | None, typer.Option("--host")] = None,
|
|
171
|
+
port: Annotated[int | None, typer.Option("--port")] = None,
|
|
172
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Print client base-URL exports for the current shell."""
|
|
175
|
+
if shell not in _SHELLS:
|
|
176
|
+
raise typer.BadParameter(f"shell must be one of {', '.join(_SHELLS)}")
|
|
177
|
+
config = _load(config_path)
|
|
178
|
+
actual_host = host or config.server.host
|
|
179
|
+
actual_port = port or config.server.port
|
|
180
|
+
base = f"http://{actual_host}:{actual_port}"
|
|
181
|
+
values: dict[str, str] = {
|
|
182
|
+
"ANTHROPIC_BASE_URL": base,
|
|
183
|
+
"OPENAI_BASE_URL": f"{base}/v1",
|
|
184
|
+
}
|
|
185
|
+
for name in sorted(config.upstreams):
|
|
186
|
+
if name in {"anthropic", "openai", "gemini", "ollama"}:
|
|
187
|
+
continue
|
|
188
|
+
values[f"CTXOPTIMIZER_{name.upper()}_BASE_URL"] = f"{base}/u/{name}"
|
|
189
|
+
for name, value in values.items():
|
|
190
|
+
typer.echo(_export_line(shell, name, value))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def doctor(
|
|
195
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
196
|
+
offline: Annotated[bool, typer.Option("--offline")] = False,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Check the local configuration, storage and upstream reachability."""
|
|
199
|
+
config = _load(config_path)
|
|
200
|
+
failures = 0
|
|
201
|
+
|
|
202
|
+
def report(ok: bool, label: str, detail: str = "") -> None:
|
|
203
|
+
nonlocal failures
|
|
204
|
+
if not ok:
|
|
205
|
+
failures += 1
|
|
206
|
+
suffix = f": {detail}" if detail else ""
|
|
207
|
+
typer.echo(f"[{'ok' if ok else 'fail'}] {label}{suffix}")
|
|
208
|
+
|
|
209
|
+
def note(label: str, detail: str) -> None:
|
|
210
|
+
typer.echo(f"[warn] {label}: {detail}")
|
|
211
|
+
|
|
212
|
+
report(
|
|
213
|
+
config.server.host in LOOPBACK_HOSTS or config.server.allow_remote,
|
|
214
|
+
"bind host",
|
|
215
|
+
config.server.host,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
db_path = _db_path(config)
|
|
219
|
+
try:
|
|
220
|
+
with sqlite3.connect(db_path) as conn:
|
|
221
|
+
conn.execute("PRAGMA user_version").fetchone()
|
|
222
|
+
report(True, "database writable", str(db_path))
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
report(False, "database writable", str(exc))
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
table = load_pricing(Path(config.pricing.file) if config.pricing.file else None)
|
|
228
|
+
verified = sum(1 for price in table.models if price.verified)
|
|
229
|
+
typer.echo(f"[ok] pricing: version {table.version}, {verified} verified entries")
|
|
230
|
+
try:
|
|
231
|
+
age_days = (date.today() - date.fromisoformat(table.version)).days
|
|
232
|
+
if age_days > 60:
|
|
233
|
+
note("pricing age", f"{age_days} days; check provider pages")
|
|
234
|
+
except ValueError:
|
|
235
|
+
note("pricing age", f"unparseable version {table.version!r}")
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
report(False, "pricing", str(exc))
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
241
|
+
sock.bind((config.server.host, config.server.port))
|
|
242
|
+
typer.echo(f"[ok] port free: {config.server.port}")
|
|
243
|
+
except OSError:
|
|
244
|
+
note("port", f"{config.server.port} is already in use (is ctxoptimizer running?)")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
import importlib.util
|
|
248
|
+
|
|
249
|
+
if importlib.util.find_spec("tiktoken") is not None:
|
|
250
|
+
typer.echo("[ok] tiktoken available")
|
|
251
|
+
else:
|
|
252
|
+
note("tiktoken", "not installed; using the fallback estimator")
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
note("tiktoken", str(exc))
|
|
255
|
+
|
|
256
|
+
if not offline:
|
|
257
|
+
for name, entry in sorted(config.upstreams.items()):
|
|
258
|
+
base = entry if isinstance(entry, str) else entry.url
|
|
259
|
+
try:
|
|
260
|
+
response = httpx.head(base, timeout=2.0, follow_redirects=False)
|
|
261
|
+
reachable = response.status_code < 500
|
|
262
|
+
except httpx.HTTPError as exc:
|
|
263
|
+
reachable = False
|
|
264
|
+
note(f"upstream {name}", str(exc))
|
|
265
|
+
else:
|
|
266
|
+
typer.echo(
|
|
267
|
+
f"[{'ok' if reachable else 'warn'}] upstream {name}: "
|
|
268
|
+
f"HTTP {response.status_code}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
typer.echo("clients: claude-code, opencode, codex, aider, cline")
|
|
272
|
+
typer.echo("hint: `ctxoptimizer env --shell powershell` prints base-URL exports")
|
|
273
|
+
if failures:
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def stats(
|
|
279
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
280
|
+
since: Annotated[str | None, typer.Option("--since")] = None,
|
|
281
|
+
project: Annotated[str | None, typer.Option("--project")] = None,
|
|
282
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Print a terminal summary of recorded requests and cost."""
|
|
285
|
+
config = _load(config_path)
|
|
286
|
+
rows = _read_rows(_db_path(config), _parse_since(since), 100_000)
|
|
287
|
+
if project:
|
|
288
|
+
rows = [row for row in rows if row.get("project") == project]
|
|
289
|
+
requests = len(rows)
|
|
290
|
+
cost = sum(float(row.get("cost_actual_usd") or 0.0) for row in rows)
|
|
291
|
+
baseline = sum(float(row.get("cost_baseline_sim_usd") or 0.0) for row in rows)
|
|
292
|
+
fresh = sum(int(row.get("fresh_input") or 0) for row in rows)
|
|
293
|
+
read = sum(int(row.get("cache_read") or 0) for row in rows)
|
|
294
|
+
output = sum(int(row.get("output_tokens") or 0) for row in rows)
|
|
295
|
+
ratio = (read / (read + fresh)) if (read + fresh) else 0.0
|
|
296
|
+
data = {
|
|
297
|
+
"requests": requests,
|
|
298
|
+
"actual_cost_usd": cost,
|
|
299
|
+
"baseline_sim_usd": baseline,
|
|
300
|
+
"savings_sim_usd": max(baseline - cost, 0.0),
|
|
301
|
+
"fresh_input": fresh,
|
|
302
|
+
"cache_read": read,
|
|
303
|
+
"cache_hit_ratio": ratio,
|
|
304
|
+
"output_tokens": output,
|
|
305
|
+
}
|
|
306
|
+
if json_output:
|
|
307
|
+
typer.echo(json.dumps(data, indent=2))
|
|
308
|
+
return
|
|
309
|
+
typer.echo(f"requests: {requests}")
|
|
310
|
+
typer.echo(f"actual cost: ${cost:.6f}")
|
|
311
|
+
typer.echo(f"baseline cost (sim.): ${baseline:.6f}")
|
|
312
|
+
typer.echo(f"fresh input: {fresh}")
|
|
313
|
+
typer.echo(f"cache read: {read}")
|
|
314
|
+
typer.echo(f"cache hit ratio: {ratio:.1%}")
|
|
315
|
+
typer.echo(f"output tokens: {output}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command("export")
|
|
319
|
+
def export_command(
|
|
320
|
+
format: Annotated[str, typer.Option("--format")] = "json",
|
|
321
|
+
since: Annotated[str | None, typer.Option("--since")] = None,
|
|
322
|
+
limit: Annotated[int, typer.Option("--limit")] = 1000,
|
|
323
|
+
out: Annotated[Path | None, typer.Option("--out")] = None,
|
|
324
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Export recorded requests as JSON or CSV."""
|
|
327
|
+
config = _load(config_path)
|
|
328
|
+
rows = _read_rows(_db_path(config), _parse_since(since), limit)
|
|
329
|
+
if format == "json":
|
|
330
|
+
reports = _read_reports(_db_path(config), _parse_since(since), limit)
|
|
331
|
+
text = json.dumps({"requests": rows, "transform_reports": reports}, indent=2, default=str)
|
|
332
|
+
elif format == "csv":
|
|
333
|
+
text = _to_csv(rows)
|
|
334
|
+
else:
|
|
335
|
+
raise typer.BadParameter("format must be json or csv")
|
|
336
|
+
if out is None:
|
|
337
|
+
typer.echo(text)
|
|
338
|
+
else:
|
|
339
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
out.write_text(text, encoding="utf-8")
|
|
341
|
+
typer.echo(f"wrote {len(rows)} rows to {out}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
guard_app = typer.Typer(help="Inspect or override per-session transform quarantine.")
|
|
345
|
+
app.add_typer(guard_app, name="guard")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@guard_app.command("status")
|
|
349
|
+
def guard_show(
|
|
350
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Show quarantined transforms."""
|
|
353
|
+
config = _load(config_path)
|
|
354
|
+
try:
|
|
355
|
+
data = _admin_request(config, "GET", "/_ctxoptimizer/api/guard")
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
typer.echo(f"error: {exc}", err=True)
|
|
358
|
+
raise typer.Exit(code=1) from exc
|
|
359
|
+
for item in data.get("quarantined", []):
|
|
360
|
+
typer.echo(f"{item['transform_id']}\t{item['session_id']}\t{item['reason']}")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _guard_action(
|
|
364
|
+
action: str,
|
|
365
|
+
transform: str,
|
|
366
|
+
session: str | None,
|
|
367
|
+
config_path: Path | None,
|
|
368
|
+
) -> None:
|
|
369
|
+
config = _load(config_path)
|
|
370
|
+
body = {"action": action, "transform_id": transform}
|
|
371
|
+
if session:
|
|
372
|
+
body["session_id"] = session
|
|
373
|
+
try:
|
|
374
|
+
data = _admin_request(config, "POST", "/_ctxoptimizer/api/guard", body)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
typer.echo(f"error: {exc}", err=True)
|
|
377
|
+
raise typer.Exit(code=1) from exc
|
|
378
|
+
typer.echo(f"{action}: {data.get('transform_id')} ({len(data.get('sessions', []))} session(s))")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@guard_app.command("enable")
|
|
382
|
+
def guard_enable(
|
|
383
|
+
transform: Annotated[str, typer.Argument()],
|
|
384
|
+
session: Annotated[str | None, typer.Option("--session")] = None,
|
|
385
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Re-enable a transform (optionally for one session)."""
|
|
388
|
+
_guard_action("enable", transform, session, config_path)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@guard_app.command("disable")
|
|
392
|
+
def guard_disable(
|
|
393
|
+
transform: Annotated[str, typer.Argument()],
|
|
394
|
+
session: Annotated[str | None, typer.Option("--session")] = None,
|
|
395
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Quarantine a transform (optionally for one session)."""
|
|
398
|
+
_guard_action("disable", transform, session, config_path)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command()
|
|
402
|
+
def waste(
|
|
403
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
404
|
+
project: Annotated[str | None, typer.Option("--project")] = None,
|
|
405
|
+
since: Annotated[str | None, typer.Option("--since")] = None,
|
|
406
|
+
detector: Annotated[str | None, typer.Option("--detector")] = None,
|
|
407
|
+
summary: Annotated[bool, typer.Option("--summary")] = False,
|
|
408
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""List open waste findings ranked by estimated cost."""
|
|
411
|
+
config = _load(config_path)
|
|
412
|
+
rows = _read_findings(_db_path(config), _parse_since(since))
|
|
413
|
+
if project:
|
|
414
|
+
rows = [row for row in rows if row.get("project") == project]
|
|
415
|
+
if detector:
|
|
416
|
+
rows = [row for row in rows if row.get("detector") == detector]
|
|
417
|
+
if summary:
|
|
418
|
+
rollup: dict[str, dict[str, float]] = {}
|
|
419
|
+
for row in rows:
|
|
420
|
+
item = rollup.setdefault(
|
|
421
|
+
str(row.get("detector")), {"findings": 0, "tokens": 0.0, "cost": 0.0}
|
|
422
|
+
)
|
|
423
|
+
item["findings"] += 1
|
|
424
|
+
item["tokens"] += float(row.get("est_tokens_wasted") or 0)
|
|
425
|
+
item["cost"] += float(row.get("est_cost_usd") or 0)
|
|
426
|
+
if json_output:
|
|
427
|
+
typer.echo(json.dumps(rollup, indent=2))
|
|
428
|
+
return
|
|
429
|
+
for name, item in sorted(rollup.items(), key=lambda pair: -pair[1]["cost"]):
|
|
430
|
+
typer.echo(
|
|
431
|
+
f"{name}\tfindings={int(item['findings'])} "
|
|
432
|
+
f"est_tokens={int(item['tokens'])} est_usd={item['cost']:.6f}"
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
if json_output:
|
|
436
|
+
typer.echo(json.dumps(rows, indent=2))
|
|
437
|
+
return
|
|
438
|
+
for row in rows:
|
|
439
|
+
typer.echo(
|
|
440
|
+
f"{row.get('detector')}\t{row.get('session_id')}\t"
|
|
441
|
+
f"est_tokens={row.get('est_tokens_wasted')}\t"
|
|
442
|
+
f"est_usd={row.get('est_cost_usd')}\tstatus={row.get('status')}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.command()
|
|
447
|
+
def recommend(
|
|
448
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
449
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Print ranked recommendations with evidence."""
|
|
452
|
+
config = _load(config_path)
|
|
453
|
+
findings = findings_from_rows(_read_findings(_db_path(config), None))
|
|
454
|
+
items = recommendations(
|
|
455
|
+
findings,
|
|
456
|
+
cache_serve_enabled=config.transforms.cache_serve.enabled,
|
|
457
|
+
volatile_normalizer_enabled=config.transforms.volatile_normalizer.enabled,
|
|
458
|
+
transforms_enabled=True,
|
|
459
|
+
)
|
|
460
|
+
if json_output:
|
|
461
|
+
typer.echo(json.dumps(items, indent=2))
|
|
462
|
+
return
|
|
463
|
+
for item in items:
|
|
464
|
+
typer.echo(f"{item['title']} [{item['label']}]")
|
|
465
|
+
typer.echo(f" {item['evidence']}")
|
|
466
|
+
typer.echo(f" action: {item['action']}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@app.command()
|
|
470
|
+
def replay(
|
|
471
|
+
path: Annotated[Path, typer.Argument(help="captured request JSON")],
|
|
472
|
+
provider: Annotated[str, typer.Option("--provider")] = "anthropic",
|
|
473
|
+
endpoint: Annotated[str, typer.Option("--endpoint")] = "messages",
|
|
474
|
+
mode: Annotated[str, typer.Option("--mode")] = "active",
|
|
475
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Run the pipeline on one request offline; print reports and changed blocks."""
|
|
478
|
+
config = _load(config_path, {"mode.default": mode} if mode else None)
|
|
479
|
+
try:
|
|
480
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
481
|
+
provider_enum = Provider(provider)
|
|
482
|
+
endpoint_enum = Endpoint(endpoint)
|
|
483
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
484
|
+
typer.echo(f"error: {exc}", err=True)
|
|
485
|
+
raise typer.Exit(code=1) from exc
|
|
486
|
+
if not isinstance(payload, dict):
|
|
487
|
+
typer.echo("error: request JSON must be an object", err=True)
|
|
488
|
+
raise typer.Exit(code=1)
|
|
489
|
+
adapter = adapter_for(provider_enum, endpoint_enum)
|
|
490
|
+
ctx = RequestContext(
|
|
491
|
+
request_id="replay",
|
|
492
|
+
provider=provider_enum,
|
|
493
|
+
endpoint=endpoint_enum,
|
|
494
|
+
upstream_name=provider_enum.value,
|
|
495
|
+
model=adapter.model_of(payload),
|
|
496
|
+
stream=adapter.is_stream(payload),
|
|
497
|
+
mode=Mode(mode),
|
|
498
|
+
session_id="replay",
|
|
499
|
+
project=None,
|
|
500
|
+
client_name="replay",
|
|
501
|
+
config=config,
|
|
502
|
+
config_hash=config.config_hash,
|
|
503
|
+
started_monotonic=time.monotonic(),
|
|
504
|
+
)
|
|
505
|
+
result = run_pipeline(ctx, payload, adapter, build_transforms(config), config)
|
|
506
|
+
for report in result.reports:
|
|
507
|
+
typer.echo(
|
|
508
|
+
f"{report.transform_id}\tchanged={report.changed}\t"
|
|
509
|
+
f"tokens={report.est_tokens_before}->{report.est_tokens_after}\t"
|
|
510
|
+
f"{report.discarded_reason or ''}"
|
|
511
|
+
)
|
|
512
|
+
typer.echo(f"total: {result.orig_est_tokens} -> {result.final_est_tokens} est. tokens")
|
|
513
|
+
for index, (before, after) in enumerate(result.changed_blocks, start=1):
|
|
514
|
+
typer.echo(f"--- block {index} before ---")
|
|
515
|
+
typer.echo(before)
|
|
516
|
+
typer.echo(f"--- block {index} after ---")
|
|
517
|
+
typer.echo(after)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@app.command()
|
|
521
|
+
def bench(
|
|
522
|
+
rounds: Annotated[int, typer.Option("--rounds")] = 25,
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Run a small synthetic pipeline benchmark."""
|
|
525
|
+
config = _load(None, {"mode.default": "active"})
|
|
526
|
+
adapter = adapter_for(Provider.ANTHROPIC, Endpoint.MESSAGES)
|
|
527
|
+
samples: list[float] = []
|
|
528
|
+
for _ in range(max(1, rounds)):
|
|
529
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": "bench"}]
|
|
530
|
+
for index in range(20):
|
|
531
|
+
messages.extend(
|
|
532
|
+
[
|
|
533
|
+
{
|
|
534
|
+
"role": "assistant",
|
|
535
|
+
"content": [
|
|
536
|
+
{
|
|
537
|
+
"type": "tool_use",
|
|
538
|
+
"id": f"t{index}",
|
|
539
|
+
"name": "Bash",
|
|
540
|
+
"input": {"command": "pytest -q"},
|
|
541
|
+
}
|
|
542
|
+
],
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
"role": "user",
|
|
546
|
+
"content": [
|
|
547
|
+
{
|
|
548
|
+
"type": "tool_result",
|
|
549
|
+
"tool_use_id": f"t{index}",
|
|
550
|
+
"content": "\x1b[31mFAILED\x1b[0m\n" * 200,
|
|
551
|
+
}
|
|
552
|
+
],
|
|
553
|
+
},
|
|
554
|
+
]
|
|
555
|
+
)
|
|
556
|
+
payload = {"model": "claude-sonnet-4-5", "messages": messages}
|
|
557
|
+
ctx = RequestContext(
|
|
558
|
+
request_id="bench",
|
|
559
|
+
provider=Provider.ANTHROPIC,
|
|
560
|
+
endpoint=Endpoint.MESSAGES,
|
|
561
|
+
upstream_name="anthropic",
|
|
562
|
+
model="claude-sonnet-4-5",
|
|
563
|
+
stream=False,
|
|
564
|
+
mode=Mode.ACTIVE,
|
|
565
|
+
session_id="bench",
|
|
566
|
+
project=None,
|
|
567
|
+
client_name="bench",
|
|
568
|
+
config=config,
|
|
569
|
+
config_hash=config.config_hash,
|
|
570
|
+
started_monotonic=time.monotonic(),
|
|
571
|
+
)
|
|
572
|
+
started = time.perf_counter()
|
|
573
|
+
run_pipeline(ctx, payload, adapter, build_transforms(config), config)
|
|
574
|
+
samples.append((time.perf_counter() - started) * 1000)
|
|
575
|
+
samples.sort()
|
|
576
|
+
p50 = samples[len(samples) // 2]
|
|
577
|
+
p95 = samples[max(0, int(len(samples) * 0.95) - 1)]
|
|
578
|
+
typer.echo(f"rounds={len(samples)} p50_ms={p50:.2f} p95_ms={p95:.2f}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@app.command()
|
|
582
|
+
def capture(
|
|
583
|
+
out: Annotated[Path, typer.Option("--out")],
|
|
584
|
+
request_id: Annotated[str | None, typer.Option("--request")] = None,
|
|
585
|
+
sanitize: Annotated[bool, typer.Option("--sanitize")] = False,
|
|
586
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Export stored request metadata and changed blocks as review fixtures."""
|
|
589
|
+
config = _load(config_path)
|
|
590
|
+
db_path = _db_path(config)
|
|
591
|
+
rows = _read_rows(db_path, None, 1000)
|
|
592
|
+
if request_id:
|
|
593
|
+
rows = [row for row in rows if row["request_id"] == request_id]
|
|
594
|
+
blobs = BlobStore(data_dir() / "blobs")
|
|
595
|
+
written = 0
|
|
596
|
+
for row in rows:
|
|
597
|
+
target = out / str(row["request_id"])
|
|
598
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
599
|
+
meta = {key: value for key, value in row.items() if not key.startswith("_")}
|
|
600
|
+
(target / "meta.json").write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
|
|
601
|
+
blocks = _capture_blocks(db_path, str(row["request_id"]), blobs, sanitize)
|
|
602
|
+
(target / "blocks.json").write_text(json.dumps(blocks, indent=2), encoding="utf-8")
|
|
603
|
+
for block in blocks:
|
|
604
|
+
location = block.get("location")
|
|
605
|
+
text = block.get("before") or ""
|
|
606
|
+
if location == "request":
|
|
607
|
+
(target / "request.json").write_text(text, encoding="utf-8")
|
|
608
|
+
elif location == "final_request":
|
|
609
|
+
(target / "request.final.json").write_text(text, encoding="utf-8")
|
|
610
|
+
elif location == "response":
|
|
611
|
+
suffix = "sse" if _looks_sse(text) else "json"
|
|
612
|
+
(target / f"response.{suffix}").write_text(text, encoding="utf-8")
|
|
613
|
+
written += 1
|
|
614
|
+
typer.echo(f"wrote {written} fixture(s) to {out}")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@app.command()
|
|
618
|
+
def purge(
|
|
619
|
+
all_data: Annotated[bool, typer.Option("--all")] = False,
|
|
620
|
+
days: Annotated[int, typer.Option("--days")] = 14,
|
|
621
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Delete recorded telemetry, either everything or rows older than N days."""
|
|
624
|
+
config = _load(config_path)
|
|
625
|
+
db_path = _db_path(config)
|
|
626
|
+
if not db_path.exists():
|
|
627
|
+
typer.echo("nothing to purge")
|
|
628
|
+
return
|
|
629
|
+
with sqlite3.connect(db_path) as conn:
|
|
630
|
+
if all_data:
|
|
631
|
+
for statement in (
|
|
632
|
+
"DELETE FROM captures",
|
|
633
|
+
"DELETE FROM blobs",
|
|
634
|
+
"DELETE FROM guard_events",
|
|
635
|
+
"DELETE FROM waste_findings",
|
|
636
|
+
"DELETE FROM drift_events",
|
|
637
|
+
"DELETE FROM transform_reports",
|
|
638
|
+
"DELETE FROM requests",
|
|
639
|
+
"DELETE FROM sessions",
|
|
640
|
+
):
|
|
641
|
+
conn.execute(statement)
|
|
642
|
+
conn.commit()
|
|
643
|
+
typer.echo("purged all telemetry")
|
|
644
|
+
else:
|
|
645
|
+
cutoff = time.time() - days * 86_400
|
|
646
|
+
deleted = conn.execute("DELETE FROM requests WHERE ts < ?", (cutoff,)).rowcount
|
|
647
|
+
conn.commit()
|
|
648
|
+
typer.echo(f"purged {deleted} request(s) older than {days} day(s)")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _load(path: Path | None, overrides: dict[str, object] | None = None) -> Config:
|
|
652
|
+
try:
|
|
653
|
+
return load_config(path=path, overrides=overrides)
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
typer.echo(f"error: {exc}", err=True)
|
|
656
|
+
raise typer.Exit(code=1) from exc
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _db_path(config: Config) -> Path:
|
|
660
|
+
return Path(config.telemetry.db_path) if config.telemetry.db_path else default_db_path()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _export_line(shell: str, name: str, value: str) -> str:
|
|
664
|
+
if shell == "powershell":
|
|
665
|
+
return f'$env:{name} = "{value}"'
|
|
666
|
+
if shell == "fish":
|
|
667
|
+
return f"set -gx {name} {shlex.quote(value)}"
|
|
668
|
+
return f'export {name}="{value}"'
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _parse_since(value: str | None) -> float | None:
|
|
672
|
+
if not value:
|
|
673
|
+
return None
|
|
674
|
+
now = time.time()
|
|
675
|
+
for suffix, seconds in (("m", 60), ("h", 3600), ("d", 86_400)):
|
|
676
|
+
if value.endswith(suffix) and value[:-1].isdigit():
|
|
677
|
+
return now - int(value[:-1]) * seconds
|
|
678
|
+
try:
|
|
679
|
+
parsed = datetime.fromisoformat(value)
|
|
680
|
+
except ValueError as exc:
|
|
681
|
+
raise typer.BadParameter("--since must be 30m, 24h, 7d or an ISO timestamp") from exc
|
|
682
|
+
if parsed.tzinfo is None:
|
|
683
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
684
|
+
return parsed.timestamp()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _capture_blocks(
|
|
688
|
+
db_path: Path, request_id: str, blobs: BlobStore, sanitize: bool
|
|
689
|
+
) -> list[dict[str, Any]]:
|
|
690
|
+
if not db_path.exists():
|
|
691
|
+
return []
|
|
692
|
+
with sqlite3.connect(db_path) as conn:
|
|
693
|
+
conn.row_factory = sqlite3.Row
|
|
694
|
+
rows = conn.execute(
|
|
695
|
+
"SELECT * FROM captures WHERE request_id = ? ORDER BY rowid",
|
|
696
|
+
(request_id,),
|
|
697
|
+
).fetchall()
|
|
698
|
+
blocks: list[dict[str, Any]] = []
|
|
699
|
+
for row in rows:
|
|
700
|
+
before = blobs.get(row["before_hash"] or "")
|
|
701
|
+
after = blobs.get(row["after_hash"] or "")
|
|
702
|
+
blocks.append(
|
|
703
|
+
{
|
|
704
|
+
"location": row["location"],
|
|
705
|
+
"before": _fixture_text(before, sanitize),
|
|
706
|
+
"after": _fixture_text(after, sanitize),
|
|
707
|
+
}
|
|
708
|
+
)
|
|
709
|
+
return blocks
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _fixture_text(data: bytes | None, sanitize: bool) -> str | None:
|
|
713
|
+
if data is None:
|
|
714
|
+
return None
|
|
715
|
+
text = redact_text(data.decode("utf-8", "replace"))
|
|
716
|
+
if sanitize:
|
|
717
|
+
import re
|
|
718
|
+
|
|
719
|
+
text = re.sub(r"[A-Za-z]:\\Users\\[^\\\s]+", "<home>", text)
|
|
720
|
+
text = re.sub(r"/(?:home|Users)/[^/\s]+", "<home>", text)
|
|
721
|
+
text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "user@example.com", text)
|
|
722
|
+
return text
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _read_rows(db_path: Path, since_ts: float | None, limit: int) -> list[_Request]:
|
|
726
|
+
if not db_path.exists():
|
|
727
|
+
return []
|
|
728
|
+
with sqlite3.connect(db_path) as conn:
|
|
729
|
+
conn.row_factory = sqlite3.Row
|
|
730
|
+
query = "SELECT * FROM requests"
|
|
731
|
+
params: list[Any] = []
|
|
732
|
+
if since_ts is not None:
|
|
733
|
+
query += " WHERE ts >= ?"
|
|
734
|
+
params.append(since_ts)
|
|
735
|
+
query += " ORDER BY ts DESC LIMIT ?"
|
|
736
|
+
params.append(limit)
|
|
737
|
+
return [dict(row) for row in conn.execute(query, params).fetchall()]
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _looks_sse(text: str) -> bool:
|
|
741
|
+
return "data:" in text and "\n\n" in text
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _read_reports(db_path: Path, since_ts: float | None, limit: int) -> list[_Request]:
|
|
745
|
+
if not db_path.exists():
|
|
746
|
+
return []
|
|
747
|
+
with sqlite3.connect(db_path) as conn:
|
|
748
|
+
conn.row_factory = sqlite3.Row
|
|
749
|
+
query = (
|
|
750
|
+
"SELECT transform_reports.*, requests.ts FROM transform_reports "
|
|
751
|
+
"JOIN requests USING(request_id)"
|
|
752
|
+
)
|
|
753
|
+
params: list[Any] = []
|
|
754
|
+
if since_ts is not None:
|
|
755
|
+
query += " WHERE requests.ts >= ?"
|
|
756
|
+
params.append(since_ts)
|
|
757
|
+
query += " ORDER BY requests.ts DESC LIMIT ?"
|
|
758
|
+
params.append(limit)
|
|
759
|
+
return [dict(row) for row in conn.execute(query, params).fetchall()]
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _read_findings(db_path: Path, since_ts: float | None) -> list[_Request]:
|
|
763
|
+
if not db_path.exists():
|
|
764
|
+
return []
|
|
765
|
+
with sqlite3.connect(db_path) as conn:
|
|
766
|
+
conn.row_factory = sqlite3.Row
|
|
767
|
+
query = "SELECT * FROM waste_findings WHERE status IN ('new', 'ack')"
|
|
768
|
+
params: list[Any] = []
|
|
769
|
+
if since_ts is not None:
|
|
770
|
+
query += " AND first_seen_ts >= ?"
|
|
771
|
+
params.append(since_ts)
|
|
772
|
+
query += " ORDER BY est_cost_usd DESC, ts DESC"
|
|
773
|
+
return [dict(row) for row in conn.execute(query, params).fetchall()]
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _admin_request(
|
|
777
|
+
config: Config,
|
|
778
|
+
method: str,
|
|
779
|
+
path: str,
|
|
780
|
+
body: dict[str, Any] | None = None,
|
|
781
|
+
) -> dict[str, Any]:
|
|
782
|
+
admin_token, csrf_token = load_tokens()
|
|
783
|
+
admin_token = admin_token or config.server.admin_token
|
|
784
|
+
if not admin_token:
|
|
785
|
+
raise RuntimeError("no admin token found; start `ctxoptimizer run` first")
|
|
786
|
+
headers = {"x-ctxoptimizer-admin-token": admin_token}
|
|
787
|
+
if method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
|
788
|
+
if not csrf_token:
|
|
789
|
+
raise RuntimeError("no CSRF token found; start `ctxoptimizer run` first")
|
|
790
|
+
headers["x-ctxoptimizer-csrf"] = csrf_token
|
|
791
|
+
base = f"http://{config.server.host}:{config.server.port}"
|
|
792
|
+
response = httpx.request(
|
|
793
|
+
method,
|
|
794
|
+
f"{base}{path}",
|
|
795
|
+
json=body,
|
|
796
|
+
headers=headers,
|
|
797
|
+
timeout=5.0,
|
|
798
|
+
)
|
|
799
|
+
if response.status_code >= 400:
|
|
800
|
+
raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
|
|
801
|
+
data = response.json()
|
|
802
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _to_csv(rows: list[_Request]) -> str:
|
|
806
|
+
if not rows:
|
|
807
|
+
return ""
|
|
808
|
+
buffer = io.StringIO()
|
|
809
|
+
writer = csv.DictWriter(buffer, fieldnames=list(rows[0].keys()))
|
|
810
|
+
writer.writeheader()
|
|
811
|
+
writer.writerows(rows)
|
|
812
|
+
return buffer.getvalue()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
mcp_app = typer.Typer(help="MCP integration for ctxoptimizer_expand.")
|
|
816
|
+
app.add_typer(mcp_app, name="mcp")
|
|
817
|
+
|
|
818
|
+
service_app = typer.Typer(help="Install or remove the per-user autostart service.")
|
|
819
|
+
app.add_typer(service_app, name="service")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@service_app.command("install")
|
|
823
|
+
def service_install(
|
|
824
|
+
config_path: Annotated[Path | None, typer.Option("--config")] = None,
|
|
825
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
826
|
+
) -> None:
|
|
827
|
+
"""Install launchd/systemd/scheduled-task autostart."""
|
|
828
|
+
plan = install_service(config_path=str(config_path) if config_path else None, dry_run=dry_run)
|
|
829
|
+
typer.echo(f"platform: {plan.platform}")
|
|
830
|
+
typer.echo(f"unit: {plan.path}")
|
|
831
|
+
for command in plan.commands:
|
|
832
|
+
typer.echo("run: " + " ".join(command))
|
|
833
|
+
if dry_run:
|
|
834
|
+
typer.echo("(dry run; nothing written or executed)")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@service_app.command("uninstall")
|
|
838
|
+
def service_uninstall(
|
|
839
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
840
|
+
) -> None:
|
|
841
|
+
"""Remove the autostart service."""
|
|
842
|
+
plan = uninstall_service(dry_run=dry_run)
|
|
843
|
+
for command in plan.remove_commands:
|
|
844
|
+
typer.echo("run: " + " ".join(command))
|
|
845
|
+
typer.echo(f"removed: {plan.path}")
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@mcp_app.command("run")
|
|
849
|
+
def mcp_run() -> None:
|
|
850
|
+
"""Serve the expand tool over stdio."""
|
|
851
|
+
from ctxoptimizer.mcp import run_stdio
|
|
852
|
+
|
|
853
|
+
run_stdio()
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@mcp_app.command("snippet")
|
|
857
|
+
def mcp_snippet(
|
|
858
|
+
client: Annotated[str, typer.Option("--client")] = "claude-code",
|
|
859
|
+
) -> None:
|
|
860
|
+
"""Print a registration snippet for an MCP client."""
|
|
861
|
+
typer.echo(
|
|
862
|
+
json.dumps(
|
|
863
|
+
{
|
|
864
|
+
"mcpServers": {"ctxoptimizer": {"command": "ctxoptimizer", "args": ["mcp", "run"]}},
|
|
865
|
+
"client": client,
|
|
866
|
+
},
|
|
867
|
+
indent=2,
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
if __name__ == "__main__":
|
|
873
|
+
app()
|