spooling 0.2.8__tar.gz → 0.2.9__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 (72) hide show
  1. {spooling-0.2.8/spooling.egg-info → spooling-0.2.9}/PKG-INFO +6 -2
  2. {spooling-0.2.8 → spooling-0.2.9}/README.md +5 -1
  3. {spooling-0.2.8 → spooling-0.2.9}/pyproject.toml +1 -1
  4. {spooling-0.2.8 → spooling-0.2.9}/spooling/cli.py +1 -1
  5. {spooling-0.2.8 → spooling-0.2.9}/spooling/mcp_server.py +1 -1
  6. {spooling-0.2.8 → spooling-0.2.9}/spooling/parser.py +23 -4
  7. {spooling-0.2.8 → spooling-0.2.9}/spooling/pricing.py +1 -0
  8. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/__init__.py +2 -0
  9. spooling-0.2.9/spooling/providers/claude_code.py +65 -0
  10. {spooling-0.2.8 → spooling-0.2.9}/spooling/server.py +7 -0
  11. {spooling-0.2.8 → spooling-0.2.9/spooling.egg-info}/PKG-INFO +6 -2
  12. {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/SOURCES.txt +2 -0
  13. spooling-0.2.9/tests/test_claude_code_provider.py +229 -0
  14. {spooling-0.2.8 → spooling-0.2.9}/LICENSE +0 -0
  15. {spooling-0.2.8 → spooling-0.2.9}/setup.cfg +0 -0
  16. {spooling-0.2.8 → spooling-0.2.9}/spooling/__init__.py +0 -0
  17. {spooling-0.2.8 → spooling-0.2.9}/spooling/agent.py +0 -0
  18. {spooling-0.2.8 → spooling-0.2.9}/spooling/classifiers.py +0 -0
  19. {spooling-0.2.8 → spooling-0.2.9}/spooling/cloud.py +0 -0
  20. {spooling-0.2.8 → spooling-0.2.9}/spooling/config.py +0 -0
  21. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/__init__.py +0 -0
  22. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/base.py +0 -0
  23. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/bigquery.py +0 -0
  24. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/clickhouse.py +0 -0
  25. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/factory.py +0 -0
  26. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/mongodb.py +0 -0
  27. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/mysql.py +0 -0
  28. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/postgresql.py +0 -0
  29. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/__init__.py +0 -0
  30. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/base.py +0 -0
  31. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/rest/shopify_connectors.py +0 -0
  32. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/snowflake.py +0 -0
  33. {spooling-0.2.8 → spooling-0.2.9}/spooling/connectors/types.py +0 -0
  34. {spooling-0.2.8 → spooling-0.2.9}/spooling/db.py +0 -0
  35. {spooling-0.2.8 → spooling-0.2.9}/spooling/embeddings.py +0 -0
  36. {spooling-0.2.8 → spooling-0.2.9}/spooling/evals.py +0 -0
  37. {spooling-0.2.8 → spooling-0.2.9}/spooling/experiments.py +0 -0
  38. {spooling-0.2.8 → spooling-0.2.9}/spooling/ingest.py +0 -0
  39. {spooling-0.2.8 → spooling-0.2.9}/spooling/optimize.py +0 -0
  40. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/antigravity.py +0 -0
  41. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/base.py +0 -0
  42. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/codex.py +0 -0
  43. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/copilot.py +0 -0
  44. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/cortex_code.py +0 -0
  45. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/cursor.py +0 -0
  46. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/gemini.py +0 -0
  47. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/github.py +0 -0
  48. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/gitlab.py +0 -0
  49. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/kiro.py +0 -0
  50. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/opencode.py +0 -0
  51. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/session_file.py +0 -0
  52. {spooling-0.2.8 → spooling-0.2.9}/spooling/providers/windsurf.py +0 -0
  53. {spooling-0.2.8 → spooling-0.2.9}/spooling/redact.py +0 -0
  54. {spooling-0.2.8 → spooling-0.2.9}/spooling/remote_otel.py +0 -0
  55. {spooling-0.2.8 → spooling-0.2.9}/spooling/schema.py +0 -0
  56. {spooling-0.2.8 → spooling-0.2.9}/spooling/sdk.py +0 -0
  57. {spooling-0.2.8 → spooling-0.2.9}/spooling/search.py +0 -0
  58. {spooling-0.2.8 → spooling-0.2.9}/spooling/sessions.py +0 -0
  59. {spooling-0.2.8 → spooling-0.2.9}/spooling/stats.py +0 -0
  60. {spooling-0.2.8 → spooling-0.2.9}/spooling/subscription_pricing.py +0 -0
  61. {spooling-0.2.8 → spooling-0.2.9}/spooling/tracing.py +0 -0
  62. {spooling-0.2.8 → spooling-0.2.9}/spooling/tunnel.py +0 -0
  63. {spooling-0.2.8 → spooling-0.2.9}/spooling/tunnel_auth.py +0 -0
  64. {spooling-0.2.8 → spooling-0.2.9}/spooling/watcher.py +0 -0
  65. {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/dependency_links.txt +0 -0
  66. {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/entry_points.txt +0 -0
  67. {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/requires.txt +0 -0
  68. {spooling-0.2.8 → spooling-0.2.9}/spooling.egg-info/top_level.txt +0 -0
  69. {spooling-0.2.8 → spooling-0.2.9}/tests/test_mcp_auth_middleware.py +0 -0
  70. {spooling-0.2.8 → spooling-0.2.9}/tests/test_session_ids.py +0 -0
  71. {spooling-0.2.8 → spooling-0.2.9}/tests/test_storage_optimizations.py +0 -0
  72. {spooling-0.2.8 → spooling-0.2.9}/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.9
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.9"
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
@@ -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.9
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,7 @@ 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
65
67
  tests/test_mcp_auth_middleware.py
66
68
  tests/test_session_ids.py
67
69
  tests/test_storage_optimizations.py
@@ -0,0 +1,229 @@
1
+ """Tests for the Claude Code provider (~/.claude/projects transcripts)."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from datetime import datetime, timezone
7
+
8
+ import pytest
9
+
10
+ from spooling import db as db_mod
11
+ from spooling import ingest
12
+ from spooling.parser import parse_session_file
13
+ from spooling.providers import PROVIDER_REGISTRY, get_provider
14
+ from spooling.providers import base as base_mod
15
+ from spooling.providers.claude_code import ClaudeCodeProvider
16
+
17
+ SID1 = "11111111-aaaa-4bbb-8ccc-000000000001"
18
+ SID2 = "22222222-aaaa-4bbb-8ccc-000000000002"
19
+ CWD = "/Users/dev/code/my-app"
20
+
21
+
22
+ def _rec(type_, uuid, parent, ts, **kw):
23
+ r = {
24
+ "type": type_, "uuid": uuid, "parentUuid": parent, "isSidechain": False,
25
+ "timestamp": ts, "cwd": CWD, "gitBranch": "main", "version": "2.1.0",
26
+ "sessionId": SID1,
27
+ }
28
+ r.update(kw)
29
+ return r
30
+
31
+
32
+ def _usage(i, o, cr=0, cw=0):
33
+ return {"input_tokens": i, "output_tokens": o,
34
+ "cache_read_input_tokens": cr, "cache_creation_input_tokens": cw}
35
+
36
+
37
+ def transcript(sid: str = SID1) -> list[dict]:
38
+ """A realistic session: placeholder msg first, then prompt, tool use, answer."""
39
+ recs = [
40
+ {"type": "permission-mode", "permissionMode": "default", "sessionId": SID1},
41
+ {"type": "file-history-snapshot", "messageId": "x", "snapshot": {}},
42
+ # Locally generated placeholder: model "<synthetic>" must not become the session model.
43
+ _rec("assistant", "a0", None, "2026-11-01T10:00:00Z",
44
+ message={"role": "assistant", "model": "<synthetic>",
45
+ "content": [{"type": "text", "text": "No response requested."}],
46
+ "usage": _usage(0, 0)}),
47
+ _rec("user", "u1", "a0", "2026-11-01T10:00:05Z",
48
+ message={"role": "user", "content": "Fix the login bug in auth.ts"}),
49
+ _rec("assistant", "a1", "u1", "2026-11-01T10:00:10Z",
50
+ message={"role": "assistant", "model": "claude-sonnet-4-5",
51
+ "content": [
52
+ {"type": "text", "text": "Let me look at the file."},
53
+ {"type": "tool_use", "id": "toolu_1", "name": "Read",
54
+ "input": {"file_path": "/Users/dev/code/my-app/auth.ts"}}],
55
+ "usage": _usage(100, 50, cr=2000, cw=500)}),
56
+ _rec("user", "u2", "a1", "2026-11-01T10:00:12Z",
57
+ message={"role": "user", "content": [
58
+ {"type": "tool_result", "tool_use_id": "toolu_1", "content": "export function login() {}"}]}),
59
+ _rec("assistant", "a2", "u2", "2026-11-01T10:00:20Z",
60
+ message={"role": "assistant", "model": "claude-sonnet-4-5",
61
+ "content": [{"type": "text", "text": "Fixed."}],
62
+ "usage": _usage(10, 20, cr=2600, cw=0)}),
63
+ {"type": "ai-title", "title": "Fix login", "sessionId": SID1},
64
+ ]
65
+ # Message uuids are globally unique in real transcripts; keep the fixture honest.
66
+ for r in recs:
67
+ for k in ("uuid", "parentUuid"):
68
+ if r.get(k):
69
+ r[k] = f"{sid[:8]}-{r[k]}"
70
+ if "sessionId" in r:
71
+ r["sessionId"] = sid
72
+ return recs
73
+
74
+
75
+ def write_session(root, project_dir, session_id, records, mtime=None):
76
+ d = root / "projects" / project_dir
77
+ d.mkdir(parents=True, exist_ok=True)
78
+ f = d / f"{session_id}.jsonl"
79
+ f.write_text("\n".join(json.dumps(r) for r in records) + "\n")
80
+ if mtime:
81
+ os.utime(f, (mtime, mtime))
82
+ return f
83
+
84
+
85
+ @pytest.fixture
86
+ def claude_home(tmp_path, monkeypatch):
87
+ home = tmp_path / "claude"
88
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home))
89
+ # No user path overrides leaking in from the dev machine.
90
+ monkeypatch.setattr(base_mod, "PATH_OVERRIDES_FILE", tmp_path / "paths.json")
91
+ return home
92
+
93
+
94
+ def test_registered():
95
+ assert PROVIDER_REGISTRY["claude-code"] is ClaudeCodeProvider
96
+ assert get_provider("claude-code").name == "Claude Code"
97
+
98
+
99
+ def test_default_path_follows_claude_config_dir(claude_home):
100
+ assert ClaudeCodeProvider().default_data_path() == claude_home / "projects"
101
+
102
+
103
+ def test_default_path_is_dot_claude_without_env(tmp_path, monkeypatch):
104
+ monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
105
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
106
+ assert ClaudeCodeProvider().default_data_path() == tmp_path / ".claude" / "projects"
107
+
108
+
109
+ def test_falls_back_to_xdg_location_when_dot_claude_missing(tmp_path, monkeypatch):
110
+ monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
111
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
112
+ (tmp_path / ".config" / "claude" / "projects").mkdir(parents=True)
113
+ assert ClaudeCodeProvider().default_data_path() == tmp_path / ".config" / "claude" / "projects"
114
+
115
+
116
+ def test_paths_json_override_wins(claude_home, tmp_path, monkeypatch):
117
+ custom = tmp_path / "elsewhere"
118
+ (tmp_path / "paths.json").write_text(json.dumps({"claude-code": str(custom)}))
119
+ assert ClaudeCodeProvider().resolved_data_path() == custom
120
+
121
+
122
+ def test_not_available_when_directory_missing(claude_home):
123
+ assert ClaudeCodeProvider().is_available() is False
124
+ assert ClaudeCodeProvider().discover_session_files() == []
125
+
126
+
127
+ def test_discovery_only_top_level_uuid_transcripts(claude_home):
128
+ old = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript(), mtime=time.time() - 1000)
129
+ new = write_session(claude_home, "-Users-dev-code-other", SID2, transcript(SID2))
130
+ proj = claude_home / "projects" / "-Users-dev-code-my-app"
131
+ # Things that must NOT be treated as sessions:
132
+ (proj / SID1 / "subagents").mkdir(parents=True)
133
+ (proj / SID1 / "subagents" / "agent-abc123.jsonl").write_text("{}\n")
134
+ (proj / "skill-injections.jsonl").write_text("{}\n") # non-uuid name
135
+ (claude_home / "projects" / "stray.jsonl").write_text("{}\n") # file directly in projects/
136
+
137
+ files = ClaudeCodeProvider().discover_session_files()
138
+ assert files == [new, old] # newest first, nothing else
139
+
140
+
141
+ def test_parse_stamps_provider_on_session_and_trace(claude_home):
142
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
143
+ [s] = ClaudeCodeProvider().parse_session_file(f)
144
+ assert s.provider_id == "claude-code"
145
+ assert s.trace is not None and s.trace.provider_id == "claude-code"
146
+ assert s.session_id == SID1
147
+
148
+
149
+ def test_project_is_working_directory_name(claude_home):
150
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
151
+ [s] = ClaudeCodeProvider().parse_session_file(f)
152
+ assert s.project == "my-app" # not "-Users-dev-code-my-app"
153
+ assert s.cwd == CWD and s.git_branch == "main" and s.agent_version == "2.1.0"
154
+
155
+
156
+ def test_synthetic_placeholder_is_not_the_session_model(claude_home):
157
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
158
+ [s] = ClaudeCodeProvider().parse_session_file(f)
159
+ assert s.model == "claude-sonnet-4-5"
160
+
161
+
162
+ def test_messages_tools_and_title(claude_home):
163
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
164
+ [s] = ClaudeCodeProvider().parse_session_file(f)
165
+ assert [m.role for m in s.messages] == ["assistant", "user", "assistant", "assistant"]
166
+ assert s.tool_call_count == 1
167
+ read = next(m for m in s.messages if m.tool_details).tool_details[0]
168
+ assert read.name == "Read" and read.input_summary.endswith("auth.ts")
169
+ assert read.result_preview == "export function login() {}" # paired from the tool_result
170
+ assert s.title == "Fix the login bug in auth.ts"
171
+ assert s.started_at == datetime(2026, 11, 1, 10, 0, 0, tzinfo=timezone.utc)
172
+
173
+
174
+ def test_token_usage_including_cache_flows_into_trace(claude_home):
175
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
176
+ [s] = ClaudeCodeProvider().parse_session_file(f)
177
+ llm = [sp for sp in s.trace.spans if getattr(sp.kind, "value", sp.kind) == "llm_call"]
178
+ assert len(llm) == 3
179
+ assert sum(x.input_tokens for x in llm) == 110
180
+ assert sum(x.output_tokens for x in llm) == 70
181
+ assert sum(x.cache_read_tokens for x in llm) == 4600
182
+ assert sum(x.cache_write_tokens for x in llm) == 500
183
+ # Cost uses all four components at claude-sonnet-4-5 rates (3 / 15 / 3.75 / 0.30 per Mtok).
184
+ real = [x for x in llm if x.model == "claude-sonnet-4-5"]
185
+ expected = (110 * 3 + 70 * 15 + 500 * 3.75 + 4600 * 0.30) / 1e6
186
+ assert sum(x.cost_usd for x in real) == pytest.approx(expected, rel=0.02)
187
+
188
+
189
+ def test_empty_or_garbage_files_yield_no_session(claude_home):
190
+ f = write_session(claude_home, "-p", SID2, [{"type": "permission-mode"}])
191
+ assert ClaudeCodeProvider().parse_session_file(f) == []
192
+ g = claude_home / "projects" / "-p" / f"{SID1}.jsonl"
193
+ g.write_text("not json\n\n{broken\n")
194
+ assert ClaudeCodeProvider().parse_session_file(g) == []
195
+
196
+
197
+ def test_generic_session_file_parser_is_unchanged(claude_home):
198
+ """The legacy jsonl-session path keeps its id and folder-name project."""
199
+ f = write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
200
+ s = parse_session_file(f)
201
+ assert s.provider_id == "jsonl-session" and s.trace.provider_id == "jsonl-session"
202
+ assert s.project == "-Users-dev-code-my-app"
203
+
204
+
205
+ def test_sync_ingests_and_is_idempotent(claude_home, tmp_path, monkeypatch):
206
+ monkeypatch.setattr(db_mod, "DB_PATH", tmp_path / "spooling.db")
207
+ write_session(claude_home, "-Users-dev-code-my-app", SID1, transcript())
208
+ write_session(claude_home, "-Users-dev-code-other", SID2, transcript(SID2))
209
+
210
+ ingest.sync(embed=False, provider_filter="claude-code")
211
+
212
+ conn = db_mod.get_connection()
213
+ rows = conn.execute("SELECT id, provider_id, project, model FROM sessions ORDER BY id").fetchall()
214
+ assert [(r["id"], r["provider_id"], r["project"], r["model"]) for r in rows] == [
215
+ (SID1, "claude-code", "my-app", "claude-sonnet-4-5"),
216
+ (SID2, "claude-code", "my-app", "claude-sonnet-4-5"),
217
+ ]
218
+ assert conn.execute("SELECT COUNT(*) AS n FROM messages").fetchone()["n"] == 8
219
+ assert [r["provider_id"] for r in conn.execute("SELECT DISTINCT provider_id FROM traces").fetchall()] == ["claude-code"]
220
+ spans = conn.execute("SELECT COUNT(*) AS n FROM spans WHERE kind = 'llm_call'").fetchone()["n"]
221
+ assert spans == 6
222
+ conn.close()
223
+
224
+ # Second run: nothing changed on disk, nothing re-ingested.
225
+ ingest.sync(embed=False, provider_filter="claude-code")
226
+ conn = db_mod.get_connection()
227
+ assert conn.execute("SELECT COUNT(*) AS n FROM sessions").fetchone()["n"] == 2
228
+ assert conn.execute("SELECT COUNT(*) AS n FROM spans WHERE kind = 'llm_call'").fetchone()["n"] == 6
229
+ conn.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes