deepsieve-cli 0.1.0__tar.gz → 0.2.0__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.
@@ -17,7 +17,20 @@
17
17
  .aws/
18
18
 
19
19
  # --- Node / Next.js (site) ---
20
- site/node_modules/
20
+ # Unanchored on purpose: `site/node_modules/` alone left a ROOT-level
21
+ # node_modules/ tracked, and running vitest from the repo root (rather than from
22
+ # site/) creates one — `node_modules/.vite/vitest/**/results.json` reached
23
+ # `development` in #320 through a `git add -A`, passing review and CI without
24
+ # anything failing. A rule that covers the path someone already hit is not a rule.
25
+ # ⚠️ NO TRAILING SLASH, and that is load-bearing too. `node_modules/` matches
26
+ # DIRECTORIES ONLY, so a SYMLINKED node_modules is not ignored and `git add -A`
27
+ # stages the link itself — an absolute path that resolves for nobody else (gh
28
+ # #756). Symlinking it is the documented way to share one 733 MB install across
29
+ # git worktrees on a full disk, so the ignore rule and the disk guidance
30
+ # disagreed silently. Verified by controlled test: with `node_modules/`,
31
+ # `git add -A` reports `A node_modules`; without the slash it reports nothing.
32
+ node_modules
33
+ site/node_modules
21
34
  site/.next/
22
35
  site/.next-*/
23
36
  site/out/
@@ -113,4 +126,9 @@ api/_stress/
113
126
  # Stray Playwright accessibility-snapshot dumps from browser-driven QA.
114
127
  ctx*-run.md
115
128
  page-*.yml
116
- user-testing/
129
+
130
+ # Antigravity worktrees and state
131
+ .gemini/
132
+
133
+ # Antigravity (Gemini) agent local config — per-machine, like .claude/settings.local.json
134
+ .agents/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: deepsieve-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: DeepSieve CLI — run cited deep research from your terminal
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -29,6 +29,7 @@ deepsieve runs create --query "..." --wait # real run: spends credits
29
29
  deepsieve runs list
30
30
  deepsieve runs get <id> # exit 4 while still running
31
31
  deepsieve runs cancel <id>
32
+ deepsieve members list # who is in the workspace + seat usage
32
33
  deepsieve data catalog # entities + columns (never guess)
33
34
  deepsieve data get companies --receipts # rows with per-cell citations
34
35
  deepsieve setup mcp # MCP registration for this origin
@@ -21,6 +21,7 @@ deepsieve runs create --query "..." --wait # real run: spends credits
21
21
  deepsieve runs list
22
22
  deepsieve runs get <id> # exit 4 while still running
23
23
  deepsieve runs cancel <id>
24
+ deepsieve members list # who is in the workspace + seat usage
24
25
  deepsieve data catalog # entities + columns (never guess)
25
26
  deepsieve data get companies --receipts # rows with per-cell citations
26
27
  deepsieve setup mcp # MCP registration for this origin
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -238,8 +238,11 @@ def cmd_runs_get(args) -> int:
238
238
 
239
239
  def cmd_runs_create(args) -> int:
240
240
  client, prof = _require_auth(args)
241
- if bool(args.query) == bool(args.seeds):
242
- err("pass exactly one of --query or --seeds")
241
+ # At most one. NEITHER is a paid broad sweep of the whole Blueprint schema
242
+ # (#1005), which the API accepts; the CLI used to refuse it as a usage
243
+ # error and could not express the feature at all.
244
+ if args.query and args.seeds:
245
+ err("pass at most one of --query or --seeds")
243
246
  return EXIT_USAGE
244
247
 
245
248
  body: dict[str, Any] = {"depth": args.depth, "dry_run": args.dry_run, "monitored": args.monitored}
@@ -322,10 +325,70 @@ def cmd_runs_cancel(args) -> int:
322
325
  return EXIT_OK
323
326
 
324
327
 
328
+ def cmd_members_list(args) -> int:
329
+ """Who is in this workspace and how many editor seats are in use (#985).
330
+ Read-only; inviting is a human-admin action in the UI."""
331
+ client, _ = _require_auth(args)
332
+ try:
333
+ body = client.request("GET", "/v1/members")
334
+ except ApiError as exc:
335
+ err(str(exc))
336
+ return EXIT_FAIL
337
+ if args.json:
338
+ out(body, True)
339
+ return EXIT_OK
340
+ for m in body.get("data", []):
341
+ print(f"{m.get('email')} {m.get('role')} ({m.get('status')})")
342
+ seats = body.get("seats") or {}
343
+ inc = seats.get("editor_seats_included")
344
+ used = seats.get("editor_seats_used")
345
+ print(f"\neditor seats: {used} used / {'unlimited' if inc is None else inc} included")
346
+ return EXIT_OK
347
+
348
+
349
+ def _blueprint_id(client, args) -> str | None:
350
+ """The Blueprint these commands address: ``--blueprint``, else the active one.
351
+
352
+ `/v1/data` was removed (pre-GA breaking change): every dataset read now names
353
+ its Blueprint in the path, because a read against the wrong one returns a
354
+ well-formed dataset about the wrong domain and nothing in the response says
355
+ so. The CLI keeps its zero-argument form by resolving the active Blueprint
356
+ here — one extra request, and only when no id was given.
357
+
358
+ Returns None after printing the reason, so callers exit non-zero rather than
359
+ falling back to some other Blueprint.
360
+ """
361
+ if getattr(args, "blueprint", None):
362
+ return args.blueprint
363
+ try:
364
+ body = client.request("GET", "/v1/blueprints")
365
+ except ApiError as exc:
366
+ err(str(exc))
367
+ return None
368
+ rows = body.get("data") or []
369
+ active = next((r for r in rows if r.get("is_active")), None)
370
+ if active is None:
371
+ # Say which of the two states this is. "No Blueprints at all" and "none
372
+ # of them is active" need different actions from the user, and an
373
+ # unqualified "not found" would send them looking for the wrong problem.
374
+ if not rows:
375
+ err("no Blueprints yet — design one in the app first.")
376
+ else:
377
+ err(
378
+ "no active Blueprint. Pass --blueprint <id>; "
379
+ "`deepsieve data catalog --blueprint <id>` lists ids via /v1/blueprints."
380
+ )
381
+ return None
382
+ return active.get("id")
383
+
384
+
325
385
  def cmd_data_catalog(args) -> int:
326
386
  client, _ = _require_auth(args)
387
+ bp_id = _blueprint_id(client, args)
388
+ if bp_id is None:
389
+ return EXIT_FAIL
327
390
  try:
328
- body = client.request("GET", "/v1/data")
391
+ body = client.request("GET", f"/v1/blueprints/{bp_id}")
329
392
  except ApiError as exc:
330
393
  err(str(exc))
331
394
  return EXIT_FAIL
@@ -340,13 +403,18 @@ def cmd_data_catalog(args) -> int:
340
403
 
341
404
  def cmd_data_get(args) -> int:
342
405
  client, _ = _require_auth(args)
406
+ bp_id = _blueprint_id(client, args)
407
+ if bp_id is None:
408
+ return EXIT_FAIL
343
409
  params: dict[str, Any] = {"limit": args.limit, "receipts": args.receipts}
344
410
  if args.cursor:
345
411
  params["cursor"] = args.cursor
346
412
  if args.updated_since:
347
413
  params["updated_since"] = args.updated_since
348
414
  try:
349
- body = client.request("GET", f"/v1/data/{args.entity}", params=params)
415
+ body = client.request(
416
+ "GET", f"/v1/blueprints/{bp_id}/entities/{args.entity}", params=params
417
+ )
350
418
  except ApiError as exc:
351
419
  err(str(exc))
352
420
  return EXIT_FAIL
@@ -484,9 +552,14 @@ def build_parser() -> argparse.ArgumentParser:
484
552
  rx.add_argument("run_id")
485
553
  rx.set_defaults(func=cmd_runs_cancel)
486
554
 
555
+ members = sub.add_parser("members", help="your workspace's team", parents=[common]).add_subparsers(dest="sub", required=True)
556
+ ml = members.add_parser("list", help="who is in the workspace, and seat usage", parents=[common])
557
+ ml.set_defaults(func=cmd_members_list)
558
+
487
559
  data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
488
560
 
489
561
  dc = data.add_parser("catalog", help="entities and columns (never guess these)", parents=[common])
562
+ dc.add_argument("--blueprint", help="Blueprint id (default: the active one)")
490
563
  dc.set_defaults(func=cmd_data_catalog)
491
564
 
492
565
  dg = data.add_parser("get", help="rows for one entity", parents=[common])
@@ -495,6 +568,7 @@ def build_parser() -> argparse.ArgumentParser:
495
568
  dg.add_argument("--cursor")
496
569
  dg.add_argument("--updated-since")
497
570
  dg.add_argument("--receipts", action="store_true", help="include per-cell citations")
571
+ dg.add_argument("--blueprint", help="Blueprint id (default: the active one)")
498
572
  dg.set_defaults(func=cmd_data_get)
499
573
 
500
574
  setup = sub.add_parser("setup", help="connect other tools", parents=[common]).add_subparsers(
@@ -207,16 +207,41 @@ def test_dry_run_needs_no_confirmation(monkeypatch):
207
207
  assert seen["idem"], "runs cost money — every create must carry an idempotency key"
208
208
 
209
209
 
210
- def test_run_create_requires_exactly_one_of_query_or_seeds(monkeypatch, capsys):
210
+ def test_run_create_refuses_both_query_and_seeds(monkeypatch, capsys):
211
211
  cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
212
- assert main(["runs", "create"]) == 2
213
- assert "exactly one" in capsys.readouterr().err
212
+ assert main(["runs", "create", "--query", "x", "--seeds", "a,b"]) == 2
213
+ assert "at most one" in capsys.readouterr().err
214
+
215
+
216
+ def test_run_create_with_neither_sends_an_empty_broad_sweep(monkeypatch):
217
+ """#1005: neither flag is a schema-derived broad sweep, not a usage error.
218
+ The body must carry NEITHER key — the API treats their absence as the
219
+ sweep, and a `query: ""` would be a validation error instead."""
220
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
221
+ seen = {}
222
+
223
+ def handler(request: httpx.Request) -> httpx.Response:
224
+ seen["body"] = json.loads(request.content)
225
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
226
+
227
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
228
+ assert main(["runs", "create", "--dry-run"]) == 0
229
+ assert "query" not in seen["body"] and "seeds" not in seen["body"]
230
+ assert seen["body"]["dry_run"] is True
231
+
232
+
233
+ # A workspace with one Blueprint, active. `data` commands resolve through this
234
+ # listing since #1282 removed the ambient /v1/data — so a handler that does not
235
+ # serve it is a handler the data commands cannot get past.
236
+ _ONE_ACTIVE_BP = {"data": [{"id": "bp_1", "name": "Finance", "is_active": True}]}
214
237
 
215
238
 
216
239
  def test_truncated_dataset_is_called_out(monkeypatch, capsys):
217
240
  cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
218
241
 
219
- def handler(_r):
242
+ def handler(r):
243
+ if r.url.path == "/v1/blueprints":
244
+ return httpx.Response(200, json=_ONE_ACTIVE_BP)
220
245
  return httpx.Response(200, json={"data": [], "truncated": True, "preview_row_cap": 10})
221
246
 
222
247
  monkeypatch.setattr(httpx, "Client", _mock(handler))
@@ -224,6 +249,59 @@ def test_truncated_dataset_is_called_out(monkeypatch, capsys):
224
249
  assert "PREVIEW" in capsys.readouterr().err
225
250
 
226
251
 
252
+ def test_data_get_addresses_the_active_blueprint_by_id(monkeypatch):
253
+ """The path must NAME a Blueprint. #1282 removed the ambient `/v1/data/{key}`,
254
+ so a CLI still calling it would 404 against any current deployment."""
255
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
256
+ seen = {}
257
+
258
+ def handler(r):
259
+ if r.url.path == "/v1/blueprints":
260
+ return httpx.Response(200, json=_ONE_ACTIVE_BP)
261
+ seen["path"] = r.url.path
262
+ return httpx.Response(200, json={"data": []})
263
+
264
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
265
+ assert main(["data", "get", "companies"]) == 0
266
+ assert seen["path"] == "/v1/blueprints/bp_1/entities/companies"
267
+
268
+
269
+ def test_blueprint_flag_names_one_without_consulting_the_listing(monkeypatch):
270
+ """`--blueprint` is the whole point of the flag: it must not be second-guessed
271
+ against the active one, and it must not cost a listing call."""
272
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
273
+ paths = []
274
+
275
+ def handler(r):
276
+ paths.append(r.url.path)
277
+ return httpx.Response(200, json={"data": []})
278
+
279
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
280
+ assert main(["data", "get", "companies", "--blueprint", "bp_other"]) == 0
281
+ assert paths == ["/v1/blueprints/bp_other/entities/companies"]
282
+
283
+
284
+ def test_no_active_blueprint_fails_rather_than_picking_one(monkeypatch, capsys):
285
+ """⚠️ The failure mode this helper exists to prevent.
286
+
287
+ With several Blueprints and none active, reading "whichever" returns a
288
+ well-formed dataset about the wrong domain — indistinguishable from the
289
+ right answer. So it exits NON-ZERO and names the flag, rather than falling
290
+ back to the first row.
291
+ """
292
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
293
+ listed = {"data": [{"id": "a", "is_active": False}, {"id": "b", "is_active": False}]}
294
+
295
+ def handler(r):
296
+ if r.url.path == "/v1/blueprints":
297
+ return httpx.Response(200, json=listed)
298
+ raise AssertionError(f"must not read data without a Blueprint: {r.url.path}")
299
+
300
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
301
+ assert main(["data", "get", "companies"]) != 0
302
+ assert "--blueprint" in capsys.readouterr().err
303
+
304
+
227
305
  def test_setup_mcp_names_this_profiles_origin(monkeypatch, capsys):
228
306
  cfg.save_profile(cfg.Profile(name="staging", origin="https://staging.test", api_key="k"))
229
307
  assert main(["--profile", "staging", "setup", "mcp"]) == 0
@@ -354,3 +432,76 @@ def test_failed_run_exits_failure_not_success(monkeypatch):
354
432
 
355
433
  monkeypatch.setattr(httpx, "Client", _mock(handler))
356
434
  assert main(["runs", "get", "r"]) == 1
435
+
436
+
437
+ # ── members list (#985) ──────────────────────────────────────────────────────
438
+
439
+
440
+ def _members_body(*, included=10, viewers=True):
441
+ return {
442
+ "object": "list",
443
+ "data": [
444
+ {"object": "member", "id": "m1", "user_id": "u1", "email": "owner@example.com",
445
+ "role": "owner", "status": "active", "workspace_ids": [], "created_at": None},
446
+ {"object": "member", "id": "m2", "user_id": "u2", "email": "reader@example.com",
447
+ "role": "viewer", "status": "active", "workspace_ids": ["ws1"], "created_at": None},
448
+ ],
449
+ "has_more": False,
450
+ "next_cursor": None,
451
+ "seats": {"editor_seats_used": 1, "editor_seats_included": included,
452
+ "editor_seats_free_now": None if included is None else included - 1,
453
+ "viewer_seats_available": viewers},
454
+ }
455
+
456
+
457
+ def _signed_in(monkeypatch):
458
+ monkeypatch.setenv(cfg.ENV_KEY, "ds_live_test")
459
+ monkeypatch.setenv(cfg.ENV_ORIGIN, "https://ci.test")
460
+
461
+
462
+ def test_members_list_prints_the_roster_and_seat_usage(monkeypatch, capsys):
463
+ """Review of #1022 found `members list` shipped with no test at all. Drives
464
+ the real command through the real client against a mocked /v1/members."""
465
+ _signed_in(monkeypatch)
466
+ seen = {}
467
+
468
+ def handler(request: httpx.Request) -> httpx.Response:
469
+ seen["path"] = request.url.path
470
+ seen["auth"] = request.headers.get("authorization")
471
+ return httpx.Response(200, json=_members_body())
472
+
473
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
474
+ assert main(["members", "list"]) == 0
475
+ out_ = capsys.readouterr().out
476
+ assert seen["path"] == "/v1/members"
477
+ assert seen["auth"] == "Bearer ds_live_test"
478
+ assert "owner@example.com owner (active)" in out_
479
+ assert "reader@example.com viewer (active)" in out_
480
+ assert "editor seats: 1 used / 10 included" in out_
481
+
482
+
483
+ def test_members_list_json_is_the_raw_body_and_unlimited_reads_as_such(monkeypatch, capsys):
484
+ _signed_in(monkeypatch)
485
+ body = _members_body(included=None) # Enterprise: null = unlimited
486
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
487
+ assert main(["members", "list", "--json"]) == 0
488
+ assert json.loads(capsys.readouterr().out) == body
489
+ # and the human form renders null as "unlimited", never "None". Client is
490
+ # still patched from above — patching again would wrap the patched factory
491
+ # and pass `transport` twice (that exact TypeError shipped in a red commit).
492
+ assert main(["members", "list"]) == 0
493
+ text = capsys.readouterr().out
494
+ assert "unlimited included" in text and "None" not in text
495
+
496
+
497
+ def test_members_list_surfaces_a_scope_refusal_as_a_typed_error(monkeypatch, capsys):
498
+ """An `agent`-preset key lacks members:read; the CLI must show the API's
499
+ code + message and exit non-zero, not stack-trace or print an empty roster."""
500
+ _signed_in(monkeypatch)
501
+ err_body = {"error": {"type": "permission_error", "code": "insufficient_scope",
502
+ "message": "this key lacks members:read", "retriable": False}}
503
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(403, json=err_body)))
504
+ assert main(["members", "list"]) == 1
505
+ captured = capsys.readouterr()
506
+ assert "insufficient_scope" in captured.err or "members:read" in captured.err
507
+ assert "owner@example.com" not in captured.out