spooling 0.2.13__tar.gz → 0.2.15__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 (78) hide show
  1. {spooling-0.2.13/spooling.egg-info → spooling-0.2.15}/PKG-INFO +44 -1
  2. {spooling-0.2.13 → spooling-0.2.15}/README.md +43 -0
  3. {spooling-0.2.13 → spooling-0.2.15}/pyproject.toml +1 -1
  4. {spooling-0.2.13 → spooling-0.2.15}/spooling/cloud.py +93 -1
  5. {spooling-0.2.13 → spooling-0.2.15/spooling.egg-info}/PKG-INFO +44 -1
  6. {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/SOURCES.txt +2 -0
  7. spooling-0.2.15/tests/test_cloud_teams.py +118 -0
  8. spooling-0.2.15/tests/test_docs.py +40 -0
  9. {spooling-0.2.13 → spooling-0.2.15}/LICENSE +0 -0
  10. {spooling-0.2.13 → spooling-0.2.15}/setup.cfg +0 -0
  11. {spooling-0.2.13 → spooling-0.2.15}/spooling/__init__.py +0 -0
  12. {spooling-0.2.13 → spooling-0.2.15}/spooling/agent.py +0 -0
  13. {spooling-0.2.13 → spooling-0.2.15}/spooling/classifiers.py +0 -0
  14. {spooling-0.2.13 → spooling-0.2.15}/spooling/cli.py +0 -0
  15. {spooling-0.2.13 → spooling-0.2.15}/spooling/config.py +0 -0
  16. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/__init__.py +0 -0
  17. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/base.py +0 -0
  18. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/bigquery.py +0 -0
  19. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/clickhouse.py +0 -0
  20. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/factory.py +0 -0
  21. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/mongodb.py +0 -0
  22. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/mysql.py +0 -0
  23. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/postgresql.py +0 -0
  24. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/__init__.py +0 -0
  25. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/base.py +0 -0
  26. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/shopify_connectors.py +0 -0
  27. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/snowflake.py +0 -0
  28. {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/types.py +0 -0
  29. {spooling-0.2.13 → spooling-0.2.15}/spooling/db.py +0 -0
  30. {spooling-0.2.13 → spooling-0.2.15}/spooling/embeddings.py +0 -0
  31. {spooling-0.2.13 → spooling-0.2.15}/spooling/evals.py +0 -0
  32. {spooling-0.2.13 → spooling-0.2.15}/spooling/experiments.py +0 -0
  33. {spooling-0.2.13 → spooling-0.2.15}/spooling/ingest.py +0 -0
  34. {spooling-0.2.13 → spooling-0.2.15}/spooling/insights.py +0 -0
  35. {spooling-0.2.13 → spooling-0.2.15}/spooling/mcp_server.py +0 -0
  36. {spooling-0.2.13 → spooling-0.2.15}/spooling/optimize.py +0 -0
  37. {spooling-0.2.13 → spooling-0.2.15}/spooling/parser.py +0 -0
  38. {spooling-0.2.13 → spooling-0.2.15}/spooling/pricing.py +0 -0
  39. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/__init__.py +0 -0
  40. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/antigravity.py +0 -0
  41. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/base.py +0 -0
  42. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/claude_code.py +0 -0
  43. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/codex.py +0 -0
  44. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/copilot.py +0 -0
  45. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/cortex_code.py +0 -0
  46. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/cursor.py +0 -0
  47. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/gemini.py +0 -0
  48. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/github.py +0 -0
  49. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/gitlab.py +0 -0
  50. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/kiro.py +0 -0
  51. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/opencode.py +0 -0
  52. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/session_file.py +0 -0
  53. {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/windsurf.py +0 -0
  54. {spooling-0.2.13 → spooling-0.2.15}/spooling/redact.py +0 -0
  55. {spooling-0.2.13 → spooling-0.2.15}/spooling/remote_otel.py +0 -0
  56. {spooling-0.2.13 → spooling-0.2.15}/spooling/schema.py +0 -0
  57. {spooling-0.2.13 → spooling-0.2.15}/spooling/sdk.py +0 -0
  58. {spooling-0.2.13 → spooling-0.2.15}/spooling/search.py +0 -0
  59. {spooling-0.2.13 → spooling-0.2.15}/spooling/server.py +0 -0
  60. {spooling-0.2.13 → spooling-0.2.15}/spooling/sessions.py +0 -0
  61. {spooling-0.2.13 → spooling-0.2.15}/spooling/stats.py +0 -0
  62. {spooling-0.2.13 → spooling-0.2.15}/spooling/subscription_pricing.py +0 -0
  63. {spooling-0.2.13 → spooling-0.2.15}/spooling/tracing.py +0 -0
  64. {spooling-0.2.13 → spooling-0.2.15}/spooling/tunnel.py +0 -0
  65. {spooling-0.2.13 → spooling-0.2.15}/spooling/tunnel_auth.py +0 -0
  66. {spooling-0.2.13 → spooling-0.2.15}/spooling/watcher.py +0 -0
  67. {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/dependency_links.txt +0 -0
  68. {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/entry_points.txt +0 -0
  69. {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/requires.txt +0 -0
  70. {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/top_level.txt +0 -0
  71. {spooling-0.2.13 → spooling-0.2.15}/tests/test_agent_spans.py +0 -0
  72. {spooling-0.2.13 → spooling-0.2.15}/tests/test_claude_code_provider.py +0 -0
  73. {spooling-0.2.13 → spooling-0.2.15}/tests/test_cloud_errors.py +0 -0
  74. {spooling-0.2.13 → spooling-0.2.15}/tests/test_insights.py +0 -0
  75. {spooling-0.2.13 → spooling-0.2.15}/tests/test_mcp_auth_middleware.py +0 -0
  76. {spooling-0.2.13 → spooling-0.2.15}/tests/test_session_ids.py +0 -0
  77. {spooling-0.2.13 → spooling-0.2.15}/tests/test_storage_optimizations.py +0 -0
  78. {spooling-0.2.13 → spooling-0.2.15}/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.13
3
+ Version: 0.2.15
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -415,6 +415,49 @@ spooling push --dry-run # show what would be pushed
415
415
 
416
416
  `push` ingests new provider sessions into the local database before uploading, so you don't need to run `spooling sync` first. The server upserts by session id, so re-running is safe.
417
417
 
418
+ ### Push only some sessions, or to a specific team
419
+
420
+ **Which team gets the sessions** is decided by the API key you are logged in with. Each key belongs to one workspace (personal or a team), and `spooling push` sends to that workspace. Mint a key for the team in the app, then log in with it:
421
+
422
+ ```bash
423
+ spooling cloud teams # every workspace (personal + teams) your account belongs to
424
+ spooling cloud whoami # the account and workspace this CLI pushes to
425
+ spooling cloud login --key sk_<team-key> # replaces the stored key; later pushes go to this team
426
+ ```
427
+
428
+ `spooling cloud teams` marks the workspace your current key pushes to with `<- this key`, and shows your role, the number of members and the plan for each. `spooling cloud status` also prints the workspace name now. `teams` and `whoami` take `--json` for scripting.
429
+
430
+ To push to a different team, log in with that team's key. Only one key is stored at a time, so check `spooling cloud whoami` before a push if you work with several teams. These commands need a server that supports `/v1/me`; against an older one they say so and `spooling cloud status` still works.
431
+
432
+ **Which sessions go up.** With no filters, `spooling push` sends the most recent `--limit` sessions from every project on the machine. Use these to send only some of them. Filters combine with AND:
433
+
434
+ | Option | Matches |
435
+ |---|---|
436
+ | `--project NAME` | Sessions whose project name is exactly `NAME` (find names with `spooling cost breakdown --by project`) |
437
+ | `--cwd TEXT` | Sessions whose working directory contains `TEXT` (case-insensitive) |
438
+ | `--title TEXT` | Sessions whose title contains `TEXT` (case-insensitive). Useful when an agent was launched from your home directory, so every session shares a generic working directory, but the project name is in the title |
439
+
440
+ ```bash
441
+ spooling cloud login --key sk_<team-key>
442
+ spooling push --cwd toebox --dry-run # preview what would be sent; nothing is uploaded
443
+ spooling push --cwd toebox # send only sessions from the toebox directory
444
+ spooling push --project my-app --limit 200
445
+ spooling push --title islet # sessions with "islet" in the title
446
+ ```
447
+
448
+ **Share the same sessions with more than one team.** Add `--copy` to write the sessions into the workspace you are logged into as fresh rows, even if they were already pushed to another workspace. Re-running is safe:
449
+
450
+ ```bash
451
+ spooling cloud login --key sk_<other-team-key>
452
+ spooling push --cwd toebox --copy
453
+ ```
454
+
455
+ **Include trace data.** Add `--with-spans` to also push each session's trace and spans (per-LLM-call usage, cost, tool calls, MCP calls and agent runs). The Traces page in the app, its agent, tool and MCP filters, and `spooling agents --cloud` / `spooling mcps --cloud` all rely on it. `--with-spans` can't be combined with `--copy` yet.
456
+
457
+ **Secrets** (cloud and AWS keys, GitHub, OpenAI, Anthropic and Stripe tokens, PEM private keys, and `KEY=VALUE` lines whose key looks sensitive) are redacted on your machine before anything is uploaded. Pass `--no-redact` only if you are sure the content is clean.
458
+
459
+ `spooling cloud watch` has no filters: it keeps pushing every new or updated session to the workspace you are logged into. Use filtered `spooling push` runs when you need to keep some sessions out.
460
+
418
461
  To bring sessions from the cloud back down, use `spooling cloud pull`, or `spooling cloud sync` for both directions.
419
462
 
420
463
  ### Watch (continuous push)
@@ -379,6 +379,49 @@ spooling push --dry-run # show what would be pushed
379
379
 
380
380
  `push` ingests new provider sessions into the local database before uploading, so you don't need to run `spooling sync` first. The server upserts by session id, so re-running is safe.
381
381
 
382
+ ### Push only some sessions, or to a specific team
383
+
384
+ **Which team gets the sessions** is decided by the API key you are logged in with. Each key belongs to one workspace (personal or a team), and `spooling push` sends to that workspace. Mint a key for the team in the app, then log in with it:
385
+
386
+ ```bash
387
+ spooling cloud teams # every workspace (personal + teams) your account belongs to
388
+ spooling cloud whoami # the account and workspace this CLI pushes to
389
+ spooling cloud login --key sk_<team-key> # replaces the stored key; later pushes go to this team
390
+ ```
391
+
392
+ `spooling cloud teams` marks the workspace your current key pushes to with `<- this key`, and shows your role, the number of members and the plan for each. `spooling cloud status` also prints the workspace name now. `teams` and `whoami` take `--json` for scripting.
393
+
394
+ To push to a different team, log in with that team's key. Only one key is stored at a time, so check `spooling cloud whoami` before a push if you work with several teams. These commands need a server that supports `/v1/me`; against an older one they say so and `spooling cloud status` still works.
395
+
396
+ **Which sessions go up.** With no filters, `spooling push` sends the most recent `--limit` sessions from every project on the machine. Use these to send only some of them. Filters combine with AND:
397
+
398
+ | Option | Matches |
399
+ |---|---|
400
+ | `--project NAME` | Sessions whose project name is exactly `NAME` (find names with `spooling cost breakdown --by project`) |
401
+ | `--cwd TEXT` | Sessions whose working directory contains `TEXT` (case-insensitive) |
402
+ | `--title TEXT` | Sessions whose title contains `TEXT` (case-insensitive). Useful when an agent was launched from your home directory, so every session shares a generic working directory, but the project name is in the title |
403
+
404
+ ```bash
405
+ spooling cloud login --key sk_<team-key>
406
+ spooling push --cwd toebox --dry-run # preview what would be sent; nothing is uploaded
407
+ spooling push --cwd toebox # send only sessions from the toebox directory
408
+ spooling push --project my-app --limit 200
409
+ spooling push --title islet # sessions with "islet" in the title
410
+ ```
411
+
412
+ **Share the same sessions with more than one team.** Add `--copy` to write the sessions into the workspace you are logged into as fresh rows, even if they were already pushed to another workspace. Re-running is safe:
413
+
414
+ ```bash
415
+ spooling cloud login --key sk_<other-team-key>
416
+ spooling push --cwd toebox --copy
417
+ ```
418
+
419
+ **Include trace data.** Add `--with-spans` to also push each session's trace and spans (per-LLM-call usage, cost, tool calls, MCP calls and agent runs). The Traces page in the app, its agent, tool and MCP filters, and `spooling agents --cloud` / `spooling mcps --cloud` all rely on it. `--with-spans` can't be combined with `--copy` yet.
420
+
421
+ **Secrets** (cloud and AWS keys, GitHub, OpenAI, Anthropic and Stripe tokens, PEM private keys, and `KEY=VALUE` lines whose key looks sensitive) are redacted on your machine before anything is uploaded. Pass `--no-redact` only if you are sure the content is clean.
422
+
423
+ `spooling cloud watch` has no filters: it keeps pushing every new or updated session to the workspace you are logged into. Use filtered `spooling push` runs when you need to keep some sessions out.
424
+
382
425
  To bring sessions from the cloud back down, use `spooling cloud pull`, or `spooling cloud sync` for both directions.
383
426
 
384
427
  ### Watch (continuous push)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spooling"
3
- version = "0.2.13"
3
+ version = "0.2.15"
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." }]
@@ -15,6 +15,7 @@ import click
15
15
  import httpx
16
16
  from rich.console import Console
17
17
  from rich.markup import escape
18
+ from rich.table import Table
18
19
 
19
20
  from spooling.db import get_connection
20
21
  from spooling.redact import redact_messages, redact_traces
@@ -253,6 +254,10 @@ def cloud_status():
253
254
  r.raise_for_status()
254
255
  s = r.json()
255
256
  console.print(f"API: [cyan]{base}[/cyan]")
257
+ me = _fetch_me(required=False)
258
+ if me and me.get("workspace"):
259
+ ws = me["workspace"]
260
+ console.print(f" workspace: [bold]{escape(ws['name'])}[/bold] [dim]({_kind_label(ws)})[/dim]")
256
261
  console.print(f" sessions: [bold]{s.get('sessions', 0)}[/bold]")
257
262
  console.print(f" messages: [bold]{s.get('messages', 0)}[/bold]")
258
263
  console.print(f" providers: [bold]{s.get('providers', 0)}[/bold]")
@@ -263,6 +268,93 @@ def cloud_status():
263
268
  _fail(f"{_explain_error(e, base)}")
264
269
 
265
270
 
271
+ def _fetch_me(*, required: bool) -> dict | None:
272
+ """GET /v1/me: who this key is and which workspaces its user belongs to.
273
+
274
+ ``required=False`` is for best-effort extras (returns None on any problem);
275
+ ``required=True`` explains failures and exits non-zero.
276
+ """
277
+ headers = _auth_headers()
278
+ base = _api_base()
279
+ try:
280
+ r = httpx.get(f"{base}/v1/me", headers=headers, timeout=15)
281
+ r.raise_for_status()
282
+ return r.json()
283
+ except httpx.HTTPStatusError as e:
284
+ if not required:
285
+ return None
286
+ if e.response.status_code == 404:
287
+ _fail(
288
+ "This server is older than the CLI and can't list workspaces yet.\n"
289
+ " Ask your admin to redeploy it. Meanwhile `spooling cloud status`\n"
290
+ " shows the totals of the workspace your key belongs to."
291
+ )
292
+ _fail(_explain_http(e))
293
+ except Exception as e: # noqa: BLE001
294
+ if not required:
295
+ return None
296
+ _fail(_explain_error(e, base))
297
+
298
+
299
+ def _kind_label(w: dict) -> str:
300
+ return "Personal" if w.get("kind") == "personal" else "Team"
301
+
302
+
303
+ @cloud.command("whoami")
304
+ @click.option("--json", "as_json", is_flag=True, help="Print JSON instead of text")
305
+ def cloud_whoami(as_json: bool):
306
+ """Show which account and workspace (team) this CLI pushes to.
307
+
308
+ The workspace is decided by the API key you logged in with. Use
309
+ `spooling cloud teams` to see every workspace you belong to.
310
+ """
311
+ me = _fetch_me(required=True)
312
+ if as_json:
313
+ click.echo(json.dumps(me, indent=2))
314
+ return
315
+ user, ws = me["user"], me.get("workspace")
316
+ who = user.get("email") or user.get("name") or user.get("id")
317
+ console.print(f"Account: [bold]{escape(str(who))}[/bold]")
318
+ if ws:
319
+ role = f", you are {ws['role']}" if ws.get("role") else ""
320
+ members = f", {ws['members']} member{'s' if ws['members'] != 1 else ''}" if ws.get("members") is not None else ""
321
+ console.print(f"Workspace: [bold cyan]{escape(ws['name'])}[/bold cyan] [dim]({_kind_label(ws)}{role}{members}, {ws.get('plan', 'free')} plan)[/dim]")
322
+ console.print(f"API: [dim]{_api_base()}[/dim]")
323
+ console.print("[dim]`spooling push` sends sessions to this workspace. See all of yours with `spooling cloud teams`.[/dim]")
324
+
325
+
326
+ @cloud.command("teams")
327
+ @click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table")
328
+ def cloud_teams(as_json: bool):
329
+ """List every workspace (personal + teams) your account belongs to.
330
+
331
+ The row marked ``<- this key`` is where `spooling push` sends sessions.
332
+ To push somewhere else, log in with that team's API key:
333
+ `spooling cloud login --key sk_<team-key>`.
334
+ """
335
+ me = _fetch_me(required=True)
336
+ if as_json:
337
+ click.echo(json.dumps(me["workspaces"], indent=2))
338
+ return
339
+ rows = me["workspaces"]
340
+ table = Table(title="Your workspaces", show_lines=False)
341
+ table.add_column("Workspace", style="cyan")
342
+ table.add_column("Type")
343
+ table.add_column("Your role")
344
+ table.add_column("Members", justify="right")
345
+ table.add_column("Plan", style="dim")
346
+ table.add_column("")
347
+ for w in rows:
348
+ table.add_row(
349
+ escape(w["name"]), _kind_label(w), w.get("role") or "-",
350
+ str(w["members"]) if w.get("members") is not None else "-",
351
+ w.get("plan", "free"),
352
+ "[green]<- this key[/green]" if w.get("current") else "",
353
+ )
354
+ console.print(table if rows else "[yellow]No workspaces found for this account.[/yellow]")
355
+ console.print("[dim]A key pushes to one workspace. Switch with `spooling cloud login --key sk_<that team's key>`.[/dim]")
356
+
357
+
266
358
  @cloud.command("logout")
267
359
  def cloud_logout():
268
360
  """Remove the stored API key."""
@@ -627,7 +719,7 @@ _SYNC_OPTION = click.option(
627
719
  @click.option(
628
720
  "--project",
629
721
  default=None,
630
- help="Only push sessions whose project name matches exactly. Pair with `spooling stats --by project` to discover names.",
722
+ help="Only push sessions whose project name matches exactly. Pair with `spooling cost breakdown --by project` to discover names.",
631
723
  )
632
724
  @click.option(
633
725
  "--cwd",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spooling
3
- Version: 0.2.13
3
+ Version: 0.2.15
4
4
  Summary: Local session tracker and semantic search for AI coding assistants
5
5
  Author: Parsed Analytics, Inc.
6
6
  License: MIT
@@ -415,6 +415,49 @@ spooling push --dry-run # show what would be pushed
415
415
 
416
416
  `push` ingests new provider sessions into the local database before uploading, so you don't need to run `spooling sync` first. The server upserts by session id, so re-running is safe.
417
417
 
418
+ ### Push only some sessions, or to a specific team
419
+
420
+ **Which team gets the sessions** is decided by the API key you are logged in with. Each key belongs to one workspace (personal or a team), and `spooling push` sends to that workspace. Mint a key for the team in the app, then log in with it:
421
+
422
+ ```bash
423
+ spooling cloud teams # every workspace (personal + teams) your account belongs to
424
+ spooling cloud whoami # the account and workspace this CLI pushes to
425
+ spooling cloud login --key sk_<team-key> # replaces the stored key; later pushes go to this team
426
+ ```
427
+
428
+ `spooling cloud teams` marks the workspace your current key pushes to with `<- this key`, and shows your role, the number of members and the plan for each. `spooling cloud status` also prints the workspace name now. `teams` and `whoami` take `--json` for scripting.
429
+
430
+ To push to a different team, log in with that team's key. Only one key is stored at a time, so check `spooling cloud whoami` before a push if you work with several teams. These commands need a server that supports `/v1/me`; against an older one they say so and `spooling cloud status` still works.
431
+
432
+ **Which sessions go up.** With no filters, `spooling push` sends the most recent `--limit` sessions from every project on the machine. Use these to send only some of them. Filters combine with AND:
433
+
434
+ | Option | Matches |
435
+ |---|---|
436
+ | `--project NAME` | Sessions whose project name is exactly `NAME` (find names with `spooling cost breakdown --by project`) |
437
+ | `--cwd TEXT` | Sessions whose working directory contains `TEXT` (case-insensitive) |
438
+ | `--title TEXT` | Sessions whose title contains `TEXT` (case-insensitive). Useful when an agent was launched from your home directory, so every session shares a generic working directory, but the project name is in the title |
439
+
440
+ ```bash
441
+ spooling cloud login --key sk_<team-key>
442
+ spooling push --cwd toebox --dry-run # preview what would be sent; nothing is uploaded
443
+ spooling push --cwd toebox # send only sessions from the toebox directory
444
+ spooling push --project my-app --limit 200
445
+ spooling push --title islet # sessions with "islet" in the title
446
+ ```
447
+
448
+ **Share the same sessions with more than one team.** Add `--copy` to write the sessions into the workspace you are logged into as fresh rows, even if they were already pushed to another workspace. Re-running is safe:
449
+
450
+ ```bash
451
+ spooling cloud login --key sk_<other-team-key>
452
+ spooling push --cwd toebox --copy
453
+ ```
454
+
455
+ **Include trace data.** Add `--with-spans` to also push each session's trace and spans (per-LLM-call usage, cost, tool calls, MCP calls and agent runs). The Traces page in the app, its agent, tool and MCP filters, and `spooling agents --cloud` / `spooling mcps --cloud` all rely on it. `--with-spans` can't be combined with `--copy` yet.
456
+
457
+ **Secrets** (cloud and AWS keys, GitHub, OpenAI, Anthropic and Stripe tokens, PEM private keys, and `KEY=VALUE` lines whose key looks sensitive) are redacted on your machine before anything is uploaded. Pass `--no-redact` only if you are sure the content is clean.
458
+
459
+ `spooling cloud watch` has no filters: it keeps pushing every new or updated session to the workspace you are logged into. Use filtered `spooling push` runs when you need to keep some sessions out.
460
+
418
461
  To bring sessions from the cloud back down, use `spooling cloud pull`, or `spooling cloud sync` for both directions.
419
462
 
420
463
  ### Watch (continuous push)
@@ -67,6 +67,8 @@ spooling/providers/windsurf.py
67
67
  tests/test_agent_spans.py
68
68
  tests/test_claude_code_provider.py
69
69
  tests/test_cloud_errors.py
70
+ tests/test_cloud_teams.py
71
+ tests/test_docs.py
70
72
  tests/test_insights.py
71
73
  tests/test_mcp_auth_middleware.py
72
74
  tests/test_session_ids.py
@@ -0,0 +1,118 @@
1
+ """`spooling cloud whoami` / `cloud teams` (GET /v1/me), and the workspace line in `cloud status`."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from spooling import cloud
10
+ from spooling.cli import cli
11
+
12
+ ME = {
13
+ "user": {"id": "u1", "email": "dev@example.com", "name": "Dev"},
14
+ "workspace": {"id": "w1", "name": "Dev", "kind": "personal", "plan": "free", "role": "owner",
15
+ "members": 1, "current": True},
16
+ "workspaces": [
17
+ {"id": "w1", "name": "Dev", "kind": "personal", "plan": "free", "role": "owner", "members": 1, "current": True},
18
+ {"id": "w2", "name": "Toebox", "kind": "team", "plan": "team", "role": "admin", "members": 4, "current": False},
19
+ {"id": "w3", "name": "[red]Odd[/red] Name", "kind": "team", "plan": "free", "role": "member", "members": 2, "current": False},
20
+ ],
21
+ }
22
+ STATS = {"sessions": 12, "messages": 340, "providers": 2, "cost": 1.5}
23
+
24
+
25
+ def run(*args):
26
+ return CliRunner().invoke(cli, ["cloud", *args])
27
+
28
+
29
+ @pytest.fixture
30
+ def server(monkeypatch):
31
+ """Fake Spooling Cloud: path -> (status, json). Records the paths requested."""
32
+ routes = {"/v1/me": (200, ME), "/v1/stats": (200, STATS)}
33
+ seen = []
34
+ monkeypatch.setattr(cloud, "_auth_headers", lambda: {"Authorization": "Bearer sk_test"})
35
+ monkeypatch.setattr(cloud, "_api_base", lambda: "https://api.example")
36
+ monkeypatch.setattr(cloud, "_load_config", lambda: {"api_key": "sk_test"})
37
+
38
+ def fake_get(url, headers=None, params=None, timeout=None):
39
+ path = url.replace("https://api.example", "")
40
+ seen.append(path)
41
+ status, body = routes[path]
42
+ return httpx.Response(status, json=body, request=httpx.Request("GET", url))
43
+
44
+ monkeypatch.setattr(cloud.httpx, "get", fake_get)
45
+ return type("S", (), {"routes": routes, "seen": seen})
46
+
47
+
48
+ def test_whoami_shows_account_workspace_and_role(server):
49
+ r = run("whoami")
50
+ assert r.exit_code == 0
51
+ assert "dev@example.com" in r.output and "Dev" in r.output
52
+ assert "Personal" in r.output and "owner" in r.output and "1 member," in r.output
53
+ assert "spooling push" in r.output
54
+
55
+
56
+ def test_whoami_json(server):
57
+ assert json.loads(run("whoami", "--json").output) == ME
58
+
59
+
60
+ def test_teams_lists_all_workspaces_and_marks_the_current_one(server):
61
+ r = run("teams")
62
+ assert r.exit_code == 0
63
+ for name in ("Toebox", "Personal", "Team", "admin", "member"):
64
+ assert name in r.output
65
+ current_rows = [l for l in r.output.splitlines() if "<- this key" in l]
66
+ assert len(current_rows) == 1 and "Dev" in current_rows[0]
67
+
68
+
69
+ def test_teams_does_not_interpret_markup_in_names(server):
70
+ assert "[red]Odd[/red] Name" in run("teams").output
71
+
72
+
73
+ def test_teams_json_is_the_workspace_list(server):
74
+ data = json.loads(run("teams", "--json").output)
75
+ assert [w["name"] for w in data] == ["Dev", "Toebox", "[red]Odd[/red] Name"]
76
+ assert [w["current"] for w in data] == [True, False, False]
77
+
78
+
79
+ def test_older_server_without_v1_me_explains_and_exits_nonzero(server):
80
+ server.routes["/v1/me"] = (404, {"detail": "Not Found"})
81
+ for cmd in ("whoami", "teams"):
82
+ r = run(cmd)
83
+ assert r.exit_code == 1
84
+ assert "older than the CLI" in r.output and "cloud status" in r.output
85
+
86
+
87
+ def test_rejected_key_is_explained(server):
88
+ server.routes["/v1/me"] = (401, {"detail": "invalid or revoked key"})
89
+ r = run("whoami")
90
+ assert r.exit_code == 1 and "401" in r.output and "cloud login" in r.output
91
+
92
+
93
+ def test_unreachable_server_exits_nonzero(server, monkeypatch):
94
+ def boom(*a, **k):
95
+ raise httpx.ConnectError("connection refused")
96
+ monkeypatch.setattr(cloud.httpx, "get", boom)
97
+ r = run("teams")
98
+ assert r.exit_code == 1 and "log in" not in r.output.lower()
99
+
100
+
101
+ def test_status_shows_workspace_name_when_server_supports_it(server):
102
+ r = run("status")
103
+ assert r.exit_code == 0
104
+ assert "workspace" in r.output and "Dev" in r.output and "sessions" in r.output
105
+ assert "/v1/me" in server.seen
106
+
107
+
108
+ def test_status_still_works_against_an_older_server(server):
109
+ server.routes["/v1/me"] = (404, {"detail": "Not Found"})
110
+ r = run("status")
111
+ assert r.exit_code == 0
112
+ assert "sessions" in r.output and "workspace:" not in r.output
113
+
114
+
115
+ def test_status_ignores_a_failing_v1_me(server, monkeypatch):
116
+ server.routes["/v1/me"] = (500, {"detail": "boom"})
117
+ r = run("status")
118
+ assert r.exit_code == 0 and "sessions" in r.output
@@ -0,0 +1,40 @@
1
+ """Docs/help text must only point at options that exist."""
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from spooling.cli import cli
6
+
7
+
8
+ def _help(*args):
9
+ return CliRunner().invoke(cli, [*args, "--help"]).output
10
+
11
+
12
+ def test_push_help_points_at_a_real_command_for_project_names():
13
+ out = _help("push")
14
+ assert "stats --by" not in out
15
+ assert "cost breakdown --by project" in out
16
+ # and that command really has the option the help names
17
+ assert "--by" in _help("cost", "breakdown")
18
+
19
+
20
+ def test_push_documents_scoping_and_team_options():
21
+ out = _help("push")
22
+ for opt in ("--project", "--cwd", "--title", "--copy", "--with-spans", "--dry-run", "--no-redact"):
23
+ assert opt in out
24
+
25
+
26
+ def test_readme_documents_every_push_scoping_option():
27
+ from pathlib import Path
28
+ readme = (Path(__file__).resolve().parent.parent / "README.md").read_text()
29
+ for opt in ("--project", "--cwd", "--title", "--copy", "--with-spans", "--dry-run", "--no-redact", "cloud login --key sk_<team-key>"):
30
+ assert opt in readme, f"README does not mention {opt}"
31
+ assert "stats --by" not in readme
32
+
33
+
34
+ def test_readme_documents_the_team_commands():
35
+ from pathlib import Path
36
+ readme = (Path(__file__).resolve().parent.parent / "README.md").read_text()
37
+ for cmd in ("spooling cloud teams", "spooling cloud whoami"):
38
+ assert cmd in readme
39
+ out = CliRunner().invoke(cli, ["cloud", "--help"]).output
40
+ assert "teams" in out and "whoami" in out
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