spooling 0.2.10__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.10/spooling.egg-info → spooling-0.2.11}/PKG-INFO +24 -1
  2. {spooling-0.2.10 → spooling-0.2.11}/README.md +23 -0
  3. {spooling-0.2.10 → spooling-0.2.11}/pyproject.toml +1 -1
  4. {spooling-0.2.10 → spooling-0.2.11}/spooling/cli.py +201 -0
  5. {spooling-0.2.10 → spooling-0.2.11}/spooling/cloud.py +22 -0
  6. spooling-0.2.11/spooling/insights.py +253 -0
  7. {spooling-0.2.10 → spooling-0.2.11/spooling.egg-info}/PKG-INFO +24 -1
  8. {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/SOURCES.txt +2 -0
  9. spooling-0.2.11/tests/test_insights.py +218 -0
  10. {spooling-0.2.10 → spooling-0.2.11}/LICENSE +0 -0
  11. {spooling-0.2.10 → spooling-0.2.11}/setup.cfg +0 -0
  12. {spooling-0.2.10 → spooling-0.2.11}/spooling/__init__.py +0 -0
  13. {spooling-0.2.10 → spooling-0.2.11}/spooling/agent.py +0 -0
  14. {spooling-0.2.10 → spooling-0.2.11}/spooling/classifiers.py +0 -0
  15. {spooling-0.2.10 → spooling-0.2.11}/spooling/config.py +0 -0
  16. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/__init__.py +0 -0
  17. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/base.py +0 -0
  18. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/bigquery.py +0 -0
  19. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/clickhouse.py +0 -0
  20. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/factory.py +0 -0
  21. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/mongodb.py +0 -0
  22. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/mysql.py +0 -0
  23. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/postgresql.py +0 -0
  24. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/__init__.py +0 -0
  25. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/base.py +0 -0
  26. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/rest/shopify_connectors.py +0 -0
  27. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/snowflake.py +0 -0
  28. {spooling-0.2.10 → spooling-0.2.11}/spooling/connectors/types.py +0 -0
  29. {spooling-0.2.10 → spooling-0.2.11}/spooling/db.py +0 -0
  30. {spooling-0.2.10 → spooling-0.2.11}/spooling/embeddings.py +0 -0
  31. {spooling-0.2.10 → spooling-0.2.11}/spooling/evals.py +0 -0
  32. {spooling-0.2.10 → spooling-0.2.11}/spooling/experiments.py +0 -0
  33. {spooling-0.2.10 → spooling-0.2.11}/spooling/ingest.py +0 -0
  34. {spooling-0.2.10 → spooling-0.2.11}/spooling/mcp_server.py +0 -0
  35. {spooling-0.2.10 → spooling-0.2.11}/spooling/optimize.py +0 -0
  36. {spooling-0.2.10 → spooling-0.2.11}/spooling/parser.py +0 -0
  37. {spooling-0.2.10 → spooling-0.2.11}/spooling/pricing.py +0 -0
  38. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/__init__.py +0 -0
  39. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/antigravity.py +0 -0
  40. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/base.py +0 -0
  41. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/claude_code.py +0 -0
  42. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/codex.py +0 -0
  43. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/copilot.py +0 -0
  44. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/cortex_code.py +0 -0
  45. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/cursor.py +0 -0
  46. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/gemini.py +0 -0
  47. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/github.py +0 -0
  48. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/gitlab.py +0 -0
  49. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/kiro.py +0 -0
  50. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/opencode.py +0 -0
  51. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/session_file.py +0 -0
  52. {spooling-0.2.10 → spooling-0.2.11}/spooling/providers/windsurf.py +0 -0
  53. {spooling-0.2.10 → spooling-0.2.11}/spooling/redact.py +0 -0
  54. {spooling-0.2.10 → spooling-0.2.11}/spooling/remote_otel.py +0 -0
  55. {spooling-0.2.10 → spooling-0.2.11}/spooling/schema.py +0 -0
  56. {spooling-0.2.10 → spooling-0.2.11}/spooling/sdk.py +0 -0
  57. {spooling-0.2.10 → spooling-0.2.11}/spooling/search.py +0 -0
  58. {spooling-0.2.10 → spooling-0.2.11}/spooling/server.py +0 -0
  59. {spooling-0.2.10 → spooling-0.2.11}/spooling/sessions.py +0 -0
  60. {spooling-0.2.10 → spooling-0.2.11}/spooling/stats.py +0 -0
  61. {spooling-0.2.10 → spooling-0.2.11}/spooling/subscription_pricing.py +0 -0
  62. {spooling-0.2.10 → spooling-0.2.11}/spooling/tracing.py +0 -0
  63. {spooling-0.2.10 → spooling-0.2.11}/spooling/tunnel.py +0 -0
  64. {spooling-0.2.10 → spooling-0.2.11}/spooling/tunnel_auth.py +0 -0
  65. {spooling-0.2.10 → spooling-0.2.11}/spooling/watcher.py +0 -0
  66. {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/dependency_links.txt +0 -0
  67. {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/entry_points.txt +0 -0
  68. {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/requires.txt +0 -0
  69. {spooling-0.2.10 → spooling-0.2.11}/spooling.egg-info/top_level.txt +0 -0
  70. {spooling-0.2.10 → spooling-0.2.11}/tests/test_claude_code_provider.py +0 -0
  71. {spooling-0.2.10 → spooling-0.2.11}/tests/test_cloud_errors.py +0 -0
  72. {spooling-0.2.10 → spooling-0.2.11}/tests/test_mcp_auth_middleware.py +0 -0
  73. {spooling-0.2.10 → spooling-0.2.11}/tests/test_session_ids.py +0 -0
  74. {spooling-0.2.10 → spooling-0.2.11}/tests/test_storage_optimizations.py +0 -0
  75. {spooling-0.2.10 → 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.10
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.10"
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
  # ---------------------------------------------------------------------------
@@ -1332,3 +1332,25 @@ def cloud_stats() -> dict | None:
1332
1332
  return r.json()
1333
1333
  except Exception:
1334
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", [])
@@ -0,0 +1,253 @@
1
+ """Agent / tool / trace insights over the local span data.
2
+
3
+ These power ``spooling tools``, ``spooling agents`` and ``spooling traces``.
4
+
5
+ Naming matches the Spooling web app so numbers line up between the two:
6
+
7
+ * an agent is its ``agent_type`` (the subagent type) when set, else the span name
8
+ * a tool is its ``tool_name`` when set, else the span name
9
+
10
+ Filter semantics:
11
+
12
+ * ``tools --agent A`` tools called *by* agent A (inside its subtree)
13
+ * ``agents --tool T`` agents whose runs called tool T (inside their subtree)
14
+ * ``traces`` trace-level, like the Traces page: a trace matches an
15
+ agent / tool filter if it contains any such span
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from spooling.db import get_connection
21
+ from spooling.stats import _days_ago
22
+
23
+ AGENT_LABEL = "COALESCE(NULLIF({a}.agent_type, ''), {a}.name)"
24
+ TOOL_LABEL = "COALESCE(NULLIF({a}.tool_name, ''), {a}.name)"
25
+
26
+
27
+ def _agent(alias: str = "sp") -> str:
28
+ return AGENT_LABEL.format(a=alias)
29
+
30
+
31
+ def _tool(alias: str = "sp") -> str:
32
+ return TOOL_LABEL.format(a=alias)
33
+
34
+
35
+ def _marks(n: int) -> str:
36
+ return ",".join("?" * n)
37
+
38
+
39
+ def _trace_filters(
40
+ *,
41
+ days: int | None,
42
+ project: str | None,
43
+ provider: str | None,
44
+ alias: str = "t",
45
+ ) -> tuple[list[str], list]:
46
+ """WHERE fragments restricting the ``traces`` table (alias ``t``)."""
47
+ where: list[str] = []
48
+ params: list = []
49
+ if days:
50
+ where.append(f"{alias}.started_at >= ?")
51
+ params.append(_days_ago(days))
52
+ if project:
53
+ where.append(f"{alias}.project LIKE ?")
54
+ params.append(f"%{project}%")
55
+ if provider:
56
+ where.append(f"{alias}.provider_id = ?")
57
+ params.append(provider)
58
+ return where, params
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # tools
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def tool_stats(
66
+ *,
67
+ days: int | None = None,
68
+ project: str | None = None,
69
+ provider: str | None = None,
70
+ agents: list[str] | None = None,
71
+ limit: int = 20,
72
+ ) -> list[dict]:
73
+ """Per-tool usage: calls, traces it appeared in, errors, average duration."""
74
+ where, params = _trace_filters(days=days, project=project, provider=provider)
75
+ where.insert(0, "sp.kind = 'tool'")
76
+
77
+ cte = ""
78
+ if agents:
79
+ # restrict to tool spans that live under one of the named agents
80
+ cte = f"""WITH RECURSIVE sub(id) AS (
81
+ SELECT sp.id FROM spans sp WHERE sp.kind = 'agent' AND {_agent()} IN ({_marks(len(agents))})
82
+ UNION
83
+ SELECT c.id FROM spans c JOIN sub ON c.parent_id = sub.id
84
+ )"""
85
+ where.append("sp.id IN (SELECT id FROM sub)")
86
+ params = list(agents) + params
87
+
88
+ sql = f"""{cte}
89
+ SELECT {_tool()} AS name,
90
+ COUNT(*) AS uses,
91
+ COUNT(DISTINCT sp.trace_id) AS traces,
92
+ COALESCE(SUM(sp.tool_is_error), 0) AS errors,
93
+ AVG(NULLIF(sp.duration_ms, 0)) AS avg_ms
94
+ FROM spans sp
95
+ JOIN traces t ON t.id = sp.trace_id
96
+ WHERE {' AND '.join(where)}
97
+ GROUP BY 1
98
+ ORDER BY uses DESC, name ASC
99
+ LIMIT ?"""
100
+ conn = get_connection()
101
+ try:
102
+ rows = conn.execute(sql, params + [limit]).fetchall()
103
+ finally:
104
+ conn.close()
105
+ return [
106
+ {
107
+ "name": r["name"],
108
+ "uses": int(r["uses"]),
109
+ "traces": int(r["traces"]),
110
+ "errors": int(r["errors"] or 0),
111
+ "avg_ms": float(r["avg_ms"]) if r["avg_ms"] is not None else None,
112
+ }
113
+ for r in rows
114
+ ]
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # agents
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def agent_stats(
122
+ *,
123
+ days: int | None = None,
124
+ project: str | None = None,
125
+ provider: str | None = None,
126
+ tools: list[str] | None = None,
127
+ limit: int = 20,
128
+ ) -> list[dict]:
129
+ """Per-agent usage: runs, traces it appeared in, tokens, cost."""
130
+ where, params = _trace_filters(days=days, project=project, provider=provider)
131
+ where.insert(0, "sp.kind = 'agent'")
132
+
133
+ cte = ""
134
+ if tools:
135
+ # restrict to agent spans that have one of the named tools somewhere beneath them
136
+ cte = f"""WITH RECURSIVE up(id) AS (
137
+ SELECT sp.parent_id FROM spans sp
138
+ WHERE sp.kind = 'tool' AND {_tool()} IN ({_marks(len(tools))}) AND sp.parent_id IS NOT NULL
139
+ UNION
140
+ SELECT p.parent_id FROM spans p JOIN up ON p.id = up.id WHERE p.parent_id IS NOT NULL
141
+ )"""
142
+ where.append("sp.id IN (SELECT id FROM up)")
143
+ params = list(tools) + params
144
+
145
+ sql = f"""{cte}
146
+ SELECT {_agent()} AS name,
147
+ COUNT(*) AS runs,
148
+ COUNT(DISTINCT sp.trace_id) AS traces,
149
+ COALESCE(SUM(sp.input_tokens), 0) + COALESCE(SUM(sp.output_tokens), 0) AS tokens,
150
+ COALESCE(SUM(sp.cost_usd), 0) AS cost,
151
+ AVG(NULLIF(sp.duration_ms, 0)) AS avg_ms
152
+ FROM spans sp
153
+ JOIN traces t ON t.id = sp.trace_id
154
+ WHERE {' AND '.join(where)}
155
+ GROUP BY 1
156
+ ORDER BY runs DESC, name ASC
157
+ LIMIT ?"""
158
+ conn = get_connection()
159
+ try:
160
+ rows = conn.execute(sql, params + [limit]).fetchall()
161
+ finally:
162
+ conn.close()
163
+ return [
164
+ {
165
+ "name": r["name"],
166
+ "runs": int(r["runs"]),
167
+ "traces": int(r["traces"]),
168
+ "tokens": int(r["tokens"] or 0),
169
+ "cost": float(r["cost"] or 0),
170
+ "avg_ms": float(r["avg_ms"]) if r["avg_ms"] is not None else None,
171
+ }
172
+ for r in rows
173
+ ]
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # traces
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def list_traces(
181
+ *,
182
+ query: str | None = None,
183
+ agents: list[str] | None = None,
184
+ tools: list[str] | None = None,
185
+ session: str | None = None,
186
+ days: int | None = None,
187
+ project: str | None = None,
188
+ provider: str | None = None,
189
+ limit: int = 20,
190
+ ) -> list[dict]:
191
+ """Traces, newest first, each with its distinct agents.
192
+
193
+ ``query`` matches title, project, session id, trace id or an agent name
194
+ (the same fields the Traces page searches).
195
+ """
196
+ where, params = _trace_filters(days=days, project=project, provider=provider)
197
+
198
+ if session:
199
+ where.append("t.session_id LIKE ?")
200
+ params.append(f"{session}%")
201
+ if query and query.strip():
202
+ like = f"%{query.strip()}%"
203
+ where.append(
204
+ f"""(COALESCE(t.title, s.title) LIKE ? OR COALESCE(t.project, s.project) LIKE ?
205
+ OR t.session_id LIKE ? OR t.id LIKE ?
206
+ OR EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'agent'
207
+ AND {_agent()} LIKE ?))"""
208
+ )
209
+ params.extend([like] * 5)
210
+ if agents:
211
+ where.append(
212
+ f"""EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'agent'
213
+ AND {_agent()} IN ({_marks(len(agents))}))"""
214
+ )
215
+ params.extend(agents)
216
+ if tools:
217
+ where.append(
218
+ f"""EXISTS (SELECT 1 FROM spans sp WHERE sp.trace_id = t.id AND sp.kind = 'tool'
219
+ AND {_tool()} IN ({_marks(len(tools))}))"""
220
+ )
221
+ params.extend(tools)
222
+
223
+ sql = f"""
224
+ SELECT t.id, t.session_id, t.provider_id,
225
+ COALESCE(t.project, s.project) AS project,
226
+ COALESCE(t.title, s.title) AS title,
227
+ t.started_at, t.duration_ms, t.span_count, t.agent_count, t.tool_count,
228
+ t.error_count, t.total_input_tokens, t.total_output_tokens,
229
+ t.total_cost_usd, t.model
230
+ FROM traces t
231
+ LEFT JOIN sessions s ON s.id = t.session_id
232
+ {('WHERE ' + ' AND '.join(where)) if where else ''}
233
+ ORDER BY t.started_at DESC
234
+ LIMIT ?"""
235
+ conn = get_connection()
236
+ try:
237
+ rows = [dict(r) for r in conn.execute(sql, params + [limit]).fetchall()]
238
+ if rows:
239
+ ids = [r["id"] for r in rows]
240
+ by_trace: dict[str, list[str]] = {i: [] for i in ids}
241
+ for a in conn.execute(
242
+ f"""SELECT DISTINCT sp.trace_id AS trace_id, {_agent()} AS name
243
+ FROM spans sp
244
+ WHERE sp.kind = 'agent' AND sp.trace_id IN ({_marks(len(ids))})
245
+ ORDER BY name""",
246
+ ids,
247
+ ).fetchall():
248
+ by_trace[a["trace_id"]].append(a["name"])
249
+ for r in rows:
250
+ r["agents"] = by_trace[r["id"]]
251
+ finally:
252
+ conn.close()
253
+ return rows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.10
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`.
@@ -12,6 +12,7 @@ spooling/embeddings.py
12
12
  spooling/evals.py
13
13
  spooling/experiments.py
14
14
  spooling/ingest.py
15
+ spooling/insights.py
15
16
  spooling/mcp_server.py
16
17
  spooling/optimize.py
17
18
  spooling/parser.py
@@ -65,6 +66,7 @@ spooling/providers/session_file.py
65
66
  spooling/providers/windsurf.py
66
67
  tests/test_claude_code_provider.py
67
68
  tests/test_cloud_errors.py
69
+ tests/test_insights.py
68
70
  tests/test_mcp_auth_middleware.py
69
71
  tests/test_session_ids.py
70
72
  tests/test_storage_optimizations.py
@@ -0,0 +1,218 @@
1
+ """Tests for `spooling tools`, `spooling agents` and `spooling traces`
2
+ (spooling.insights + the CLI commands)."""
3
+
4
+ import json
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ import pytest
8
+ from click.testing import CliRunner
9
+
10
+ from spooling import db as db_mod
11
+ from spooling import insights
12
+ from spooling.cli import cli
13
+
14
+ NOW = datetime.now(timezone.utc)
15
+
16
+
17
+ @pytest.fixture
18
+ def conn(tmp_path, monkeypatch):
19
+ monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
20
+ c = db_mod.get_connection()
21
+ yield c
22
+ c.close()
23
+
24
+
25
+ def add_trace(conn, tid, sid, *, title, days_ago=0, project="proj", provider="claude-code"):
26
+ started = (NOW - timedelta(days=days_ago)).isoformat()
27
+ conn.execute(
28
+ "INSERT INTO sessions (id, provider_id, project, title, started_at) VALUES (?,?,?,?,?)",
29
+ (sid, provider, project, title, started),
30
+ )
31
+ conn.execute(
32
+ "INSERT INTO traces (id, session_id, provider_id, project, title, started_at) VALUES (?,?,?,?,?,?)",
33
+ (tid, sid, provider, project, title, started),
34
+ )
35
+
36
+
37
+ def add_span(conn, sid, tid, kind, *, parent=None, name=None, tool=None, agent=None,
38
+ err=0, ms=100, tokens=0, cost=0.0):
39
+ conn.execute(
40
+ """INSERT INTO spans (id, trace_id, parent_id, kind, name, tool_name, agent_type,
41
+ tool_is_error, duration_ms, input_tokens, output_tokens, cost_usd)
42
+ VALUES (?,?,?,?,?,?,?,?,?,?,0,?)""",
43
+ (sid, tid, parent, kind, name or tool or agent or kind, tool, agent, err, ms, tokens, cost),
44
+ )
45
+
46
+
47
+ @pytest.fixture
48
+ def seeded(conn):
49
+ # trace A: root -> explore agent (Read x2, Grep) ; root -> Bash (top level, one error)
50
+ add_trace(conn, "tA", "ses_aaaa1111", title="Fix login bug", days_ago=1)
51
+ add_span(conn, "A-root", "tA", "session")
52
+ add_span(conn, "A-ex", "tA", "agent", parent="A-root", name="agent:explore", agent="explore", tokens=100, cost=0.5)
53
+ add_span(conn, "A-r1", "tA", "tool", parent="A-ex", tool="Read", ms=100)
54
+ add_span(conn, "A-r2", "tA", "tool", parent="A-ex", tool="Read", ms=300)
55
+ add_span(conn, "A-g1", "tA", "tool", parent="A-ex", tool="Grep")
56
+ add_span(conn, "A-b1", "tA", "tool", parent="A-root", tool="Bash", err=1)
57
+ # trace B (older): root -> build agent -> Bash ; agent has no agent_type, falls back to name
58
+ add_trace(conn, "tB", "ses_bbbb2222", title="Refactor billing", days_ago=40, project="other", provider="opencode")
59
+ add_span(conn, "B-root", "tB", "session")
60
+ add_span(conn, "B-bd", "tB", "agent", parent="B-root", name="build", agent=None, tokens=50, cost=0.25)
61
+ add_span(conn, "B-b1", "tB", "tool", parent="B-bd", tool="Bash")
62
+ # trace C: no agents at all
63
+ add_trace(conn, "tC", "ses_cccc3333", title="Quick question", days_ago=2)
64
+ add_span(conn, "C-root", "tC", "session")
65
+ add_span(conn, "C-r1", "tC", "tool", parent="C-root", tool="Read")
66
+ conn.commit()
67
+
68
+
69
+ def by_name(rows):
70
+ return {r["name"]: r for r in rows}
71
+
72
+
73
+ class TestToolStats:
74
+ def test_counts_errors_and_traces(self, seeded):
75
+ t = by_name(insights.tool_stats())
76
+ assert t["Read"]["uses"] == 3 and t["Read"]["traces"] == 2
77
+ assert t["Bash"]["uses"] == 2 and t["Bash"]["errors"] == 1
78
+ assert t["Read"]["avg_ms"] == pytest.approx(166.67, abs=0.1)
79
+
80
+ def test_sorted_by_uses_and_limited(self, seeded):
81
+ rows = insights.tool_stats(limit=1)
82
+ assert [r["name"] for r in rows] == ["Read"]
83
+
84
+ def test_agent_filter_is_subtree_only(self, seeded):
85
+ t = by_name(insights.tool_stats(agents=["explore"]))
86
+ assert set(t) == {"Read", "Grep"} # top-level Bash and trace C's Read excluded
87
+ assert t["Read"]["uses"] == 2
88
+
89
+ def test_agent_filter_falls_back_to_span_name(self, seeded):
90
+ assert set(by_name(insights.tool_stats(agents=["build"]))) == {"Bash"}
91
+
92
+ def test_days_project_provider_filters(self, seeded):
93
+ assert by_name(insights.tool_stats(days=7))["Bash"]["uses"] == 1 # trace B is 40 days old
94
+ other = by_name(insights.tool_stats(project="other"))
95
+ assert set(other) == {"Bash"} and other["Bash"]["uses"] == 1
96
+ assert by_name(insights.tool_stats(provider="opencode"))["Bash"]["uses"] == 1
97
+
98
+
99
+ class TestAgentStats:
100
+ def test_runs_tokens_cost(self, seeded):
101
+ a = by_name(insights.agent_stats())
102
+ assert set(a) == {"explore", "build"}
103
+ assert a["explore"]["runs"] == 1 and a["explore"]["tokens"] == 100
104
+ assert a["explore"]["cost"] == pytest.approx(0.5)
105
+
106
+ def test_tool_filter_is_subtree_only(self, seeded):
107
+ assert set(by_name(insights.agent_stats(tools=["Grep"]))) == {"explore"}
108
+ assert set(by_name(insights.agent_stats(tools=["Bash"]))) == {"build"} # explore's Bash is not under it
109
+
110
+ def test_days_filter(self, seeded):
111
+ assert set(by_name(insights.agent_stats(days=7))) == {"explore"}
112
+
113
+ def test_none_when_no_agents(self, conn):
114
+ add_trace(conn, "t1", "s1", title="x")
115
+ add_span(conn, "r", "t1", "session")
116
+ conn.commit()
117
+ assert insights.agent_stats() == []
118
+
119
+
120
+ class TestListTraces:
121
+ def test_newest_first_with_agents(self, seeded):
122
+ rows = insights.list_traces()
123
+ assert [r["id"] for r in rows] == ["tA", "tC", "tB"]
124
+ assert by_name([{"name": r["id"], **r} for r in rows])["tA"]["agents"] == ["explore"]
125
+ assert rows[1]["agents"] == []
126
+
127
+ def test_session_id_search_full_prefix_and_substring(self, seeded):
128
+ assert [r["id"] for r in insights.list_traces(query="ses_bbbb2222")] == ["tB"]
129
+ assert [r["id"] for r in insights.list_traces(query="bbbb")] == ["tB"]
130
+ assert [r["id"] for r in insights.list_traces(session="ses_aaaa")] == ["tA"]
131
+
132
+ def test_query_matches_title_and_agent_name(self, seeded):
133
+ assert [r["id"] for r in insights.list_traces(query="billing")] == ["tB"]
134
+ assert [r["id"] for r in insights.list_traces(query="explore")] == ["tA"]
135
+
136
+ def test_agent_and_tool_filters_are_trace_level(self, seeded):
137
+ assert [r["id"] for r in insights.list_traces(agents=["build"])] == ["tB"]
138
+ assert [r["id"] for r in insights.list_traces(agents=["build", "explore"])] == ["tA", "tB"]
139
+ assert [r["id"] for r in insights.list_traces(tools=["Grep"])] == ["tA"]
140
+ assert [r["id"] for r in insights.list_traces(agents=["explore"], tools=["Bash"])] == ["tA"]
141
+ assert insights.list_traces(agents=["explore"], tools=["Nope"]) == []
142
+
143
+ def test_other_filters_and_limit(self, seeded):
144
+ assert [r["id"] for r in insights.list_traces(provider="opencode")] == ["tB"]
145
+ assert [r["id"] for r in insights.list_traces(days=7)] == ["tA", "tC"]
146
+ assert len(insights.list_traces(limit=1)) == 1
147
+
148
+
149
+ class TestCli:
150
+ def run(self, *args):
151
+ return CliRunner().invoke(cli, list(args))
152
+
153
+ def test_tools_table_and_json(self, seeded):
154
+ r = self.run("tools")
155
+ assert r.exit_code == 0 and "Read" in r.output and "Bash" in r.output
156
+ data = json.loads(self.run("tools", "--agent", "explore", "--json").output)
157
+ assert {d["name"] for d in data} == {"Read", "Grep"}
158
+
159
+ def test_agents_table_and_json(self, seeded):
160
+ assert "explore" in self.run("agents").output
161
+ data = json.loads(self.run("agents", "--tool", "Grep", "--json").output)
162
+ assert [d["name"] for d in data] == ["explore"]
163
+
164
+ def test_agents_empty_explains_why(self, conn):
165
+ r = self.run("agents")
166
+ assert r.exit_code == 0 and "No agent spans" in r.output
167
+
168
+ def test_traces_filters(self, seeded):
169
+ data = json.loads(self.run("traces", "--agent", "build", "--json").output)
170
+ assert [d["id"] for d in data] == ["tB"]
171
+ data = json.loads(self.run("traces", "ses_cccc", "--json").output)
172
+ assert [d["id"] for d in data] == ["tC"]
173
+ data = json.loads(self.run("traces", "--tool", "Grep", "--days", "7", "--json").output)
174
+ assert [d["id"] for d in data] == ["tA"]
175
+
176
+ def test_cloud_rejects_local_only_filters(self, seeded):
177
+ r = self.run("tools", "--cloud", "--days", "7")
178
+ assert r.exit_code == 2 and "can't be combined with --cloud" in r.output
179
+ r = self.run("agents", "--cloud", "--tool", "Bash")
180
+ assert r.exit_code == 2
181
+
182
+ def test_cloud_mode_renders_server_data(self, seeded, monkeypatch):
183
+ from spooling import cloud
184
+ monkeypatch.setattr(cloud, "cloud_tools", lambda limit=20: [
185
+ {"tool_name": "Bash", "uses": 12, "sessions": 3, "errors": 1}])
186
+ monkeypatch.setattr(cloud, "cloud_agents", lambda: [{"agent_type": "explore", "uses": 7}])
187
+ assert "Bash" in self.run("tools", "--cloud").output
188
+ assert json.loads(self.run("agents", "--cloud", "--json").output) == [{"agent_type": "explore", "uses": 7}]
189
+
190
+
191
+ class TestCloudErrors:
192
+ def run(self, *args):
193
+ return CliRunner().invoke(cli, list(args))
194
+
195
+ def test_unreachable_server_exits_nonzero_with_reason(self, conn, monkeypatch):
196
+ import httpx
197
+ from spooling import cloud
198
+ monkeypatch.setattr(cloud, "_auth_headers", lambda: {"Authorization": "Bearer sk_x"})
199
+
200
+ def boom(*a, **k):
201
+ raise httpx.ConnectError("connection refused")
202
+ monkeypatch.setattr(cloud.httpx, "get", boom)
203
+ for cmd in (["tools", "--cloud"], ["agents", "--cloud"]):
204
+ r = self.run(*cmd)
205
+ assert r.exit_code == 1
206
+ assert "log in" not in r.output.lower() # a network error must not read as "log in"
207
+
208
+ def test_rejected_key_explained(self, conn, monkeypatch):
209
+ import httpx
210
+ from spooling import cloud
211
+ monkeypatch.setattr(cloud, "_auth_headers", lambda: {"Authorization": "Bearer sk_x"})
212
+ req = httpx.Request("GET", "https://api.example/v1/tools")
213
+
214
+ def reject(*a, **k):
215
+ return httpx.Response(401, text="bad key", request=req)
216
+ monkeypatch.setattr(cloud.httpx, "get", reject)
217
+ r = self.run("tools", "--cloud")
218
+ assert r.exit_code == 1 and "401" in r.output
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