spooling 0.2.1__tar.gz → 0.2.3__tar.gz
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.
- {spooling-0.2.1/spooling.egg-info → spooling-0.2.3}/PKG-INFO +1 -1
- {spooling-0.2.1 → spooling-0.2.3}/pyproject.toml +1 -1
- {spooling-0.2.1 → spooling-0.2.3}/spooling/cli.py +421 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/cloud.py +4 -3
- {spooling-0.2.1 → spooling-0.2.3}/spooling/tunnel.py +91 -19
- {spooling-0.2.1 → spooling-0.2.3/spooling.egg-info}/PKG-INFO +1 -1
- {spooling-0.2.1 → spooling-0.2.3}/LICENSE +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/README.md +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/setup.cfg +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/agent.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/classifiers.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/config.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/connectors/types.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/db.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/embeddings.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/evals.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/experiments.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/ingest.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/mcp_server.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/parser.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/pricing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/base.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/codex.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/github.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/redact.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/remote_otel.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/schema.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/sdk.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/search.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/server.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/stats.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/tracing.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling/watcher.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling.egg-info/SOURCES.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.1 → spooling-0.2.3}/tests/test_tunnel_auth.py +0 -0
|
@@ -318,6 +318,39 @@ def otel_ingest(path, provider_id, project):
|
|
|
318
318
|
raise SystemExit(1)
|
|
319
319
|
|
|
320
320
|
|
|
321
|
+
@otel.command("preview")
|
|
322
|
+
@click.option("--file", "path", required=True, type=click.Path(exists=True), help="OTLP JSON export file")
|
|
323
|
+
@click.option("--provider", "provider_id", default="otel-remote", help="Provider id")
|
|
324
|
+
@click.option("--project", default=None, help="Project name")
|
|
325
|
+
def otel_preview(path, provider_id, project):
|
|
326
|
+
"""Dry-run: show what an OTLP file would produce without writing to the DB."""
|
|
327
|
+
from spooling.remote_otel import ingest_otlp_json_file
|
|
328
|
+
import json as _json
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
# Parse the file to get the trace object without persisting
|
|
332
|
+
with open(path) as f:
|
|
333
|
+
raw = _json.load(f)
|
|
334
|
+
span_count = sum(
|
|
335
|
+
len(rs.get("scopeSpans", [{}])[0].get("spans", []))
|
|
336
|
+
for rs in raw.get("resourceSpans", [])
|
|
337
|
+
) if isinstance(raw, dict) else 0
|
|
338
|
+
|
|
339
|
+
console.print(Panel(
|
|
340
|
+
f"File: [bold]{path}[/bold]\n"
|
|
341
|
+
f"Provider: {provider_id}\n"
|
|
342
|
+
f"Project: {project or '(none)'}\n"
|
|
343
|
+
f"Resource span groups: {len(raw.get('resourceSpans', []))}\n"
|
|
344
|
+
f"Total spans: [bold]{span_count}[/bold]\n"
|
|
345
|
+
f"\n[dim]Run without --dry-run equivalent: spooling otel ingest --file {path}[/dim]",
|
|
346
|
+
title="[bold]OTel Preview (dry run)[/bold]",
|
|
347
|
+
style="cyan",
|
|
348
|
+
))
|
|
349
|
+
except Exception as e:
|
|
350
|
+
console.print(f"[red]Preview failed:[/red] {e}")
|
|
351
|
+
raise SystemExit(1)
|
|
352
|
+
|
|
353
|
+
|
|
321
354
|
@cli.group()
|
|
322
355
|
def experiment():
|
|
323
356
|
"""Create and run Strands experiments (cases + evaluators)."""
|
|
@@ -376,6 +409,89 @@ def experiment_run(experiment_id):
|
|
|
376
409
|
console.print(f" - {name}: {score}")
|
|
377
410
|
|
|
378
411
|
|
|
412
|
+
@experiment.command("inspect")
|
|
413
|
+
@click.option("--id", "experiment_id", required=True, help="Experiment id")
|
|
414
|
+
def experiment_inspect(experiment_id):
|
|
415
|
+
"""Show the spec/definition of an experiment."""
|
|
416
|
+
from spooling.experiments import load_experiment
|
|
417
|
+
import json as _json
|
|
418
|
+
|
|
419
|
+
spec = load_experiment(experiment_id)
|
|
420
|
+
if not spec:
|
|
421
|
+
console.print(f"[red]Experiment not found: {experiment_id}[/red]")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
console.print(Panel(
|
|
425
|
+
f"ID: [bold]{spec.id}[/bold]\n"
|
|
426
|
+
f"Name: {spec.name}\n"
|
|
427
|
+
f"Description: {spec.description or '—'}\n"
|
|
428
|
+
f"Cases: {len(spec.cases)}\n"
|
|
429
|
+
f"Evaluators: {len(spec.evaluators)}",
|
|
430
|
+
title="[bold]Experiment Spec[/bold]",
|
|
431
|
+
style="cyan",
|
|
432
|
+
))
|
|
433
|
+
|
|
434
|
+
if spec.cases:
|
|
435
|
+
table = Table(title="Cases", show_lines=True)
|
|
436
|
+
table.add_column("#", justify="right")
|
|
437
|
+
table.add_column("Input")
|
|
438
|
+
table.add_column("Expected", style="dim")
|
|
439
|
+
for i, c in enumerate(spec.cases, 1):
|
|
440
|
+
inp = _json.dumps(c.get("input", ""))[:80] if isinstance(c.get("input"), dict) else str(c.get("input", ""))[:80]
|
|
441
|
+
exp = str(c.get("expected_output", ""))[:60]
|
|
442
|
+
table.add_row(str(i), inp, exp)
|
|
443
|
+
console.print(table)
|
|
444
|
+
|
|
445
|
+
if spec.evaluators:
|
|
446
|
+
table = Table(title="Evaluators", show_lines=False)
|
|
447
|
+
table.add_column("Name", style="cyan")
|
|
448
|
+
table.add_column("Kind")
|
|
449
|
+
for e in spec.evaluators:
|
|
450
|
+
table.add_row(e.get("name", ""), e.get("kind", ""))
|
|
451
|
+
console.print(table)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@experiment.command("runs")
|
|
455
|
+
@click.option("--id", "experiment_id", default=None, help="Filter to a specific experiment id")
|
|
456
|
+
@click.option("--limit", "-n", default=20, help="Max runs to show (default: 20)")
|
|
457
|
+
def experiment_runs(experiment_id, limit):
|
|
458
|
+
"""List past experiment run history."""
|
|
459
|
+
from spooling.experiments import list_runs
|
|
460
|
+
import json as _json
|
|
461
|
+
|
|
462
|
+
rows = list_runs(experiment_id=experiment_id, limit=limit)
|
|
463
|
+
if not rows:
|
|
464
|
+
console.print("[yellow]No runs found.[/yellow]")
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
table = Table(title="Experiment Runs", show_lines=False)
|
|
468
|
+
table.add_column("Run ID", style="cyan")
|
|
469
|
+
table.add_column("Experiment")
|
|
470
|
+
table.add_column("Status")
|
|
471
|
+
table.add_column("Started", style="dim")
|
|
472
|
+
table.add_column("Finished", style="dim")
|
|
473
|
+
table.add_column("Scores")
|
|
474
|
+
|
|
475
|
+
for r in rows:
|
|
476
|
+
scores = r.get("overall_scores") or {}
|
|
477
|
+
if isinstance(scores, str):
|
|
478
|
+
try:
|
|
479
|
+
scores = _json.loads(scores)
|
|
480
|
+
except Exception:
|
|
481
|
+
scores = {}
|
|
482
|
+
score_str = " ".join(f"{k}: {v:.2f}" if isinstance(v, float) else f"{k}: {v}" for k, v in scores.items())
|
|
483
|
+
status_color = "green" if r["status"] == "completed" else "red" if r["status"] == "failed" else "yellow"
|
|
484
|
+
table.add_row(
|
|
485
|
+
(r["id"] or "")[:16],
|
|
486
|
+
(r["experiment_id"] or "")[:20],
|
|
487
|
+
f"[{status_color}]{r['status']}[/{status_color}]",
|
|
488
|
+
str(r.get("started_at") or "")[:19],
|
|
489
|
+
str(r.get("finished_at") or "")[:19],
|
|
490
|
+
score_str or "—",
|
|
491
|
+
)
|
|
492
|
+
console.print(table)
|
|
493
|
+
|
|
494
|
+
|
|
379
495
|
@experiment.command("show")
|
|
380
496
|
@click.option("--run", "run_id", required=True, help="Run id")
|
|
381
497
|
def experiment_show(run_id):
|
|
@@ -588,6 +704,74 @@ def cost_session(session_id):
|
|
|
588
704
|
console.print(table)
|
|
589
705
|
|
|
590
706
|
|
|
707
|
+
@cost.command("subscription")
|
|
708
|
+
@click.option("--provider", "-p", required=True,
|
|
709
|
+
help=f"Provider id (e.g. cursor, copilot, windsurf, codex, gemini, kiro)")
|
|
710
|
+
@click.option("--days", default=30, type=int, help="Look-back window in days (default: 30)")
|
|
711
|
+
def cost_subscription(provider, days):
|
|
712
|
+
"""Estimate flat-rate subscription spend for a provider.
|
|
713
|
+
|
|
714
|
+
Uses your actual model usage to infer which plan tier you were on,
|
|
715
|
+
then calculates the subscription cost over the date range — which is
|
|
716
|
+
typically far lower than the API-equivalent token cost shown by
|
|
717
|
+
'spooling cost overview'.
|
|
718
|
+
|
|
719
|
+
\b
|
|
720
|
+
Examples:
|
|
721
|
+
spooling cost subscription --provider cursor
|
|
722
|
+
spooling cost subscription --provider copilot --days 90
|
|
723
|
+
"""
|
|
724
|
+
from spooling.subscription_pricing import subscription_cost_for_range
|
|
725
|
+
from spooling.db import get_connection
|
|
726
|
+
from datetime import datetime as _dt, timezone, timedelta
|
|
727
|
+
|
|
728
|
+
conn = get_connection()
|
|
729
|
+
cutoff = (_dt.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
730
|
+
rows = conn.execute(
|
|
731
|
+
"""SELECT model, MIN(started_at) AS first, MAX(ended_at) AS last
|
|
732
|
+
FROM sessions
|
|
733
|
+
WHERE provider_id = ? AND started_at >= ?
|
|
734
|
+
GROUP BY model""",
|
|
735
|
+
(provider, cutoff),
|
|
736
|
+
).fetchall()
|
|
737
|
+
conn.close()
|
|
738
|
+
|
|
739
|
+
if not rows:
|
|
740
|
+
console.print(f"[yellow]No sessions found for provider '{provider}' in the last {days} days.[/yellow]")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
models_used = [r["model"] for r in rows]
|
|
744
|
+
# date range across all sessions
|
|
745
|
+
all_starts = [r["first"] for r in rows if r["first"]]
|
|
746
|
+
all_ends = [r["last"] for r in rows if r["last"]]
|
|
747
|
+
|
|
748
|
+
def _parse(ts):
|
|
749
|
+
if not ts:
|
|
750
|
+
return None
|
|
751
|
+
if isinstance(ts, _dt):
|
|
752
|
+
return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts
|
|
753
|
+
try:
|
|
754
|
+
return _dt.fromisoformat(ts).replace(tzinfo=timezone.utc)
|
|
755
|
+
except Exception:
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
started_at = min((_parse(t) for t in all_starts if _parse(t)), default=None)
|
|
759
|
+
ended_at = max((_parse(t) for t in all_ends if _parse(t)), default=None) or _dt.now(timezone.utc)
|
|
760
|
+
|
|
761
|
+
result = subscription_cost_for_range(provider, models_used, started_at, ended_at)
|
|
762
|
+
|
|
763
|
+
console.print(Panel(
|
|
764
|
+
f"Provider: [bold]{provider}[/bold]\n"
|
|
765
|
+
f"Plan tier: [bold]{result['plan']}[/bold]\n"
|
|
766
|
+
f"Monthly cost: ${result['monthly_usd']:.2f}/mo\n"
|
|
767
|
+
f"Months billed: {result['months']}\n"
|
|
768
|
+
f"Est. total: [bold]${result['cost_usd']:.2f}[/bold]\n"
|
|
769
|
+
f"\n[dim]Models detected: {', '.join(m for m in set(models_used) if m) or '—'}[/dim]",
|
|
770
|
+
title="[bold]Subscription Cost Estimate[/bold]",
|
|
771
|
+
style="green",
|
|
772
|
+
))
|
|
773
|
+
|
|
774
|
+
|
|
591
775
|
@cost.command("recalc")
|
|
592
776
|
@click.option("--session", "session_id", default=None, help="Single session id to recalc")
|
|
593
777
|
@click.option("--provider", "-p", default=None, help="Recalc all sessions for a provider")
|
|
@@ -853,6 +1037,243 @@ cli.add_command(_cloud_group)
|
|
|
853
1037
|
cli.add_command(_push_cmd)
|
|
854
1038
|
|
|
855
1039
|
|
|
1040
|
+
# ---------------------------------------------------------------------------
|
|
1041
|
+
# Redact
|
|
1042
|
+
# ---------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
@cli.command()
|
|
1045
|
+
@click.option("--file", "path", default=None, type=click.Path(exists=True), help="File to scan for secrets")
|
|
1046
|
+
@click.option("--session", "session_id", default=None, help="Scan a specific session's messages from the local DB")
|
|
1047
|
+
@click.option("--show", is_flag=True, help="Print the redacted text (default: summary only)")
|
|
1048
|
+
def redact(path, session_id, show):
|
|
1049
|
+
"""Scan text or a session for secrets that would be redacted on push.
|
|
1050
|
+
|
|
1051
|
+
Useful for auditing what the pre-push redactor would catch before
|
|
1052
|
+
running 'spooling push'.
|
|
1053
|
+
|
|
1054
|
+
\b
|
|
1055
|
+
Examples:
|
|
1056
|
+
spooling redact --file ./notes.txt
|
|
1057
|
+
spooling redact --session <session_id>
|
|
1058
|
+
spooling redact --file ./notes.txt --show
|
|
1059
|
+
"""
|
|
1060
|
+
from spooling.redact import redact_text
|
|
1061
|
+
|
|
1062
|
+
if not path and not session_id:
|
|
1063
|
+
console.print("[red]Provide --file or --session.[/red]")
|
|
1064
|
+
raise SystemExit(1)
|
|
1065
|
+
|
|
1066
|
+
if path:
|
|
1067
|
+
with open(path) as f:
|
|
1068
|
+
text = f.read()
|
|
1069
|
+
source = path
|
|
1070
|
+
else:
|
|
1071
|
+
from spooling.db import get_connection
|
|
1072
|
+
conn = get_connection()
|
|
1073
|
+
rows = conn.execute(
|
|
1074
|
+
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY timestamp",
|
|
1075
|
+
(session_id,),
|
|
1076
|
+
).fetchall()
|
|
1077
|
+
conn.close()
|
|
1078
|
+
if not rows:
|
|
1079
|
+
console.print(f"[yellow]No messages found for session {session_id}[/yellow]")
|
|
1080
|
+
return
|
|
1081
|
+
text = "\n".join(f"[{r['role']}] {r['content'] or ''}" for r in rows)
|
|
1082
|
+
source = f"session {session_id[:12]}..."
|
|
1083
|
+
|
|
1084
|
+
redacted, hits = redact_text(text)
|
|
1085
|
+
|
|
1086
|
+
if not hits:
|
|
1087
|
+
console.print(f"[green]No secrets detected in {source}.[/green]")
|
|
1088
|
+
else:
|
|
1089
|
+
console.print(f"[yellow]{len(hits)} secret(s) detected in {source}:[/yellow]")
|
|
1090
|
+
table = Table(show_lines=False)
|
|
1091
|
+
table.add_column("Label", style="red")
|
|
1092
|
+
table.add_column("Position", style="dim")
|
|
1093
|
+
for h in hits:
|
|
1094
|
+
table.add_row(h.label, f"chars {h.span[0]}–{h.span[1]}")
|
|
1095
|
+
console.print(table)
|
|
1096
|
+
|
|
1097
|
+
if show:
|
|
1098
|
+
console.print("\n[bold]Redacted text:[/bold]")
|
|
1099
|
+
console.print(redacted[:4000] + ("[dim]... truncated[/dim]" if len(redacted) > 4000 else ""))
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
# ---------------------------------------------------------------------------
|
|
1103
|
+
# Sessions
|
|
1104
|
+
# ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
@cli.group()
|
|
1107
|
+
def session():
|
|
1108
|
+
"""Inspect and list local sessions."""
|
|
1109
|
+
pass
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@session.command("list")
|
|
1113
|
+
@click.option("--project", "-p", default=None, help="Filter by exact project name")
|
|
1114
|
+
@click.option("--cwd", default=None, help="Filter by cwd substring")
|
|
1115
|
+
@click.option("--title", default=None, help="Filter by title substring")
|
|
1116
|
+
@click.option("--limit", "-n", default=20, help="Max sessions to return (default: 20)")
|
|
1117
|
+
def session_list(project, cwd, title, limit):
|
|
1118
|
+
"""List local sessions with optional filters."""
|
|
1119
|
+
from spooling.db import get_connection
|
|
1120
|
+
from datetime import datetime as _dt
|
|
1121
|
+
|
|
1122
|
+
conn = get_connection()
|
|
1123
|
+
wheres = []
|
|
1124
|
+
params: list = []
|
|
1125
|
+
if project:
|
|
1126
|
+
wheres.append("LOWER(project) = LOWER(?)")
|
|
1127
|
+
params.append(project)
|
|
1128
|
+
if cwd:
|
|
1129
|
+
wheres.append("cwd LIKE ?")
|
|
1130
|
+
params.append(f"%{cwd}%")
|
|
1131
|
+
if title:
|
|
1132
|
+
wheres.append("title LIKE ?")
|
|
1133
|
+
params.append(f"%{title}%")
|
|
1134
|
+
where_clause = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
|
1135
|
+
params.append(limit)
|
|
1136
|
+
|
|
1137
|
+
rows = conn.execute(
|
|
1138
|
+
f"""SELECT id, provider_id, project, title, started_at,
|
|
1139
|
+
message_count, tool_call_count, estimated_cost_usd
|
|
1140
|
+
FROM sessions {where_clause}
|
|
1141
|
+
ORDER BY started_at DESC NULLS LAST LIMIT ?""",
|
|
1142
|
+
tuple(params),
|
|
1143
|
+
).fetchall()
|
|
1144
|
+
conn.close()
|
|
1145
|
+
|
|
1146
|
+
if not rows:
|
|
1147
|
+
console.print("[yellow]No sessions found.[/yellow]")
|
|
1148
|
+
return
|
|
1149
|
+
|
|
1150
|
+
table = Table(title=f"Sessions ({len(rows)})", show_lines=False)
|
|
1151
|
+
table.add_column("ID", style="dim")
|
|
1152
|
+
table.add_column("Provider", style="cyan")
|
|
1153
|
+
table.add_column("Project")
|
|
1154
|
+
table.add_column("Title")
|
|
1155
|
+
table.add_column("Started", style="dim")
|
|
1156
|
+
table.add_column("Msgs", justify="right")
|
|
1157
|
+
table.add_column("Tools", justify="right")
|
|
1158
|
+
table.add_column("Cost", justify="right")
|
|
1159
|
+
|
|
1160
|
+
for r in rows:
|
|
1161
|
+
raw_ts = r["started_at"]
|
|
1162
|
+
if raw_ts and isinstance(raw_ts, str):
|
|
1163
|
+
try:
|
|
1164
|
+
raw_ts = _dt.fromisoformat(raw_ts)
|
|
1165
|
+
except ValueError:
|
|
1166
|
+
raw_ts = None
|
|
1167
|
+
ts = raw_ts.strftime("%m/%d %H:%M") if raw_ts else ""
|
|
1168
|
+
table.add_row(
|
|
1169
|
+
(r["id"] or "")[:12],
|
|
1170
|
+
r["provider_id"] or "",
|
|
1171
|
+
_clean_project(r["project"] or ""),
|
|
1172
|
+
(r["title"] or "")[:45],
|
|
1173
|
+
ts,
|
|
1174
|
+
str(r["message_count"] or 0),
|
|
1175
|
+
str(r["tool_call_count"] or 0),
|
|
1176
|
+
f"${float(r['estimated_cost_usd'] or 0):.4f}",
|
|
1177
|
+
)
|
|
1178
|
+
console.print(table)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
@session.command("show")
|
|
1182
|
+
@click.argument("session_id")
|
|
1183
|
+
@click.option("--messages", "-m", is_flag=True, help="Show full message content")
|
|
1184
|
+
def session_show(session_id, messages):
|
|
1185
|
+
"""Show full detail for a session including messages and tool calls."""
|
|
1186
|
+
from spooling.stats import get_session_detail
|
|
1187
|
+
|
|
1188
|
+
detail = get_session_detail(session_id)
|
|
1189
|
+
if not detail:
|
|
1190
|
+
console.print(f"[yellow]Session not found: {session_id}[/yellow]")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
s = detail["session"]
|
|
1194
|
+
console.print(Panel(
|
|
1195
|
+
f"ID: [bold]{s['id']}[/bold]\n"
|
|
1196
|
+
f"Provider: {s.get('provider_id') or '—'}\n"
|
|
1197
|
+
f"Project: {_clean_project(s.get('project') or '—')}\n"
|
|
1198
|
+
f"Model: {s.get('model') or '—'}\n"
|
|
1199
|
+
f"Started: {str(s.get('started_at') or '')[:19]}\n"
|
|
1200
|
+
f"Ended: {str(s.get('ended_at') or '')[:19]}\n"
|
|
1201
|
+
f"Messages: {s.get('message_count', 0)}\n"
|
|
1202
|
+
f"Tool calls: {s.get('tool_call_count', 0)}\n"
|
|
1203
|
+
f"In tokens: {s.get('estimated_input_tokens', 0):,}\n"
|
|
1204
|
+
f"Out tokens: {s.get('estimated_output_tokens', 0):,}\n"
|
|
1205
|
+
f"Cost: [bold]${float(s.get('estimated_cost_usd') or 0):.4f}[/bold]",
|
|
1206
|
+
title=f"[bold]{(s.get('title') or 'Session')[:60]}[/bold]",
|
|
1207
|
+
style="blue",
|
|
1208
|
+
))
|
|
1209
|
+
|
|
1210
|
+
if detail["tool_summary"]:
|
|
1211
|
+
table = Table(title="Tool Usage", show_lines=False)
|
|
1212
|
+
table.add_column("Tool", style="magenta")
|
|
1213
|
+
table.add_column("Uses", justify="right")
|
|
1214
|
+
for t in detail["tool_summary"]:
|
|
1215
|
+
table.add_row(t["tool_name"], str(t["uses"]))
|
|
1216
|
+
console.print(table)
|
|
1217
|
+
|
|
1218
|
+
if messages and detail["messages"]:
|
|
1219
|
+
table = Table(title="Messages", show_lines=True)
|
|
1220
|
+
table.add_column("Role", style="cyan", width=10)
|
|
1221
|
+
table.add_column("Timestamp", style="dim", width=18)
|
|
1222
|
+
table.add_column("Content")
|
|
1223
|
+
for m in detail["messages"]:
|
|
1224
|
+
content = (m.get("content") or "")[:300]
|
|
1225
|
+
if m.get("tool_calls"):
|
|
1226
|
+
tools = ", ".join(tc["name"] for tc in m["tool_calls"])
|
|
1227
|
+
content += f"\n[dim]Tools: {tools}[/dim]"
|
|
1228
|
+
table.add_row(
|
|
1229
|
+
m.get("role", ""),
|
|
1230
|
+
str(m.get("timestamp") or "")[:18],
|
|
1231
|
+
content,
|
|
1232
|
+
)
|
|
1233
|
+
console.print(table)
|
|
1234
|
+
elif not messages and detail["messages"]:
|
|
1235
|
+
console.print(f"[dim]{len(detail['messages'])} messages — pass --messages to show content[/dim]")
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
# ---------------------------------------------------------------------------
|
|
1239
|
+
# Stats providers subcommand
|
|
1240
|
+
# ---------------------------------------------------------------------------
|
|
1241
|
+
|
|
1242
|
+
@cli.command("providers")
|
|
1243
|
+
def stats_providers():
|
|
1244
|
+
"""Show usage breakdown by provider."""
|
|
1245
|
+
from spooling.stats import get_provider_breakdown
|
|
1246
|
+
|
|
1247
|
+
rows = get_provider_breakdown()
|
|
1248
|
+
if not rows:
|
|
1249
|
+
console.print("[yellow]No sessions synced yet. Run 'spooling sync' first.[/yellow]")
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
table = Table(title="Provider Breakdown", show_lines=False)
|
|
1253
|
+
table.add_column("Provider", style="cyan")
|
|
1254
|
+
table.add_column("Sessions", justify="right")
|
|
1255
|
+
table.add_column("Messages", justify="right")
|
|
1256
|
+
table.add_column("Tool Calls", justify="right")
|
|
1257
|
+
table.add_column("In Tokens", justify="right")
|
|
1258
|
+
table.add_column("Out Tokens", justify="right")
|
|
1259
|
+
table.add_column("Est. Cost", justify="right")
|
|
1260
|
+
table.add_column("Last Session", style="dim")
|
|
1261
|
+
|
|
1262
|
+
for r in rows:
|
|
1263
|
+
last = str(r.get("last_session") or "")[:10]
|
|
1264
|
+
table.add_row(
|
|
1265
|
+
r["provider_id"] or "unknown",
|
|
1266
|
+
str(r["sessions"]),
|
|
1267
|
+
str(int(r["messages"] or 0)),
|
|
1268
|
+
str(int(r["tool_calls"] or 0)),
|
|
1269
|
+
f"{int(r['input_tokens'] or 0):,}",
|
|
1270
|
+
f"{int(r['output_tokens'] or 0):,}",
|
|
1271
|
+
f"${float(r['cost'] or 0):.2f}",
|
|
1272
|
+
last,
|
|
1273
|
+
)
|
|
1274
|
+
console.print(table)
|
|
1275
|
+
|
|
1276
|
+
|
|
856
1277
|
# ---------------------------------------------------------------------------
|
|
857
1278
|
# Token management
|
|
858
1279
|
# ---------------------------------------------------------------------------
|
|
@@ -223,16 +223,17 @@ def _collect_traces(session_ids: list[str]) -> list[dict]:
|
|
|
223
223
|
return []
|
|
224
224
|
conn = get_connection()
|
|
225
225
|
try:
|
|
226
|
+
placeholders = ", ".join(["%s"] * len(session_ids))
|
|
226
227
|
traces = conn.execute(
|
|
227
|
-
"""SELECT id, session_id, provider_id, project, title,
|
|
228
|
+
f"""SELECT id, session_id, provider_id, project, title,
|
|
228
229
|
started_at, ended_at, duration_ms,
|
|
229
230
|
span_count, agent_count, tool_count, llm_count, error_count,
|
|
230
231
|
total_input_tokens, total_output_tokens,
|
|
231
232
|
total_cache_read_tokens, total_cache_write_tokens,
|
|
232
233
|
total_cost_usd, cwd, git_branch, model,
|
|
233
234
|
vendor_count, top_vendors, attrs
|
|
234
|
-
FROM traces WHERE session_id
|
|
235
|
-
(session_ids
|
|
235
|
+
FROM traces WHERE session_id IN ({placeholders})""",
|
|
236
|
+
tuple(session_ids),
|
|
236
237
|
).fetchall()
|
|
237
238
|
|
|
238
239
|
out: list[dict] = []
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"""Cloudflare tunnel support for exposing local MCP servers."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
3
5
|
import re
|
|
6
|
+
import stat
|
|
4
7
|
import subprocess
|
|
5
8
|
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
6
11
|
from typing import Optional, Tuple
|
|
7
12
|
|
|
8
13
|
from rich.console import Console
|
|
@@ -12,19 +17,85 @@ console = Console()
|
|
|
12
17
|
# Pattern to match Cloudflare quick tunnel URLs
|
|
13
18
|
TUNNEL_URL_PATTERN = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
14
19
|
|
|
20
|
+
# Where we cache the auto-downloaded binary
|
|
21
|
+
_BIN_DIR = Path.home() / ".spooling" / "bin"
|
|
22
|
+
_CLOUDFLARED_BIN = _BIN_DIR / ("cloudflared.exe" if platform.system() == "Windows" else "cloudflared")
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
# Download URL templates per platform/arch
|
|
25
|
+
_DOWNLOAD_URLS: dict[tuple[str, str], str] = {
|
|
26
|
+
("Darwin", "arm64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64",
|
|
27
|
+
("Darwin", "x86_64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64",
|
|
28
|
+
("Linux", "aarch64"):"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
|
|
29
|
+
("Linux", "x86_64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
30
|
+
("Windows", "AMD64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cloudflared_cmd() -> Optional[str]:
|
|
35
|
+
"""Return the path to a usable cloudflared binary, or None."""
|
|
36
|
+
# 1. Already on PATH
|
|
18
37
|
try:
|
|
19
|
-
result = subprocess.run(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
text=True,
|
|
23
|
-
timeout=5,
|
|
24
|
-
)
|
|
25
|
-
return result.returncode == 0
|
|
38
|
+
result = subprocess.run(["cloudflared", "version"], capture_output=True, timeout=5)
|
|
39
|
+
if result.returncode == 0:
|
|
40
|
+
return "cloudflared"
|
|
26
41
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
27
|
-
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# 2. Previously auto-downloaded
|
|
45
|
+
if _CLOUDFLARED_BIN.exists():
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run([str(_CLOUDFLARED_BIN), "version"], capture_output=True, timeout=5)
|
|
48
|
+
if result.returncode == 0:
|
|
49
|
+
return str(_CLOUDFLARED_BIN)
|
|
50
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _auto_install_cloudflared() -> Optional[str]:
|
|
57
|
+
"""Download the cloudflared binary for this platform into ~/.spooling/bin/.
|
|
58
|
+
|
|
59
|
+
Returns the path to the binary on success, None on failure.
|
|
60
|
+
"""
|
|
61
|
+
system = platform.system()
|
|
62
|
+
machine = platform.machine()
|
|
63
|
+
url = _DOWNLOAD_URLS.get((system, machine))
|
|
64
|
+
|
|
65
|
+
if not url:
|
|
66
|
+
console.print(f"[red]No cloudflared binary available for {system}/{machine}.[/red]")
|
|
67
|
+
console.print("Install manually: https://developers.cloudflare.com/cloudflared/install/")
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
console.print(f"[bold]Downloading cloudflared for {system}/{machine}...[/bold]")
|
|
73
|
+
try:
|
|
74
|
+
urllib.request.urlretrieve(url, _CLOUDFLARED_BIN)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
console.print(f"[red]Download failed:[/red] {e}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Make executable on Unix
|
|
80
|
+
if system != "Windows":
|
|
81
|
+
_CLOUDFLARED_BIN.chmod(_CLOUDFLARED_BIN.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
82
|
+
|
|
83
|
+
# Verify
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run([str(_CLOUDFLARED_BIN), "version"], capture_output=True, timeout=5)
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
console.print(f"[green]cloudflared installed to {_CLOUDFLARED_BIN}[/green]")
|
|
88
|
+
return str(_CLOUDFLARED_BIN)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
console.print("[red]Downloaded binary did not run correctly.[/red]")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_cloudflared() -> bool:
|
|
97
|
+
"""Check if cloudflared is available (on PATH or auto-downloaded)."""
|
|
98
|
+
return _cloudflared_cmd() is not None
|
|
28
99
|
|
|
29
100
|
|
|
30
101
|
def _validate_port(port: int) -> bool:
|
|
@@ -49,15 +120,13 @@ def start_tunnel(
|
|
|
49
120
|
console.print(f"[red]Invalid port: {port}. Must be 1-65535.[/red]")
|
|
50
121
|
return None
|
|
51
122
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
console.print(" Linux: [bold]curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared[/bold]")
|
|
57
|
-
console.print(" Windows: [bold]winget install cloudflare.cloudflared[/bold]")
|
|
123
|
+
cmd_bin = _cloudflared_cmd()
|
|
124
|
+
if not cmd_bin:
|
|
125
|
+
cmd_bin = _auto_install_cloudflared()
|
|
126
|
+
if not cmd_bin:
|
|
58
127
|
return None
|
|
59
128
|
|
|
60
|
-
cmd = [
|
|
129
|
+
cmd = [cmd_bin, "tunnel", "--no-autoupdate", "--url", f"http://localhost:{port}"]
|
|
61
130
|
|
|
62
131
|
console.print(f"[bold]Starting Cloudflare tunnel to localhost:{port}...[/bold]")
|
|
63
132
|
|
|
@@ -142,10 +211,13 @@ def start_tunnel_background(
|
|
|
142
211
|
if not _validate_port(port):
|
|
143
212
|
return None, None
|
|
144
213
|
|
|
145
|
-
|
|
214
|
+
cmd_bin = _cloudflared_cmd()
|
|
215
|
+
if not cmd_bin:
|
|
216
|
+
cmd_bin = _auto_install_cloudflared()
|
|
217
|
+
if not cmd_bin:
|
|
146
218
|
return None, None
|
|
147
219
|
|
|
148
|
-
cmd = [
|
|
220
|
+
cmd = [cmd_bin, "tunnel", "--no-autoupdate", "--url", f"http://localhost:{port}"]
|
|
149
221
|
|
|
150
222
|
process = subprocess.Popen(
|
|
151
223
|
cmd,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|