spooling 0.2.9__tar.gz → 0.2.11__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.
Files changed (75) hide show
  1. {spooling-0.2.9/spooling.egg-info → spooling-0.2.11}/PKG-INFO +24 -1
  2. {spooling-0.2.9 → spooling-0.2.11}/README.md +23 -0
  3. {spooling-0.2.9 → spooling-0.2.11}/pyproject.toml +1 -1
  4. {spooling-0.2.9 → spooling-0.2.11}/spooling/cli.py +201 -0
  5. {spooling-0.2.9 → spooling-0.2.11}/spooling/cloud.py +189 -24
  6. spooling-0.2.11/spooling/insights.py +253 -0
  7. {spooling-0.2.9 → spooling-0.2.11/spooling.egg-info}/PKG-INFO +24 -1
  8. {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/SOURCES.txt +3 -0
  9. spooling-0.2.11/tests/test_cloud_errors.py +219 -0
  10. spooling-0.2.11/tests/test_insights.py +218 -0
  11. {spooling-0.2.9 → spooling-0.2.11}/LICENSE +0 -0
  12. {spooling-0.2.9 → spooling-0.2.11}/setup.cfg +0 -0
  13. {spooling-0.2.9 → spooling-0.2.11}/spooling/__init__.py +0 -0
  14. {spooling-0.2.9 → spooling-0.2.11}/spooling/agent.py +0 -0
  15. {spooling-0.2.9 → spooling-0.2.11}/spooling/classifiers.py +0 -0
  16. {spooling-0.2.9 → spooling-0.2.11}/spooling/config.py +0 -0
  17. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/__init__.py +0 -0
  18. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/base.py +0 -0
  19. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/bigquery.py +0 -0
  20. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/clickhouse.py +0 -0
  21. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/factory.py +0 -0
  22. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/mongodb.py +0 -0
  23. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/mysql.py +0 -0
  24. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/postgresql.py +0 -0
  25. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/__init__.py +0 -0
  26. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/base.py +0 -0
  27. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/rest/shopify_connectors.py +0 -0
  28. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/snowflake.py +0 -0
  29. {spooling-0.2.9 → spooling-0.2.11}/spooling/connectors/types.py +0 -0
  30. {spooling-0.2.9 → spooling-0.2.11}/spooling/db.py +0 -0
  31. {spooling-0.2.9 → spooling-0.2.11}/spooling/embeddings.py +0 -0
  32. {spooling-0.2.9 → spooling-0.2.11}/spooling/evals.py +0 -0
  33. {spooling-0.2.9 → spooling-0.2.11}/spooling/experiments.py +0 -0
  34. {spooling-0.2.9 → spooling-0.2.11}/spooling/ingest.py +0 -0
  35. {spooling-0.2.9 → spooling-0.2.11}/spooling/mcp_server.py +0 -0
  36. {spooling-0.2.9 → spooling-0.2.11}/spooling/optimize.py +0 -0
  37. {spooling-0.2.9 → spooling-0.2.11}/spooling/parser.py +0 -0
  38. {spooling-0.2.9 → spooling-0.2.11}/spooling/pricing.py +0 -0
  39. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/__init__.py +0 -0
  40. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/antigravity.py +0 -0
  41. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/base.py +0 -0
  42. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/claude_code.py +0 -0
  43. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/codex.py +0 -0
  44. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/copilot.py +0 -0
  45. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/cortex_code.py +0 -0
  46. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/cursor.py +0 -0
  47. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/gemini.py +0 -0
  48. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/github.py +0 -0
  49. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/gitlab.py +0 -0
  50. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/kiro.py +0 -0
  51. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/opencode.py +0 -0
  52. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/session_file.py +0 -0
  53. {spooling-0.2.9 → spooling-0.2.11}/spooling/providers/windsurf.py +0 -0
  54. {spooling-0.2.9 → spooling-0.2.11}/spooling/redact.py +0 -0
  55. {spooling-0.2.9 → spooling-0.2.11}/spooling/remote_otel.py +0 -0
  56. {spooling-0.2.9 → spooling-0.2.11}/spooling/schema.py +0 -0
  57. {spooling-0.2.9 → spooling-0.2.11}/spooling/sdk.py +0 -0
  58. {spooling-0.2.9 → spooling-0.2.11}/spooling/search.py +0 -0
  59. {spooling-0.2.9 → spooling-0.2.11}/spooling/server.py +0 -0
  60. {spooling-0.2.9 → spooling-0.2.11}/spooling/sessions.py +0 -0
  61. {spooling-0.2.9 → spooling-0.2.11}/spooling/stats.py +0 -0
  62. {spooling-0.2.9 → spooling-0.2.11}/spooling/subscription_pricing.py +0 -0
  63. {spooling-0.2.9 → spooling-0.2.11}/spooling/tracing.py +0 -0
  64. {spooling-0.2.9 → spooling-0.2.11}/spooling/tunnel.py +0 -0
  65. {spooling-0.2.9 → spooling-0.2.11}/spooling/tunnel_auth.py +0 -0
  66. {spooling-0.2.9 → spooling-0.2.11}/spooling/watcher.py +0 -0
  67. {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/dependency_links.txt +0 -0
  68. {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/entry_points.txt +0 -0
  69. {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/requires.txt +0 -0
  70. {spooling-0.2.9 → spooling-0.2.11}/spooling.egg-info/top_level.txt +0 -0
  71. {spooling-0.2.9 → spooling-0.2.11}/tests/test_claude_code_provider.py +0 -0
  72. {spooling-0.2.9 → spooling-0.2.11}/tests/test_mcp_auth_middleware.py +0 -0
  73. {spooling-0.2.9 → spooling-0.2.11}/tests/test_session_ids.py +0 -0
  74. {spooling-0.2.9 → spooling-0.2.11}/tests/test_storage_optimizations.py +0 -0
  75. {spooling-0.2.9 → spooling-0.2.11}/tests/test_tunnel_auth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -155,6 +155,29 @@ spooling stats --week # Weekly breakdown
155
155
  spooling stats --days 30 # Last 30 days
156
156
  ```
157
157
 
158
+ ### `spooling tools`, `spooling agents`, `spooling traces`
159
+
160
+ See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
161
+
162
+ ```bash
163
+ spooling tools # Top tools: uses, traces, errors, avg time
164
+ spooling tools --agent explore # Tools called by the "explore" agent
165
+ spooling tools --days 7 --provider claude-code
166
+
167
+ spooling agents # Agents: runs, traces, tokens, cost
168
+ spooling agents --tool Bash # Agents whose runs called Bash
169
+
170
+ spooling traces # Latest traces, with the agents each ran
171
+ spooling traces ses_10dd # Search title, project, session id, trace id or agent name
172
+ spooling traces --agent explore --tool Bash --days 30
173
+ ```
174
+
175
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
176
+
177
+ `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
178
+
179
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
180
+
158
181
  ### `spooling search <query>`
159
182
 
160
183
  Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
@@ -119,6 +119,29 @@ spooling stats --week # Weekly breakdown
119
119
  spooling stats --days 30 # Last 30 days
120
120
  ```
121
121
 
122
+ ### `spooling tools`, `spooling agents`, `spooling traces`
123
+
124
+ See which tools and agents your sessions use. These read the spans stored by `spooling sync`, and use the same agent/tool names as the web app (an agent is its subagent type, falling back to its span name).
125
+
126
+ ```bash
127
+ spooling tools # Top tools: uses, traces, errors, avg time
128
+ spooling tools --agent explore # Tools called by the "explore" agent
129
+ spooling tools --days 7 --provider claude-code
130
+
131
+ spooling agents # Agents: runs, traces, tokens, cost
132
+ spooling agents --tool Bash # Agents whose runs called Bash
133
+
134
+ spooling traces # Latest traces, with the agents each ran
135
+ spooling traces ses_10dd # Search title, project, session id, trace id or agent name
136
+ spooling traces --agent explore --tool Bash --days 30
137
+ ```
138
+
139
+ Common options: `--days N`, `--project TEXT`, `--provider/-p NAME`, `--limit/-n N`, and `--json` for scripting. `--agent` and `--tool` can be repeated (matches any).
140
+
141
+ `tools --agent` and `agents --tool` look inside an agent's own runs. `traces --agent/--tool` matches whole traces, like the Traces page.
142
+
143
+ `spooling tools --cloud` and `spooling agents --cloud` show workspace-wide totals from Spooling Cloud. The cloud endpoints only support `--limit`, so combining `--cloud` with other filters is an error. Agent data is only recorded for providers that expose subagents (for example Claude Code's `Task` tool), so `spooling agents` can be empty for other providers.
144
+
122
145
  ### `spooling search <query>`
123
146
 
124
147
  Semantic search across all your session history using natural language. Each result shows the session title **and its session id**, so you can open it with `spooling session show`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "Local session tracker and semantic search for AI coding assistants"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Parsed Analytics, Inc." }]
@@ -1339,6 +1339,207 @@ def stats_providers():
1339
1339
  console.print(table)
1340
1340
 
1341
1341
 
1342
+ # ---------------------------------------------------------------------------
1343
+ # Tools / agents / traces insights
1344
+ # ---------------------------------------------------------------------------
1345
+
1346
+ def _fmt_ms(ms) -> str:
1347
+ if ms is None:
1348
+ return "-"
1349
+ if ms < 1000:
1350
+ return f"{ms:.0f}ms"
1351
+ if ms < 60_000:
1352
+ return f"{ms / 1000:.1f}s"
1353
+ return f"{ms / 60_000:.1f}m"
1354
+
1355
+
1356
+ def _fmt_num(n) -> str:
1357
+ n = int(n or 0)
1358
+ if n >= 1_000_000:
1359
+ return f"{n / 1_000_000:.1f}M"
1360
+ if n >= 10_000:
1361
+ return f"{n / 1000:.1f}K"
1362
+ return f"{n:,}"
1363
+
1364
+
1365
+ def _emit_json(data) -> None:
1366
+ import json
1367
+ click.echo(json.dumps(data, indent=2, default=str))
1368
+
1369
+
1370
+ def _reject_cloud_filters(**filters) -> None:
1371
+ used = [f"--{k}" for k, v in filters.items() if v]
1372
+ if used:
1373
+ raise click.UsageError(
1374
+ f"{', '.join(used)} can't be combined with --cloud "
1375
+ "(the cloud endpoint only supports --limit). Drop --cloud to filter local data."
1376
+ )
1377
+
1378
+
1379
+ _FILTER_OPTS = [
1380
+ click.option("--days", type=int, default=None, help="Only the last N days"),
1381
+ click.option("--project", default=None, help="Project name contains this text"),
1382
+ click.option("--provider", "-p", default=None, help="Only this provider (claude-code, opencode, ...)"),
1383
+ click.option("--limit", "-n", type=int, default=20, show_default=True, help="Max rows"),
1384
+ click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table"),
1385
+ ]
1386
+
1387
+
1388
+ def _filter_opts(fn):
1389
+ for opt in reversed(_FILTER_OPTS):
1390
+ fn = opt(fn)
1391
+ return fn
1392
+
1393
+
1394
+ @cli.command("tools")
1395
+ @click.option("--agent", "agents", multiple=True, help="Only tools called by this agent (repeatable)")
1396
+ @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1397
+ @_filter_opts
1398
+ def tools_cmd(agents, cloud_mode, days, project, provider, limit, as_json):
1399
+ """Show which tools are used, how often, and how often they fail.
1400
+
1401
+ \b
1402
+ spooling tools top tools across all traces
1403
+ spooling tools --agent explore tools called by the 'explore' agent
1404
+ spooling tools --days 7 --json
1405
+ spooling tools --cloud workspace-wide, from Spooling Cloud
1406
+ """
1407
+ if cloud_mode:
1408
+ _reject_cloud_filters(agent=agents, days=days, project=project, provider=provider)
1409
+ from spooling.cloud import cloud_tools
1410
+ rows = cloud_tools(limit)
1411
+ if as_json:
1412
+ return _emit_json(rows)
1413
+ table = Table(title="Tools (cloud)", show_lines=False)
1414
+ table.add_column("Tool", style="cyan")
1415
+ table.add_column("Uses", justify="right")
1416
+ table.add_column("Sessions", justify="right")
1417
+ table.add_column("Errors", justify="right")
1418
+ for r in rows:
1419
+ table.add_row(r["tool_name"], _fmt_num(r["uses"]), _fmt_num(r["sessions"]),
1420
+ f"[red]{r['errors']}[/red]" if r["errors"] else "0")
1421
+ console.print(table if rows else "[yellow]No tool data in the cloud workspace yet.[/yellow]")
1422
+ return
1423
+
1424
+ from spooling.insights import tool_stats
1425
+ rows = tool_stats(days=days, project=project, provider=provider, agents=list(agents) or None, limit=limit)
1426
+ if as_json:
1427
+ return _emit_json(rows)
1428
+ if not rows:
1429
+ console.print("[yellow]No tool data found. Run 'spooling sync' first"
1430
+ + (" (agent filters need traces that contain agent spans)." if agents else ".") + "[/yellow]")
1431
+ return
1432
+ table = Table(title="Tools" + (f" called by {', '.join(agents)}" if agents else ""), show_lines=False)
1433
+ table.add_column("Tool", style="cyan")
1434
+ table.add_column("Uses", justify="right")
1435
+ table.add_column("Traces", justify="right")
1436
+ table.add_column("Errors", justify="right")
1437
+ table.add_column("Avg time", justify="right", style="dim")
1438
+ for r in rows:
1439
+ table.add_row(r["name"], _fmt_num(r["uses"]), _fmt_num(r["traces"]),
1440
+ f"[red]{r['errors']}[/red]" if r["errors"] else "0", _fmt_ms(r["avg_ms"]))
1441
+ console.print(table)
1442
+
1443
+
1444
+ @cli.command("agents")
1445
+ @click.option("--tool", "tools", multiple=True, help="Only agents that called this tool (repeatable)")
1446
+ @click.option("--cloud", "cloud_mode", is_flag=True, help="Use the cloud workspace instead of local data")
1447
+ @_filter_opts
1448
+ def agents_cmd(tools, cloud_mode, days, project, provider, limit, as_json):
1449
+ """Show which agents (subagent types) ran, how often, and what they cost.
1450
+
1451
+ \b
1452
+ spooling agents agents across all traces
1453
+ spooling agents --tool Bash agents whose runs called Bash
1454
+ spooling agents --days 30 --json
1455
+ spooling agents --cloud workspace-wide, from Spooling Cloud
1456
+ """
1457
+ if cloud_mode:
1458
+ _reject_cloud_filters(tool=tools, days=days, project=project, provider=provider)
1459
+ from spooling.cloud import cloud_agents
1460
+ rows = cloud_agents()[:limit]
1461
+ if as_json:
1462
+ return _emit_json(rows)
1463
+ table = Table(title="Agents (cloud)", show_lines=False)
1464
+ table.add_column("Agent", style="cyan")
1465
+ table.add_column("Runs", justify="right")
1466
+ for r in rows:
1467
+ table.add_row(r["agent_type"], _fmt_num(r["uses"]))
1468
+ console.print(table if rows else "[yellow]No agent data in the cloud workspace yet.[/yellow]")
1469
+ return
1470
+
1471
+ from spooling.insights import agent_stats
1472
+ rows = agent_stats(days=days, project=project, provider=provider, tools=list(tools) or None, limit=limit)
1473
+ if as_json:
1474
+ return _emit_json(rows)
1475
+ if not rows:
1476
+ console.print(
1477
+ "[yellow]No agent spans found locally.[/yellow]\n"
1478
+ "[dim]Agent runs are recorded for providers that expose subagents (e.g. Claude Code's Task tool). "
1479
+ "Run 'spooling sync' after using one, or try 'spooling agents --cloud'.[/dim]"
1480
+ )
1481
+ return
1482
+ table = Table(title="Agents" + (f" that called {', '.join(tools)}" if tools else ""), show_lines=False)
1483
+ table.add_column("Agent", style="cyan")
1484
+ table.add_column("Runs", justify="right")
1485
+ table.add_column("Traces", justify="right")
1486
+ table.add_column("Tokens", justify="right")
1487
+ table.add_column("Est. Cost", justify="right")
1488
+ table.add_column("Avg time", justify="right", style="dim")
1489
+ for r in rows:
1490
+ table.add_row(r["name"], _fmt_num(r["runs"]), _fmt_num(r["traces"]), _fmt_num(r["tokens"]),
1491
+ f"${r['cost']:.2f}", _fmt_ms(r["avg_ms"]))
1492
+ console.print(table)
1493
+
1494
+
1495
+ @cli.command("traces")
1496
+ @click.argument("query", required=False)
1497
+ @click.option("--agent", "agents", multiple=True, help="Only traces that ran this agent (repeatable)")
1498
+ @click.option("--tool", "tools", multiple=True, help="Only traces that used this tool (repeatable)")
1499
+ @click.option("--session", default=None, help="Session id (or unique prefix)")
1500
+ @_filter_opts
1501
+ def traces_cmd(query, agents, tools, session, days, project, provider, limit, as_json):
1502
+ """List traces, optionally filtered by agent, tool, or free text.
1503
+
1504
+ QUERY matches title, project, session id, trace id or an agent name.
1505
+
1506
+ \b
1507
+ spooling traces latest traces
1508
+ spooling traces ses_10dd find by session id
1509
+ spooling traces --agent explore traces that ran the explore agent
1510
+ spooling traces --tool Bash --days 7 traces that used Bash this week
1511
+ """
1512
+ from spooling.insights import list_traces
1513
+ rows = list_traces(query=query, agents=list(agents) or None, tools=list(tools) or None,
1514
+ session=session, days=days, project=project, provider=provider, limit=limit)
1515
+ if as_json:
1516
+ return _emit_json(rows)
1517
+ if not rows:
1518
+ console.print("[yellow]No traces match. Traces are created by 'spooling sync'.[/yellow]")
1519
+ return
1520
+ table = Table(title=f"Traces ({len(rows)})", show_lines=False, pad_edge=False)
1521
+ table.add_column("Date", style="dim", no_wrap=True)
1522
+ table.add_column("Title", style="cyan", max_width=22, overflow="ellipsis", no_wrap=True)
1523
+ table.add_column("Session", style="dim", no_wrap=True)
1524
+ table.add_column("Agents", max_width=12, overflow="ellipsis", no_wrap=True)
1525
+ table.add_column("Spans", justify="right", no_wrap=True)
1526
+ table.add_column("Tools", justify="right", no_wrap=True)
1527
+ table.add_column("Cost", justify="right", no_wrap=True)
1528
+ for r in rows:
1529
+ ag = r["agents"]
1530
+ ag_txt = ", ".join(ag[:1]) + (f" +{len(ag) - 1}" if len(ag) > 1 else "") if ag else "-"
1531
+ table.add_row(
1532
+ str(r["started_at"] or "")[5:10],
1533
+ r["title"] or "(untitled)",
1534
+ (r["session_id"] or "")[:10],
1535
+ ag_txt,
1536
+ _fmt_num(r["span_count"]),
1537
+ _fmt_num(r["tool_count"]),
1538
+ f"${float(r['total_cost_usd'] or 0):.2f}",
1539
+ )
1540
+ console.print(table)
1541
+
1542
+
1342
1543
  # ---------------------------------------------------------------------------
1343
1544
  # Token management
1344
1545
  # ---------------------------------------------------------------------------
@@ -4,13 +4,17 @@ import json
4
4
  import os
5
5
  import re
6
6
  import signal
7
+ import socket
8
+ import ssl
7
9
  import time
8
10
  from datetime import datetime, timedelta, timezone
9
11
  from pathlib import Path
12
+ from urllib.parse import urlparse
10
13
 
11
14
  import click
12
15
  import httpx
13
16
  from rich.console import Console
17
+ from rich.markup import escape
14
18
 
15
19
  from spooling.db import get_connection
16
20
  from spooling.redact import redact_messages, redact_traces
@@ -70,6 +74,143 @@ def _api_base() -> str:
70
74
  return cfg.get("api_url") or os.environ.get("SPOOLING_CLOUD_URL") or DEFAULT_API
71
75
 
72
76
 
77
+ # --- Friendly network errors -------------------------------------------------
78
+ #
79
+ # Raw exceptions like "[Errno 8] nodename nor servname provided, or not known"
80
+ # tell a user nothing. Everything that talks to the cloud funnels its errors
81
+ # through _explain_error / _explain_http so they get a plain-English cause and
82
+ # the next step.
83
+
84
+ _SELF_HOST_HINT = (
85
+ "Using a self-hosted or staging server? Point the CLI at it with\n"
86
+ " spooling cloud login --key sk_... --api-url https://your-host\n"
87
+ " or set SPOOLING_CLOUD_URL."
88
+ )
89
+
90
+
91
+ def _host_port(base: str) -> tuple[str, int]:
92
+ try:
93
+ u = urlparse(base)
94
+ host = u.hostname or base
95
+ port = u.port or (80 if u.scheme == "http" else 443)
96
+ except ValueError: # e.g. port out of range in a hand-typed URL
97
+ return base, 443
98
+ return host, port
99
+
100
+
101
+ def _exc_chain(e: BaseException):
102
+ seen = set()
103
+ while e is not None and id(e) not in seen:
104
+ seen.add(id(e))
105
+ yield e
106
+ e = e.__cause__ or e.__context__
107
+
108
+
109
+ _DNS_MARKERS = (
110
+ "nodename nor servname", # macOS
111
+ "name or service not known", # Linux (glibc)
112
+ "temporary failure in name resolution",
113
+ "no address associated with hostname",
114
+ "getaddrinfo failed", # Windows
115
+ "no such host",
116
+ )
117
+
118
+
119
+ def _is_dns_error(e: BaseException) -> bool:
120
+ for x in _exc_chain(e):
121
+ if isinstance(x, socket.gaierror):
122
+ return True
123
+ if any(m in str(x).lower() for m in _DNS_MARKERS):
124
+ return True
125
+ return False
126
+
127
+
128
+ def _explain_error(e: BaseException, base: str) -> str:
129
+ """Return a plain-English explanation (rich markup) for a failed request.
130
+
131
+ Never raises: it runs inside error handlers, where a second exception would
132
+ hide the original problem.
133
+ """
134
+ try:
135
+ return _explain_error_impl(e, base)
136
+ except Exception: # noqa: BLE001
137
+ return escape(str(e) or type(e).__name__)
138
+
139
+
140
+ def _explain_error_impl(e: BaseException, base: str) -> str:
141
+ host, port = _host_port(base)
142
+ chain = list(_exc_chain(e))
143
+ if _is_dns_error(e):
144
+ return (
145
+ f"Can't reach Spooling Cloud at {escape(base)}\n"
146
+ f" The hostname '{escape(host)}' doesn't resolve (DNS lookup failed).\n"
147
+ f" Either you're offline, or that server doesn't exist (yet).\n"
148
+ f" - Offline or on a VPN? Check your connection and try again.\n"
149
+ f" - {_SELF_HOST_HINT}"
150
+ )
151
+ if any(isinstance(x, ConnectionRefusedError) for x in chain):
152
+ return (
153
+ f"Connection refused by {escape(host)}:{port}\n"
154
+ f" The host is reachable but nothing is listening. Is the sync server running?\n"
155
+ f" - {_SELF_HOST_HINT}"
156
+ )
157
+ if isinstance(e, httpx.TimeoutException) or any(isinstance(x, (TimeoutError, socket.timeout)) for x in chain):
158
+ return (
159
+ f"Timed out talking to {escape(base)}\n"
160
+ f" The server may be starting up or overloaded. Try again in a moment."
161
+ )
162
+ if any(isinstance(x, ssl.SSLError) for x in chain):
163
+ return (
164
+ f"TLS/SSL error connecting to {escape(host)}\n"
165
+ f" {escape(str(e))}\n"
166
+ f" Check the server's certificate, and that the URL scheme (http/https) is right."
167
+ )
168
+ if isinstance(e, httpx.ConnectError):
169
+ return f"Can't connect to {escape(base)}: {escape(str(e) or type(e).__name__)}"
170
+ return escape(str(e) or type(e).__name__)
171
+
172
+
173
+ def _explain_http(e: "httpx.HTTPStatusError") -> str:
174
+ code = e.response.status_code
175
+ body = escape(e.response.text[:200])
176
+ if code == 401:
177
+ return (
178
+ f"HTTP 401: the server rejected your API key.\n"
179
+ f" Create a new key in the admin app (Settings > API keys), then run\n"
180
+ f" spooling cloud login --key sk_...\n"
181
+ f" {body}"
182
+ )
183
+ if code == 403:
184
+ return f"HTTP 403: this API key isn't allowed to do that (missing scope or no workspace).\n {body}"
185
+ if code == 429:
186
+ retry = e.response.headers.get("retry-after")
187
+ return f"HTTP 429: rate limited{f', retry in {retry}s' if retry else ''}. Slow down or try again shortly."
188
+ if code >= 500:
189
+ return f"HTTP {code}: the server hit an error. This is not a problem with your data; try again shortly.\n {body}"
190
+ return f"HTTP {code}: {body}"
191
+
192
+
193
+ def _fail(message: str) -> "None":
194
+ """Print an error and exit non-zero so scripts and cron can detect it."""
195
+ console.print(f"[red]{message}[/red]")
196
+ raise click.exceptions.Exit(1)
197
+
198
+
199
+ def _preflight(base: str) -> None:
200
+ """Fail fast, before any sync/redaction work, if the server can't be resolved.
201
+
202
+ Only checks DNS (no request is sent, so it works with any server version).
203
+ Connection-level problems are still reported by the request itself.
204
+ """
205
+ host, port = _host_port(base)
206
+ try:
207
+ socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
208
+ except OSError as e:
209
+ console.print(f"[red]{_explain_error(e, base)}[/red]")
210
+ console.print("[dim]Nothing was sent. Your local sessions are untouched.[/dim]")
211
+ raise click.exceptions.Exit(1)
212
+
213
+
73
214
  @click.group()
74
215
  def cloud():
75
216
  """Spooling Cloud: push local sessions to api.spooling.ai."""
@@ -93,8 +234,10 @@ def cloud_login(key: str, api_url: str | None):
93
234
  stats = r.json()
94
235
  console.print(f"[green]Logged in to {base}[/green]")
95
236
  console.print(f" sessions in cloud: [bold]{stats.get('sessions', 0)}[/bold]")
237
+ except httpx.HTTPStatusError as e:
238
+ console.print(f"[red]Saved key, but the server rejected the check:\n {_explain_http(e)}[/red]")
96
239
  except Exception as e:
97
- console.print(f"[red]Saved key, but /v1/stats check failed: {e}[/red]")
240
+ console.print(f"[red]Saved key, but couldn't verify it.\n {_explain_error(e, base)}[/red]")
98
241
 
99
242
 
100
243
  @cloud.command("status")
@@ -114,8 +257,10 @@ def cloud_status():
114
257
  console.print(f" messages: [bold]{s.get('messages', 0)}[/bold]")
115
258
  console.print(f" providers: [bold]{s.get('providers', 0)}[/bold]")
116
259
  console.print(f" cost: [bold]${s.get('cost', 0):.2f}[/bold]")
260
+ except httpx.HTTPStatusError as e:
261
+ _fail(f"{_explain_http(e)}")
117
262
  except Exception as e:
118
- console.print(f"[red]Error: {e}[/red]")
263
+ _fail(f"{_explain_error(e, base)}")
119
264
 
120
265
 
121
266
  @cloud.command("logout")
@@ -395,14 +540,9 @@ def _push_trace_batches(
395
540
  else:
396
541
  log(f" pushed {acc} trace(s), {spans} span(s)")
397
542
  except httpx.HTTPStatusError as e:
398
- return (
399
- accepted,
400
- rejected,
401
- spans_total,
402
- f"HTTP {e.response.status_code}: {e.response.text[:200]}",
403
- )
543
+ return accepted, rejected, spans_total, _explain_http(e)
404
544
  except Exception as e:
405
- return accepted, rejected, spans_total, str(e)
545
+ return accepted, rejected, spans_total, _explain_error(e, base)
406
546
  return accepted, rejected, spans_total, None
407
547
 
408
548
 
@@ -448,9 +588,9 @@ def _push_batches(
448
588
  else:
449
589
  log(f" pushed {accepted} sessions")
450
590
  except httpx.HTTPStatusError as e:
451
- return total, rejected, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
591
+ return total, rejected, _explain_http(e)
452
592
  except Exception as e:
453
- return total, rejected, str(e)
593
+ return total, rejected, _explain_error(e, base)
454
594
  return total, rejected, None
455
595
 
456
596
 
@@ -559,6 +699,8 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
559
699
  return
560
700
  headers = _auth_headers()
561
701
  base = _api_base()
702
+ if not dry_run:
703
+ _preflight(base) # fail fast, before syncing and redacting, if the server can't be reached
562
704
  if do_sync:
563
705
  _sync_local()
564
706
  sessions = _collect_sessions(
@@ -607,8 +749,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
607
749
  return
608
750
  total, rejected, err = _push_batches(sessions, batch, base, headers, console.print, copy=copy)
609
751
  if err:
610
- console.print(f"[red]{err}[/red]")
611
- return
752
+ _fail(err)
612
753
  if rejected:
613
754
  console.print(
614
755
  f"[green]Done.[/green] {total} sessions synced to {base} "
@@ -649,8 +790,7 @@ def push(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
649
790
  traces, trace_batch_size, base, headers, console.print,
650
791
  )
651
792
  if t_err:
652
- console.print(f"[yellow]Trace push failed: {t_err}[/yellow]")
653
- return
793
+ _fail(f"Sessions were pushed, but the trace/span upload failed:\n {t_err}")
654
794
  msg = f"[green]Done.[/green] {t_acc} trace(s) and {t_spans} span(s) synced"
655
795
  if t_rej:
656
796
  msg += f" ([yellow]{t_rej} trace(s) rejected[/yellow])"
@@ -715,7 +855,7 @@ def cloud_watch(interval: int, limit: int, batch: int, lookback: int, no_redact:
715
855
  console.print(f" [dim]Redacted {redaction_count} secret(s) before push[/dim]")
716
856
  total, rejected, err = _push_batches(sessions, batch, base, headers, console.print)
717
857
  if err:
718
- console.print(f"[red]{err}[/red] (will retry next cycle)")
858
+ console.print(f"[red]{err.splitlines()[0]}[/red] (will retry next cycle)")
719
859
  else:
720
860
  # Advance watermark to the cycle start; the lookback window catches
721
861
  # sessions whose started_at slid backwards or whose messages were
@@ -790,6 +930,7 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
790
930
 
791
931
  headers = _auth_headers()
792
932
  base = _api_base()
933
+ _preflight(base)
793
934
  params = {"dry_run": "true" if dry_run else "false"}
794
935
  if project:
795
936
  params["project"] = project
@@ -810,10 +951,10 @@ def cloud_delete(project: str | None, cwd_substr: str | None, session_id: str |
810
951
  r.raise_for_status()
811
952
  data = r.json()
812
953
  except httpx.HTTPStatusError as e:
813
- console.print(f"[red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
954
+ console.print(f"[red]{_explain_http(e)}[/red]")
814
955
  return
815
956
  except Exception as e:
816
- console.print(f"[red]{e}[/red]")
957
+ console.print(f"[red]{_explain_error(e, base)}[/red]")
817
958
  return
818
959
 
819
960
  matched = data.get("matched", 0)
@@ -928,9 +1069,9 @@ def _pull_sessions(
928
1069
  r = client.get(f"{base}/v1/sessions", headers=headers, params=params)
929
1070
  r.raise_for_status()
930
1071
  except httpx.HTTPStatusError as e:
931
- return 0, 0, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
1072
+ return 0, 0, _explain_http(e)
932
1073
  except Exception as e:
933
- return 0, 0, str(e)
1074
+ return 0, 0, _explain_error(e, base)
934
1075
 
935
1076
  data = r.json()
936
1077
  cloud_sessions = data.get("sessions", [])
@@ -979,6 +1120,7 @@ def pull(limit: int, batch: int, dry_run: bool):
979
1120
  """
980
1121
  headers = _auth_headers()
981
1122
  base = _api_base()
1123
+ _preflight(base)
982
1124
 
983
1125
  cfg = _load_config()
984
1126
  last = cfg.get("last_pull_at")
@@ -996,10 +1138,10 @@ def pull(limit: int, batch: int, dry_run: bool):
996
1138
  r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
997
1139
  r.raise_for_status()
998
1140
  except httpx.HTTPStatusError as e:
999
- console.print(f"[red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
1141
+ console.print(f"[red]{_explain_http(e)}[/red]")
1000
1142
  return
1001
1143
  except Exception as e:
1002
- console.print(f"[red]{e}[/red]")
1144
+ console.print(f"[red]{_explain_error(e, base)}[/red]")
1003
1145
  return
1004
1146
 
1005
1147
  data = r.json()
@@ -1070,6 +1212,7 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
1070
1212
  """
1071
1213
  headers = _auth_headers()
1072
1214
  base = _api_base()
1215
+ _preflight(base)
1073
1216
 
1074
1217
  # Phase 1: Push local → cloud
1075
1218
  console.print("[bold]Phase 1: Push local → cloud[/bold]")
@@ -1115,10 +1258,10 @@ def sync(limit: int, batch: int, project: str | None, cwd_substr: str | None, ti
1115
1258
  r = httpx.get(f"{base}/v1/sessions", headers=headers, params=params, timeout=30)
1116
1259
  r.raise_for_status()
1117
1260
  except httpx.HTTPStatusError as e:
1118
- console.print(f" [red]HTTP {e.response.status_code}: {e.response.text[:200]}[/red]")
1261
+ console.print(f" [red]{_explain_http(e)}[/red]")
1119
1262
  return
1120
1263
  except Exception as e:
1121
- console.print(f" [red]{e}[/red]")
1264
+ console.print(f" [red]{_explain_error(e, base)}[/red]")
1122
1265
  return
1123
1266
 
1124
1267
  data = r.json()
@@ -1189,3 +1332,25 @@ def cloud_stats() -> dict | None:
1189
1332
  return r.json()
1190
1333
  except Exception:
1191
1334
  return None
1335
+
1336
+
1337
+ def _cloud_get(path: str, params: dict | None = None):
1338
+ """GET a Spooling Cloud endpoint; on any failure print a plain-English reason and exit 1."""
1339
+ headers = _auth_headers()
1340
+ base = _api_base()
1341
+ try:
1342
+ r = httpx.get(f"{base}{path}", headers=headers, params=params, timeout=15)
1343
+ r.raise_for_status()
1344
+ return r.json()
1345
+ except Exception as e: # noqa: BLE001
1346
+ _fail(_explain_error(e, base))
1347
+
1348
+
1349
+ def cloud_tools(limit: int = 20) -> list[dict]:
1350
+ """Per-tool usage across the cloud workspace (GET /v1/tools)."""
1351
+ return _cloud_get("/v1/tools", {"limit": limit})
1352
+
1353
+
1354
+ def cloud_agents() -> list[dict]:
1355
+ """Per-agent-type usage across the cloud workspace (GET /v1/stats/agent-subtypes)."""
1356
+ return _cloud_get("/v1/stats/agent-subtypes").get("agent_subtypes", [])