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.
- {spooling-0.2.13/spooling.egg-info → spooling-0.2.15}/PKG-INFO +44 -1
- {spooling-0.2.13 → spooling-0.2.15}/README.md +43 -0
- {spooling-0.2.13 → spooling-0.2.15}/pyproject.toml +1 -1
- {spooling-0.2.13 → spooling-0.2.15}/spooling/cloud.py +93 -1
- {spooling-0.2.13 → spooling-0.2.15/spooling.egg-info}/PKG-INFO +44 -1
- {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/SOURCES.txt +2 -0
- spooling-0.2.15/tests/test_cloud_teams.py +118 -0
- spooling-0.2.15/tests/test_docs.py +40 -0
- {spooling-0.2.13 → spooling-0.2.15}/LICENSE +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/setup.cfg +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/__init__.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/agent.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/classifiers.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/cli.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/config.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/__init__.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/base.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/bigquery.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/clickhouse.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/factory.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/mongodb.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/mysql.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/postgresql.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/__init__.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/base.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/rest/shopify_connectors.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/snowflake.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/connectors/types.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/db.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/embeddings.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/evals.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/experiments.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/ingest.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/insights.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/mcp_server.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/optimize.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/parser.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/pricing.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/__init__.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/antigravity.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/base.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/claude_code.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/codex.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/copilot.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/cortex_code.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/cursor.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/gemini.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/github.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/gitlab.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/kiro.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/opencode.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/session_file.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/providers/windsurf.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/redact.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/remote_otel.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/schema.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/sdk.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/search.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/server.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/sessions.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/stats.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/subscription_pricing.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/tracing.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/tunnel.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/tunnel_auth.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling/watcher.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/dependency_links.txt +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/entry_points.txt +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/requires.txt +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/spooling.egg-info/top_level.txt +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_agent_spans.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_claude_code_provider.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_cloud_errors.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_insights.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_mcp_auth_middleware.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_session_ids.py +0 -0
- {spooling-0.2.13 → spooling-0.2.15}/tests/test_storage_optimizations.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|