spooling 0.1.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.
spooling/cli.py ADDED
@@ -0,0 +1,522 @@
1
+ """Spooling CLI - track and search your AI coding assistant sessions."""
2
+
3
+ import re
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+
10
+ console = Console()
11
+
12
+
13
+ def _clean_project(name: str) -> str:
14
+ """Turn '-Users-username-path-to-project' into '~/path/to/project'."""
15
+ return re.sub(r"-Users-[^-]+-", "~/", name).replace("-", "/")
16
+
17
+
18
+ @click.group()
19
+ @click.version_option(package_name="spooling")
20
+ def cli():
21
+ """Spooling - local session tracker for AI coding assistants."""
22
+ pass
23
+
24
+
25
+ @cli.command()
26
+ def init():
27
+ """Check database connection and show provider status."""
28
+ from spooling.db import check_db
29
+ from spooling.config import DATABASE_URL
30
+ from spooling.providers import get_all_providers
31
+
32
+ console.print(Panel("[bold]Spooling[/bold] - Session Tracker", style="blue"))
33
+
34
+ # Check DB
35
+ if check_db():
36
+ console.print("[green]Database connected[/green]")
37
+ else:
38
+ console.print("[red]Cannot connect to database.[/red]")
39
+ console.print(f" URL: {DATABASE_URL}")
40
+ console.print(" Run: [bold]docker compose up -d[/bold]")
41
+ return
42
+
43
+ # Check all providers
44
+ providers = get_all_providers()
45
+ table = Table(show_lines=False, title="Providers")
46
+ table.add_column("Provider", style="cyan")
47
+ table.add_column("Status")
48
+ table.add_column("Path", style="dim")
49
+
50
+ for type_id, provider in providers.items():
51
+ available = provider.is_available()
52
+ status = "[green]available[/green]" if available else "[dim]not found[/dim]"
53
+ if provider.is_remote:
54
+ path_str = "[dim](remote API — connect via GUI)[/dim]"
55
+ else:
56
+ if available:
57
+ files = provider.discover_session_files()
58
+ status = f"[green]{len(files)} session files[/green]"
59
+ path_str = str(provider.resolved_data_path())
60
+ table.add_row(provider.name, status, path_str)
61
+
62
+ console.print(table)
63
+ console.print("\nRun [bold]spooling sync[/bold] to ingest sessions from all available providers.")
64
+
65
+
66
+ @cli.command()
67
+ @click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
68
+ @click.option("--provider", "-p", default=None, help="Only sync a specific provider (jsonl-session, codex, cursor, copilot, windsurf)")
69
+ def sync(no_embed, provider):
70
+ """Sync AI coding sessions to the database."""
71
+ from spooling.ingest import sync as do_sync
72
+ do_sync(embed=not no_embed, provider_filter=provider)
73
+
74
+
75
+ @cli.command()
76
+ def watch():
77
+ """Watch for new session data and auto-sync."""
78
+ from spooling.watcher import watch as do_watch
79
+ do_watch()
80
+
81
+
82
+ @cli.command()
83
+ @click.argument("query")
84
+ @click.option("-n", "--limit", default=10, help="Number of results")
85
+ @click.option("-p", "--project", default=None, help="Filter by project")
86
+ def search(query, limit, project):
87
+ """Semantic search across session history."""
88
+ from spooling.search import search as do_search
89
+
90
+ results = do_search(query, limit=limit, project=project)
91
+
92
+ if not results:
93
+ console.print("[yellow]No results found.[/yellow]")
94
+ return
95
+
96
+ for i, r in enumerate(results, 1):
97
+ similarity = f"{r['similarity']:.1%}"
98
+ project_name = r["project"] or "unknown"
99
+ role = r["role"]
100
+ ts = r["timestamp"] or ""
101
+
102
+ console.print(
103
+ f"\n[bold]{i}.[/bold] [{similarity}] "
104
+ f"[dim]{project_name}[/dim] "
105
+ f"[{'green' if role == 'user' else 'blue'}]{role}[/{'green' if role == 'user' else 'blue'}] "
106
+ f"[dim]{ts[:19]}[/dim]"
107
+ )
108
+ if r["title"]:
109
+ console.print(f" [dim]Session:[/dim] {r['title']}")
110
+ console.print(f" {r['content']}")
111
+
112
+
113
+ @cli.command()
114
+ @click.option("--week", is_flag=True, help="Show weekly breakdown")
115
+ @click.option("--days", default=7, help="Number of days for daily stats")
116
+ def stats(week, days):
117
+ """Show usage statistics."""
118
+ from spooling.stats import get_overview, get_daily_stats
119
+
120
+ overview = get_overview()
121
+ s = overview["summary"]
122
+
123
+ if not s or s.get("total_sessions", 0) == 0:
124
+ console.print("[yellow]No sessions synced yet. Run 'spooling sync' first.[/yellow]")
125
+ return
126
+
127
+ # Overview panel
128
+ total_tokens = s["total_input_tokens"] + s["total_output_tokens"]
129
+ console.print(Panel(
130
+ f"Sessions: [bold]{s['total_sessions']}[/bold] | "
131
+ f"Messages: [bold]{s['total_messages']}[/bold] | "
132
+ f"Tool calls: [bold]{s['total_tool_calls']}[/bold]\n"
133
+ f"Tokens: [bold]{total_tokens:,}[/bold] est. | "
134
+ f"Cost: [bold]${float(s['total_cost_usd']):.2f}[/bold] est.",
135
+ title="[bold]Spooling Overview[/bold]",
136
+ style="blue",
137
+ ))
138
+
139
+ # Projects table
140
+ if overview["projects"]:
141
+ table = Table(title="Projects", show_lines=False)
142
+ table.add_column("Project", style="cyan")
143
+ table.add_column("Sessions", justify="right")
144
+ table.add_column("Messages", justify="right")
145
+ table.add_column("Est. Cost", justify="right")
146
+ for p in overview["projects"][:10]:
147
+ proj = _clean_project(p["project"])
148
+ table.add_row(
149
+ proj,
150
+ str(p["sessions"]),
151
+ str(int(p["messages"] or 0)),
152
+ f"${float(p['cost'] or 0):.2f}",
153
+ )
154
+ console.print(table)
155
+
156
+ # Top tools
157
+ if overview["top_tools"]:
158
+ table = Table(title="Top Tools", show_lines=False)
159
+ table.add_column("Tool", style="magenta")
160
+ table.add_column("Uses", justify="right")
161
+ for t in overview["top_tools"][:10]:
162
+ table.add_row(t["tool_name"], str(t["uses"]))
163
+ console.print(table)
164
+
165
+ # Daily stats
166
+ if week or days:
167
+ daily = get_daily_stats(days=days if not week else 7)
168
+ if daily:
169
+ table = Table(title=f"Daily Usage (last {days if not week else 7} days)", show_lines=False)
170
+ table.add_column("Date")
171
+ table.add_column("Sessions", justify="right")
172
+ table.add_column("Messages", justify="right")
173
+ table.add_column("Tool Calls", justify="right")
174
+ table.add_column("Tokens", justify="right")
175
+ table.add_column("Cost", justify="right")
176
+ for d in daily:
177
+ table.add_row(
178
+ str(d["day"]),
179
+ str(d["sessions"]),
180
+ str(int(d["messages"])),
181
+ str(int(d["tool_calls"])),
182
+ f"{int(d['total_tokens']):,}",
183
+ f"${float(d['cost']):.2f}",
184
+ )
185
+ console.print(table)
186
+
187
+ # Recent sessions
188
+ if overview["recent_sessions"]:
189
+ table = Table(title="Recent Sessions", show_lines=False)
190
+ table.add_column("Started", style="dim")
191
+ table.add_column("Project", style="cyan")
192
+ table.add_column("Title")
193
+ table.add_column("Msgs", justify="right")
194
+ table.add_column("Cost", justify="right")
195
+ for r in overview["recent_sessions"]:
196
+ proj = _clean_project(r["project"] or "")
197
+ ts = r["started_at"].strftime("%m/%d %H:%M") if r["started_at"] else ""
198
+ title = (r["title"] or "")[:50]
199
+ table.add_row(
200
+ ts, proj, title,
201
+ str(r["message_count"]),
202
+ f"${float(r['estimated_cost_usd'] or 0):.2f}",
203
+ )
204
+ console.print(table)
205
+
206
+
207
+ @cli.group()
208
+ def eval():
209
+ """Run eval rubrics over traces/spans."""
210
+ pass
211
+
212
+
213
+ @eval.command("list")
214
+ def eval_list():
215
+ """List all eval rubrics."""
216
+ from spooling.db import get_connection
217
+ conn = get_connection()
218
+ rows = conn.execute(
219
+ "SELECT id, name, kind, target_kind, description FROM eval_rubrics ORDER BY id"
220
+ ).fetchall()
221
+ conn.close()
222
+ table = Table(title="Eval Rubrics")
223
+ table.add_column("ID", style="cyan")
224
+ table.add_column("Name")
225
+ table.add_column("Kind")
226
+ table.add_column("Target")
227
+ table.add_column("Description", style="dim")
228
+ for r in rows:
229
+ table.add_row(r["id"], r["name"], r["kind"], r["target_kind"], r["description"] or "")
230
+ console.print(table)
231
+
232
+
233
+ @eval.command("run")
234
+ @click.option("--rubric", required=True, help="Rubric id")
235
+ @click.option("--trace", default=None, help="Run against a single trace id")
236
+ @click.option("--days", default=None, type=int, help="Run against all traces from the last N days")
237
+ def eval_run(rubric, trace, days):
238
+ """Run a rubric against one trace or a batch."""
239
+ from spooling.evals import run_rubric, run_rubric_bulk
240
+ from datetime import datetime, timezone, timedelta
241
+
242
+ if trace:
243
+ result = run_rubric(rubric, trace)
244
+ if result is None:
245
+ console.print(f"[yellow]No eval recorded for {trace}[/yellow]")
246
+ else:
247
+ console.print(f"[green]Eval {result} recorded for {trace}[/green]")
248
+ return
249
+
250
+ since = None
251
+ if days:
252
+ since = datetime.now(timezone.utc) - timedelta(days=days)
253
+ result = run_rubric_bulk(rubric, since=since)
254
+ console.print(result)
255
+
256
+
257
+ @cli.command()
258
+ @click.option("--host", default=None, help="Host to bind to")
259
+ @click.option("--port", default=None, type=int, help="Port to bind to")
260
+ def serve(host, port):
261
+ """Start the API server."""
262
+ from spooling.config import UI_HOST
263
+ from spooling.server import app
264
+ import uvicorn
265
+
266
+ h = host or UI_HOST
267
+ p = port or 3002
268
+ console.print(f"[bold]Spooling API[/bold] at http://{h}:{p}")
269
+ console.print("Start the UI with: [bold]cd ui && npm run dev[/bold]")
270
+ uvicorn.run(app, host=h, port=p, log_level="warning")
271
+
272
+
273
+ @cli.group()
274
+ def otel():
275
+ """Ingest OTel/Strands spans from external sources into Spooling."""
276
+ pass
277
+
278
+
279
+ @otel.command("ingest")
280
+ @click.option("--file", "path", required=True, type=click.Path(exists=True), help="OTLP JSON export file")
281
+ @click.option("--provider", "provider_id", default="otel-remote", help="Provider id to tag the trace with")
282
+ @click.option("--project", default=None, help="Project name")
283
+ def otel_ingest(path, provider_id, project):
284
+ """Ingest an OTLP/JSON spans file as a Spooling trace."""
285
+ from spooling.remote_otel import ingest_otlp_json_file
286
+ try:
287
+ trace_id = ingest_otlp_json_file(path, provider_id=provider_id, project=project)
288
+ console.print(f"[green]Ingested:[/green] {trace_id}")
289
+ except Exception as e:
290
+ console.print(f"[red]Ingest failed:[/red] {e}")
291
+ raise SystemExit(1)
292
+
293
+
294
+ @cli.group()
295
+ def experiment():
296
+ """Create and run Strands experiments (cases + evaluators)."""
297
+ pass
298
+
299
+
300
+ @experiment.command("create")
301
+ @click.option("--file", "path", required=True, type=click.Path(exists=True), help="Path to a JSON spec")
302
+ def experiment_create(path):
303
+ """Register an experiment from a JSON file."""
304
+ from spooling.experiments import load_spec_from_file, create_experiment
305
+ spec = load_spec_from_file(path)
306
+ eid = create_experiment(spec)
307
+ console.print(f"[green]Experiment created: {eid}[/green] ({spec.name})")
308
+
309
+
310
+ @experiment.command("list")
311
+ def experiment_list():
312
+ """List experiments."""
313
+ from spooling.experiments import list_experiments
314
+ rows = list_experiments()
315
+ if not rows:
316
+ console.print("[yellow]No experiments yet. Create one with 'spooling experiment create --file ...'[/yellow]")
317
+ return
318
+ table = Table(title="Experiments")
319
+ table.add_column("ID", style="cyan")
320
+ table.add_column("Name")
321
+ table.add_column("Cases", justify="right")
322
+ table.add_column("Evaluators", justify="right")
323
+ table.add_column("Created", style="dim")
324
+ for r in rows:
325
+ table.add_row(
326
+ r["id"], r["name"],
327
+ str(r["case_count"]), str(r["evaluator_count"]),
328
+ str(r["created_at"])[:19],
329
+ )
330
+ console.print(table)
331
+
332
+
333
+ @experiment.command("run")
334
+ @click.option("--id", "experiment_id", required=True, help="Experiment id")
335
+ def experiment_run(experiment_id):
336
+ """Run an experiment and persist the report."""
337
+ from spooling.experiments import run_experiment, load_run
338
+ console.print(f"[bold]Running experiment {experiment_id}...[/bold]")
339
+ try:
340
+ run_id = run_experiment(experiment_id)
341
+ except Exception as e:
342
+ console.print(f"[red]Run failed:[/red] {e}")
343
+ raise SystemExit(1)
344
+ run = load_run(run_id)
345
+ console.print(f"[green]Run complete: {run_id}[/green]")
346
+ if run and run.get("overall_scores"):
347
+ console.print("Scores:")
348
+ for name, score in run["overall_scores"].items():
349
+ console.print(f" - {name}: {score}")
350
+
351
+
352
+ @experiment.command("show")
353
+ @click.option("--run", "run_id", required=True, help="Run id")
354
+ def experiment_show(run_id):
355
+ """Show the report for a past run."""
356
+ from spooling.experiments import load_run
357
+ run = load_run(run_id)
358
+ if not run:
359
+ console.print(f"[red]Run not found: {run_id}[/red]")
360
+ return
361
+ console.print(f"[bold]Run {run_id}[/bold] ({run['status']})")
362
+ console.print(f"Experiment: {run['experiment_id']}")
363
+ console.print(f"Started: {run['started_at']}")
364
+ console.print(f"Finished: {run['finished_at']}")
365
+ if run.get("error"):
366
+ console.print(f"[red]Error:[/red] {run['error']}")
367
+ if run.get("overall_scores"):
368
+ table = Table(title="Overall scores")
369
+ table.add_column("Evaluator", style="cyan")
370
+ table.add_column("Score", justify="right")
371
+ for k, v in run["overall_scores"].items():
372
+ table.add_row(k, f"{v:.3f}" if isinstance(v, (int, float)) else str(v))
373
+ console.print(table)
374
+
375
+
376
+ @cli.group()
377
+ def pricing():
378
+ """Manage the LiteLLM-backed model pricing table."""
379
+ pass
380
+
381
+
382
+ @pricing.command("refresh")
383
+ def pricing_refresh():
384
+ """Force-fetch the LiteLLM model pricing table into ~/.spool/model_prices.json."""
385
+ from spool import pricing as _pricing
386
+ try:
387
+ data = _pricing.refresh()
388
+ console.print(f"[green]Pricing refreshed:[/green] {len(data)} models cached at {_pricing.CACHE_FILE}")
389
+ except Exception as e:
390
+ console.print(f"[red]Pricing refresh failed:[/red] {e}")
391
+ raise SystemExit(1)
392
+
393
+
394
+ @pricing.command("show")
395
+ @click.argument("model", required=False)
396
+ def pricing_show(model):
397
+ """Show the cached pricing for one model, or the source status if no model given."""
398
+ from spool import pricing as _pricing
399
+
400
+ if not model:
401
+ status = _pricing.table_status()
402
+ table = Table(title="Pricing source")
403
+ table.add_column("Key", style="cyan")
404
+ table.add_column("Value")
405
+ for k, v in status.items():
406
+ table.add_row(k, str(v))
407
+ console.print(table)
408
+ return
409
+
410
+ rates = _pricing.get_rates(model)
411
+ table = Table(title=f"Rates for {model}")
412
+ table.add_column("Component", style="cyan")
413
+ table.add_column("$/Mtok", justify="right")
414
+ for label, rate in [
415
+ ("Input", rates.input),
416
+ ("Output", rates.output),
417
+ ("Cache write", rates.cache_write),
418
+ ("Cache read", rates.cache_read),
419
+ ]:
420
+ table.add_row(label, f"${rate * 1_000_000:.2f}")
421
+ console.print(table)
422
+
423
+
424
+ @cli.command()
425
+ @click.option("--stdio", is_flag=True, help="Use stdio transport (default is streamable-HTTP)")
426
+ def mcp(stdio):
427
+ """Launch the Spooling MCP server.
428
+
429
+ Defaults to streamable-HTTP at http://127.0.0.1:3004/mcp so any
430
+ MCP-compatible agent (Codex, Cursor, web agents) can
431
+ connect by URL. Pass --stdio for stdio-only clients.
432
+ """
433
+ if stdio:
434
+ from spooling.mcp_server import serve_stdio
435
+ console.print("[bold]Spooling MCP[/bold] over stdio")
436
+ serve_stdio()
437
+ else:
438
+ from spooling.mcp_server import serve_http, MCP_URL
439
+ console.print(f"[bold]Spooling MCP[/bold] at {MCP_URL}")
440
+ serve_http()
441
+
442
+
443
+ def _check_ollama_preflight() -> None:
444
+ """Ping Ollama at the configured URL and warn if it's down.
445
+
446
+ Non-blocking: Spooling still starts because the user may have switched
447
+ the chat agent to Anthropic, and LLM-judge evals are opt-in. The
448
+ warning makes it obvious why evals/chat would fail with "All
449
+ connection attempts failed" otherwise.
450
+ """
451
+ import urllib.request
452
+ import urllib.error
453
+
454
+ ollama_url = "http://localhost:11434"
455
+ try:
456
+ from spooling.db import get_connection
457
+ conn = get_connection()
458
+ try:
459
+ row = conn.execute(
460
+ "SELECT config FROM providers WHERE id = 'spooling-agent'"
461
+ ).fetchone()
462
+ finally:
463
+ conn.close()
464
+ cfg = (row["config"] if row and isinstance(row.get("config"), dict) else {}) if row else {}
465
+ ollama_url = cfg.get("ollama_url") or ollama_url
466
+ except Exception:
467
+ pass
468
+
469
+ try:
470
+ urllib.request.urlopen(f"{ollama_url}/api/tags", timeout=1.5).read()
471
+ except (urllib.error.URLError, TimeoutError, OSError):
472
+ console.print(
473
+ f"[yellow] ! Ollama is not reachable at {ollama_url}.[/yellow]"
474
+ )
475
+ console.print(
476
+ "[dim] Chat and LLM-judge evals will fail until you run:[/dim] "
477
+ "[bold]ollama serve[/bold]"
478
+ )
479
+
480
+
481
+ @cli.command()
482
+ def ui():
483
+ """Launch the API server, MCP HTTP server, and Next.js UI together."""
484
+ import subprocess
485
+ import os
486
+ import sys
487
+
488
+ ui_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ui")
489
+
490
+ console.print("[bold]Starting Spooling...[/bold]")
491
+ console.print(" API: http://127.0.0.1:3002")
492
+ console.print(" MCP: http://127.0.0.1:3004/mcp")
493
+ console.print(" UI: http://localhost:3003")
494
+
495
+ # Preflight: warn if Ollama is down — otherwise chat + judge will fail
496
+ # silently with "All connection attempts failed" from httpx.
497
+ _check_ollama_preflight()
498
+
499
+ api_proc = subprocess.Popen(
500
+ [sys.executable, "-m", "uvicorn", "spool.server:app", "--host", "127.0.0.1", "--port", "3002", "--log-level", "warning"],
501
+ )
502
+
503
+ mcp_proc = subprocess.Popen(
504
+ [sys.executable, "-m", "spool.mcp_server"],
505
+ )
506
+
507
+ try:
508
+ subprocess.run(["npm", "run", "dev"], cwd=ui_dir)
509
+ except KeyboardInterrupt:
510
+ pass
511
+ finally:
512
+ api_proc.terminate()
513
+ mcp_proc.terminate()
514
+
515
+
516
+ from spooling.cloud import cloud as _cloud_group, push as _push_cmd
517
+ cli.add_command(_cloud_group)
518
+ cli.add_command(_push_cmd)
519
+
520
+
521
+ if __name__ == "__main__":
522
+ cli()