spooling 0.2.8__tar.gz → 0.2.10__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 (73) hide show
  1. {spooling-0.2.8/spooling.egg-info → spooling-0.2.10}/PKG-INFO +6 -2
  2. {spooling-0.2.8 → spooling-0.2.10}/README.md +5 -1
  3. {spooling-0.2.8 → spooling-0.2.10}/pyproject.toml +1 -1
  4. {spooling-0.2.8 → spooling-0.2.10}/spooling/cli.py +1 -1
  5. {spooling-0.2.8 → spooling-0.2.10}/spooling/cloud.py +167 -24
  6. {spooling-0.2.8 → spooling-0.2.10}/spooling/mcp_server.py +1 -1
  7. {spooling-0.2.8 → spooling-0.2.10}/spooling/parser.py +23 -4
  8. {spooling-0.2.8 → spooling-0.2.10}/spooling/pricing.py +1 -0
  9. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/__init__.py +2 -0
  10. spooling-0.2.10/spooling/providers/claude_code.py +65 -0
  11. {spooling-0.2.8 → spooling-0.2.10}/spooling/server.py +7 -0
  12. {spooling-0.2.8 → spooling-0.2.10/spooling.egg-info}/PKG-INFO +6 -2
  13. {spooling-0.2.8 → spooling-0.2.10}/spooling.egg-info/SOURCES.txt +3 -0
  14. spooling-0.2.10/tests/test_claude_code_provider.py +229 -0
  15. spooling-0.2.10/tests/test_cloud_errors.py +219 -0
  16. {spooling-0.2.8 → spooling-0.2.10}/LICENSE +0 -0
  17. {spooling-0.2.8 → spooling-0.2.10}/setup.cfg +0 -0
  18. {spooling-0.2.8 → spooling-0.2.10}/spooling/__init__.py +0 -0
  19. {spooling-0.2.8 → spooling-0.2.10}/spooling/agent.py +0 -0
  20. {spooling-0.2.8 → spooling-0.2.10}/spooling/classifiers.py +0 -0
  21. {spooling-0.2.8 → spooling-0.2.10}/spooling/config.py +0 -0
  22. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/__init__.py +0 -0
  23. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/base.py +0 -0
  24. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/bigquery.py +0 -0
  25. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/clickhouse.py +0 -0
  26. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/factory.py +0 -0
  27. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/mongodb.py +0 -0
  28. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/mysql.py +0 -0
  29. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/postgresql.py +0 -0
  30. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/rest/__init__.py +0 -0
  31. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/rest/base.py +0 -0
  32. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/rest/shopify_connectors.py +0 -0
  33. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/snowflake.py +0 -0
  34. {spooling-0.2.8 → spooling-0.2.10}/spooling/connectors/types.py +0 -0
  35. {spooling-0.2.8 → spooling-0.2.10}/spooling/db.py +0 -0
  36. {spooling-0.2.8 → spooling-0.2.10}/spooling/embeddings.py +0 -0
  37. {spooling-0.2.8 → spooling-0.2.10}/spooling/evals.py +0 -0
  38. {spooling-0.2.8 → spooling-0.2.10}/spooling/experiments.py +0 -0
  39. {spooling-0.2.8 → spooling-0.2.10}/spooling/ingest.py +0 -0
  40. {spooling-0.2.8 → spooling-0.2.10}/spooling/optimize.py +0 -0
  41. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/antigravity.py +0 -0
  42. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/base.py +0 -0
  43. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/codex.py +0 -0
  44. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/copilot.py +0 -0
  45. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/cortex_code.py +0 -0
  46. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/cursor.py +0 -0
  47. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/gemini.py +0 -0
  48. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/github.py +0 -0
  49. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/gitlab.py +0 -0
  50. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/kiro.py +0 -0
  51. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/opencode.py +0 -0
  52. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/session_file.py +0 -0
  53. {spooling-0.2.8 → spooling-0.2.10}/spooling/providers/windsurf.py +0 -0
  54. {spooling-0.2.8 → spooling-0.2.10}/spooling/redact.py +0 -0
  55. {spooling-0.2.8 → spooling-0.2.10}/spooling/remote_otel.py +0 -0
  56. {spooling-0.2.8 → spooling-0.2.10}/spooling/schema.py +0 -0
  57. {spooling-0.2.8 → spooling-0.2.10}/spooling/sdk.py +0 -0
  58. {spooling-0.2.8 → spooling-0.2.10}/spooling/search.py +0 -0
  59. {spooling-0.2.8 → spooling-0.2.10}/spooling/sessions.py +0 -0
  60. {spooling-0.2.8 → spooling-0.2.10}/spooling/stats.py +0 -0
  61. {spooling-0.2.8 → spooling-0.2.10}/spooling/subscription_pricing.py +0 -0
  62. {spooling-0.2.8 → spooling-0.2.10}/spooling/tracing.py +0 -0
  63. {spooling-0.2.8 → spooling-0.2.10}/spooling/tunnel.py +0 -0
  64. {spooling-0.2.8 → spooling-0.2.10}/spooling/tunnel_auth.py +0 -0
  65. {spooling-0.2.8 → spooling-0.2.10}/spooling/watcher.py +0 -0
  66. {spooling-0.2.8 → spooling-0.2.10}/spooling.egg-info/dependency_links.txt +0 -0
  67. {spooling-0.2.8 → spooling-0.2.10}/spooling.egg-info/entry_points.txt +0 -0
  68. {spooling-0.2.8 → spooling-0.2.10}/spooling.egg-info/requires.txt +0 -0
  69. {spooling-0.2.8 → spooling-0.2.10}/spooling.egg-info/top_level.txt +0 -0
  70. {spooling-0.2.8 → spooling-0.2.10}/tests/test_mcp_auth_middleware.py +0 -0
  71. {spooling-0.2.8 → spooling-0.2.10}/tests/test_session_ids.py +0 -0
  72. {spooling-0.2.8 → spooling-0.2.10}/tests/test_storage_optimizations.py +0 -0
  73. {spooling-0.2.8 → spooling-0.2.10}/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.8
3
+ Version: 0.2.10
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  Local session tracker and semantic search for AI coding assistants.
40
40
 
41
- Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
41
+ Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
42
42
 
43
43
  **Website:** [spooling.ai](https://spooling.ai)
44
44
 
@@ -142,6 +142,7 @@ spooling sync # Full sync with embeddings
142
142
  spooling sync --no-embed # Skip embeddings (faster initial sync)
143
143
  spooling sync -p cursor # Sync only Cursor sessions
144
144
  spooling sync -p codex # Sync only Codex CLI sessions
145
+ spooling sync -p claude-code # Sync only Claude Code sessions
145
146
  ```
146
147
 
147
148
  ### `spooling stats`
@@ -639,6 +640,7 @@ All optional — defaults work out of the box.
639
640
 
640
641
  | Provider | Data Location | Format |
641
642
  |----------|--------------|--------|
643
+ | **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
642
644
  | **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
643
645
  | **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
644
646
  | **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
@@ -650,5 +652,7 @@ All optional — defaults work out of the box.
650
652
 
651
653
  Run `spooling init` to see which providers are detected on your system.
652
654
 
655
+ **Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
656
+
653
657
  Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
654
658
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local session tracker and semantic search for AI coding assistants.
4
4
 
5
- Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
5
+ Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
6
6
 
7
7
  **Website:** [spooling.ai](https://spooling.ai)
8
8
 
@@ -106,6 +106,7 @@ spooling sync # Full sync with embeddings
106
106
  spooling sync --no-embed # Skip embeddings (faster initial sync)
107
107
  spooling sync -p cursor # Sync only Cursor sessions
108
108
  spooling sync -p codex # Sync only Codex CLI sessions
109
+ spooling sync -p claude-code # Sync only Claude Code sessions
109
110
  ```
110
111
 
111
112
  ### `spooling stats`
@@ -603,6 +604,7 @@ All optional — defaults work out of the box.
603
604
 
604
605
  | Provider | Data Location | Format |
605
606
  |----------|--------------|--------|
607
+ | **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
606
608
  | **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
607
609
  | **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
608
610
  | **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
@@ -614,5 +616,7 @@ All optional — defaults work out of the box.
614
616
 
615
617
  Run `spooling init` to see which providers are detected on your system.
616
618
 
619
+ **Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
620
+
617
621
  Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
618
622
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.8"
3
+ version = "0.2.10"
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." }]
@@ -64,7 +64,7 @@ def init():
64
64
 
65
65
  @cli.command()
66
66
  @click.option("--no-embed", is_flag=True, help="Skip embedding (faster sync)")
67
- @click.option("--provider", "-p", default=None, help="Only sync a specific provider (jsonl-session, codex, cursor, copilot, windsurf)")
67
+ @click.option("--provider", "-p", default=None, help="Only sync a specific provider (claude-code, jsonl-session, codex, cursor, copilot, windsurf)")
68
68
  def sync(no_embed, provider):
69
69
  """Sync AI coding sessions to the database."""
70
70
  from spooling.ingest import sync as do_sync
@@ -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()
@@ -161,7 +161,7 @@ def list_traces(
161
161
 
162
162
  Args:
163
163
  limit: Max rows to return (default 25, capped at 200).
164
- provider: Filter to one provider id (jsonl-session, codex, cursor, copilot, windsurf, kiro, antigravity, gemini, opencode).
164
+ provider: Filter to one provider id (claude-code, jsonl-session, codex, cursor, copilot, windsurf, kiro, antigravity, gemini, opencode).
165
165
  project: Filter to sessions whose project name matches exactly.
166
166
  """
167
167
  limit = max(1, min(limit, 200))
@@ -304,8 +304,19 @@ def _tool_result_text(block: dict) -> tuple[str, bool | None]:
304
304
  return "", is_error
305
305
 
306
306
 
307
- def parse_session_file(file_path: Path) -> ParsedSession | None:
308
- """Parse a single session JSONL file into messages + trace."""
307
+ def parse_session_file(
308
+ file_path: Path,
309
+ provider_id: str = "jsonl-session",
310
+ project_from_cwd: bool = False,
311
+ ) -> ParsedSession | None:
312
+ """Parse a single session JSONL file into messages + trace.
313
+
314
+ ``provider_id`` is stamped on both the session and its trace (the generic
315
+ ``jsonl-session`` reader and the ``claude-code`` provider share this parser).
316
+ With ``project_from_cwd`` the project is the working directory's basename,
317
+ matching Codex/Gemini/opencode, instead of the on-disk folder name (which for
318
+ Claude Code is the whole path with slashes replaced by dashes).
319
+ """
309
320
  session_id = file_path.stem
310
321
  project = file_path.parent.name
311
322
 
@@ -344,9 +355,14 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
344
355
  agent_version = agent_version or r.get("version")
345
356
  if not model:
346
357
  m = (r.get("message") or {}).get("model")
347
- if m:
358
+ # "<synthetic>" marks locally generated placeholder messages
359
+ # (interrupts, API errors), not a real model.
360
+ if m and m != "<synthetic>":
348
361
  model = m
349
362
 
363
+ if project_from_cwd and cwd:
364
+ project = Path(cwd.rstrip("/")).name or project
365
+
350
366
  # --- Build ParsedMessage list (legacy path, with tool details) -------
351
367
  messages: list[ParsedMessage] = []
352
368
  pending_tool_details: list[ToolCallDetail] = []
@@ -422,6 +438,7 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
422
438
  git_branch=git_branch,
423
439
  model=model,
424
440
  title=title,
441
+ provider_id=provider_id,
425
442
  )
426
443
 
427
444
  return ParsedSession(
@@ -435,6 +452,7 @@ def parse_session_file(file_path: Path) -> ParsedSession | None:
435
452
  agent_version=agent_version,
436
453
  model=model,
437
454
  title=title,
455
+ provider_id=provider_id,
438
456
  trace=trace,
439
457
  )
440
458
 
@@ -467,6 +485,7 @@ def _build_trace(
467
485
  git_branch: str | None,
468
486
  model: str | None,
469
487
  title: str | None,
488
+ provider_id: str = "jsonl-session",
470
489
  ) -> Trace:
471
490
  """Build a Trace with span tree from session records.
472
491
 
@@ -479,7 +498,7 @@ def _build_trace(
479
498
  └─ tool ...
480
499
  """
481
500
  tb = TraceBuilder(
482
- provider_id="jsonl-session",
501
+ provider_id=provider_id,
483
502
  session_id=session_id,
484
503
  project=project,
485
504
  cwd=cwd,
@@ -48,6 +48,7 @@ _GEMINI_ZERO_MINOR = re.compile(r"^(gemini-\d+)-0(-)")
48
48
  # "auto"), substitute the provider's documented default before pricing.
49
49
  # Source: each provider's docs, picked as the most-common-actual-model.
50
50
  PROVIDER_DEFAULT_MODEL: dict[str, str] = {
51
+ "claude-code": "claude-sonnet-4-5",
51
52
  "copilot": "gpt-4o",
52
53
  "cursor": "gpt-4o",
53
54
  "windsurf": "gpt-4o",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from spooling.providers.base import Provider, RemoteProvider, PROVIDER_REGISTRY
4
4
  from spooling.providers.session_file import SessionFileProvider
5
+ from spooling.providers.claude_code import ClaudeCodeProvider
5
6
  from spooling.providers.codex import CodexProvider
6
7
  from spooling.providers.cursor import CursorProvider
7
8
  from spooling.providers.copilot import CopilotProvider
@@ -19,6 +20,7 @@ __all__ = [
19
20
  "RemoteProvider",
20
21
  "PROVIDER_REGISTRY",
21
22
  "SessionFileProvider",
23
+ "ClaudeCodeProvider",
22
24
  "CodexProvider",
23
25
  "CursorProvider",
24
26
  "CopilotProvider",
@@ -0,0 +1,65 @@
1
+ """Claude Code provider: reads session transcripts from ``~/.claude/projects``.
2
+
3
+ Claude Code writes one JSONL transcript per session at
4
+ ``<config dir>/projects/<encoded-cwd>/<session-uuid>.jsonl``, where the encoded
5
+ cwd is the working directory with ``/`` replaced by ``-``. Each line is a
6
+ ``user`` / ``assistant`` record (plus bookkeeping records the parser ignores)
7
+ carrying per-message token ``usage`` including prompt-cache reads and writes,
8
+ which is what makes cost tracking accurate.
9
+
10
+ The config directory is ``$CLAUDE_CONFIG_DIR`` when set, otherwise ``~/.claude``
11
+ (falling back to ``~/.config/claude`` where newer installs put it). A different
12
+ location can be pinned in ``~/.config/spooling/paths.json`` under the key
13
+ ``"claude-code"``.
14
+
15
+ Not yet included: subagent transcripts, which recent Claude Code versions write
16
+ to separate files under ``<session-uuid>/subagents/``. Their spend is not part of
17
+ the parent session's totals.
18
+ """
19
+
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+
24
+ from spooling.parser import ParsedSession, parse_session_file
25
+ from spooling.providers.base import Provider
26
+
27
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
28
+
29
+
30
+ def _claude_config_dirs() -> list[Path]:
31
+ env = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
32
+ if env:
33
+ return [Path(env).expanduser()]
34
+ return [Path.home() / ".claude", Path.home() / ".config" / "claude"]
35
+
36
+
37
+ class ClaudeCodeProvider(Provider):
38
+ type_id = "claude-code"
39
+ name = "Claude Code"
40
+
41
+ def default_data_path(self) -> Path:
42
+ candidates = [d / "projects" for d in _claude_config_dirs()]
43
+ for c in candidates:
44
+ if c.exists():
45
+ return c
46
+ return candidates[0]
47
+
48
+ def discover_session_files(self, data_path: Path | None = None) -> list[Path]:
49
+ base = data_path or self.resolved_data_path()
50
+ if not base.exists():
51
+ return []
52
+ files: list[Path] = []
53
+ for project_dir in base.iterdir():
54
+ if not project_dir.is_dir():
55
+ continue
56
+ # Top level only and UUID-named: skips subagent transcripts and
57
+ # non-session JSONL such as plugin skill-injection logs.
58
+ for f in project_dir.glob("*.jsonl"):
59
+ if _UUID_RE.match(f.stem):
60
+ files.append(f)
61
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
62
+
63
+ def parse_session_file(self, file_path: Path) -> list[ParsedSession]:
64
+ session = parse_session_file(file_path, provider_id=self.type_id, project_from_cwd=True)
65
+ return [session] if session else []
@@ -108,6 +108,13 @@ async def api_search(
108
108
  # --- Providers / Connections ---
109
109
 
110
110
  PROVIDER_TEMPLATES = {
111
+ "claude-code": {
112
+ "name": "Claude Code",
113
+ "icon": "claude",
114
+ "default_path": "~/.claude/projects",
115
+ "description": "Anthropic's Claude Code CLI. One JSONL transcript per session, with per-message token and prompt-cache usage.",
116
+ "status_hint": "Auto-detected from ~/.claude/projects/",
117
+ },
111
118
  "jsonl-session": {
112
119
  "name": "Session Files",
113
120
  "icon": "file",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  Local session tracker and semantic search for AI coding assistants.
40
40
 
41
- Track your AI coding sessions across **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
41
+ Track your AI coding sessions across **Claude Code**, **OpenAI Codex CLI**, **GitHub Copilot**, **Cursor**, **Windsurf**, **Kiro**, **Google Antigravity**, and **opencode**, all in one place. Get usage stats, cost estimates, per-provider breakdowns, semantic search, and a built-in AI chat agent to explore your history.
42
42
 
43
43
  **Website:** [spooling.ai](https://spooling.ai)
44
44
 
@@ -142,6 +142,7 @@ spooling sync # Full sync with embeddings
142
142
  spooling sync --no-embed # Skip embeddings (faster initial sync)
143
143
  spooling sync -p cursor # Sync only Cursor sessions
144
144
  spooling sync -p codex # Sync only Codex CLI sessions
145
+ spooling sync -p claude-code # Sync only Claude Code sessions
145
146
  ```
146
147
 
147
148
  ### `spooling stats`
@@ -639,6 +640,7 @@ All optional — defaults work out of the box.
639
640
 
640
641
  | Provider | Data Location | Format |
641
642
  |----------|--------------|--------|
643
+ | **Claude Code** | `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) | `<project>/<session-uuid>.jsonl` transcripts with per-message token usage, prompt-cache reads/writes, tool calls and git context |
642
644
  | **JSONL Sessions** | `~/.sessions/projects/` | UUID-named JSONL files with conversation history, tool calls, git context |
643
645
  | **OpenAI Codex CLI** | `~/.codex/sessions/` | `rollout-*.jsonl` files organized by date |
644
646
  | **GitHub Copilot** | `~/Library/Application Support/Code/User/workspaceStorage/` | Chat session JSON from VS Code |
@@ -650,5 +652,7 @@ All optional — defaults work out of the box.
650
652
 
651
653
  Run `spooling init` to see which providers are detected on your system.
652
654
 
655
+ **Claude Code notes.** Sessions are detected automatically; there is nothing to connect. Cost is computed from each message's real token usage (including prompt-cache reads and writes) at the model's list price, so it reflects API-equivalent spend, not what a subscription plan charges. The project name is the session's working directory. To read transcripts from somewhere else, set `CLAUDE_CONFIG_DIR` or add `{"claude-code": "/path/to/projects"}` to `~/.config/spooling/paths.json`. Subagent transcripts (stored in separate files under `<session>/subagents/` by recent Claude Code versions) are not included yet.
656
+
653
657
  Nothing leaves your machine unless you opt in: Spooling Cloud (`spooling push`), a tunnel (`spooling tunnel`), or the Anthropic chat provider. `spooling pricing refresh` downloads public model prices but sends none of your data.
654
658
 
@@ -51,6 +51,7 @@ spooling/connectors/rest/shopify_connectors.py
51
51
  spooling/providers/__init__.py
52
52
  spooling/providers/antigravity.py
53
53
  spooling/providers/base.py
54
+ spooling/providers/claude_code.py
54
55
  spooling/providers/codex.py
55
56
  spooling/providers/copilot.py
56
57
  spooling/providers/cortex_code.py
@@ -62,6 +63,8 @@ spooling/providers/kiro.py
62
63
  spooling/providers/opencode.py
63
64
  spooling/providers/session_file.py
64
65
  spooling/providers/windsurf.py
66
+ tests/test_claude_code_provider.py
67
+ tests/test_cloud_errors.py
65
68
  tests/test_mcp_auth_middleware.py
66
69
  tests/test_session_ids.py
67
70
  tests/test_storage_optimizations.py