deepsieve-cli 0.2.0__tar.gz → 0.3.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.
@@ -132,3 +132,10 @@ page-*.yml
132
132
 
133
133
  # Antigravity (Gemini) agent local config — per-machine, like .claude/settings.local.json
134
134
  .agents/
135
+
136
+ # --- Review-seat scratch (`.review-<ticket>/`) ---
137
+ # Reviewer briefs and seat reports written into a worktree for a review round.
138
+ # `.review-1539/` reached `development` in #1561 through a `git add -A` — the
139
+ # same mechanism as the node_modules note above, a second time. Reports belong
140
+ # in the session scratchpad; a copy in the tree is only ever a transient.
141
+ .review-*/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepsieve-cli
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -26,6 +26,7 @@ revoked there at any time.
26
26
  deepsieve whoami # identity, workspace, scopes
27
27
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
28
28
  deepsieve runs create --query "..." --wait # real run: spends credits
29
+ deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
29
30
  deepsieve runs list
30
31
  deepsieve runs get <id> # exit 4 while still running
31
32
  deepsieve runs cancel <id>
@@ -18,6 +18,7 @@ revoked there at any time.
18
18
  deepsieve whoami # identity, workspace, scopes
19
19
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
20
20
  deepsieve runs create --query "..." --wait # real run: spends credits
21
+ deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
21
22
  deepsieve runs list
22
23
  deepsieve runs get <id> # exit 4 while still running
23
24
  deepsieve runs cancel <id>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -250,6 +250,11 @@ def cmd_runs_create(args) -> int:
250
250
  body["query"] = args.query
251
251
  if args.seeds:
252
252
  body["seeds"] = [s.strip() for s in args.seeds.split(",") if s.strip()]
253
+ allowed = [s.strip() for s in (getattr(args, "allowed_source", None) or []) if s.strip()]
254
+ if allowed:
255
+ # #1802 — repeatable; the server resolves and refuses, the CLI only carries.
256
+ # Only blanks given → no list at all, never an empty one (the server 400s on []).
257
+ body["allowed_sources"] = allowed
253
258
 
254
259
  # A real run spends money. Confirm interactively unless told not to — and
255
260
  # never silently in a pipe, where nobody is watching.
@@ -451,21 +456,120 @@ def _remote_server_name(prof) -> str:
451
456
  return "deepsieve"
452
457
 
453
458
 
459
+ def _remote_oauth_enabled(prof) -> bool | None:
460
+ """Whether this deployment serves remote MCP browser-OAuth.
461
+
462
+ Probes the RFC 9728 discovery document, which ``api/routes/mcp.py`` serves
463
+ with a 200 only when the deployment's ``oauth_enabled()`` (``authkit_domain``
464
+ + ``mcp_base_url``) is true, and a 404 (``MCP OAuth not configured``)
465
+ otherwise. Unauthenticated, so it works before anyone has signed in — the
466
+ situation this command exists for.
467
+
468
+ Three states, not two — the distinction is load-bearing (#1798):
469
+
470
+ - ``True`` — a 200: OAuth is live, emit the HTTP/browser registration.
471
+ - ``False`` — the documented **404** (``MCP OAuth not configured``): the one
472
+ definitive "OAuth is off" signal → fall back to the local stdio server.
473
+ - ``None`` — could not determine: a network error, OR any other non-2xx the
474
+ app itself never emits (a 5xx/504 from the ALB, a 429, a WAF 403 — a 503
475
+ deploy blip is the live example). We must NOT read those as "OAuth off":
476
+ that would relocate the found-nothing/could-not-look collapse this ticket
477
+ fixes to the 5xx boundary and render a transient error as the categorical
478
+ "no browser login". The caller keeps the HTTP default on ``None`` (a
479
+ normally-reachable origin usually has whatever it has) but says the answer
480
+ is unverified; only a definitive ``False`` flips to stdio.
481
+ """
482
+ try:
483
+ Client(prof.origin).request("GET", "/.well-known/oauth-protected-resource")
484
+ return True
485
+ except ApiError as exc:
486
+ # Only the 404 is definitive. `http_404` is what `client.request` derives
487
+ # for the route's `{"detail": ...}` body (no `error` envelope → the
488
+ # status-code default). Everything else is "could not determine".
489
+ return False if exc.code == "http_404" else None
490
+
491
+
454
492
  def cmd_setup_mcp(args) -> int:
455
- """Print (or install) the MCP registration for this profile's origin."""
493
+ """Print (or install) the MCP registration for this profile's origin.
494
+
495
+ Emits the transport the deployment actually supports: remote HTTP + browser
496
+ OAuth when it is configured, else the local stdio server keyed off
497
+ ``DEEPSIEVE_API_KEY``. Both the human and ``--json`` output derive that
498
+ choice from the SAME live probe (`_remote_oauth_enabled`), so the two can
499
+ never advertise different transports — #1798, where the served ``setup.md``
500
+ and this command both hardcoded HTTP and dead-ended every agent on a
501
+ deployment without OAuth.
502
+ """
456
503
  _, prof = _client(args)
457
- url = f"{prof.origin}/mcp"
458
504
  # Ask the deployment what it calls itself rather than assuming "deepsieve":
459
505
  # hardcoding it here would print a staging registration that overwrites the
460
506
  # production entry — the exact collision the env-aware naming fixed.
461
507
  name = args.name or _remote_server_name(prof)
508
+
509
+ oauth = _remote_oauth_enabled(prof)
510
+ # Three probe outcomes get three distinct renderings — "could not look" must
511
+ # never be byte-identical to "confirmed OAuth" (#1798). The `oauth_discovery`
512
+ # marker rides every --json payload; the human output states an unverified
513
+ # default in words. `unconfirmed` covers both an unreachable origin and any
514
+ # non-404 status (e.g. a 5xx) — neither is a definitive "OAuth off". Only a
515
+ # definitive `False` (the 404) flips us off the HTTP path.
516
+ discovery = {True: "confirmed", False: "absent", None: "unconfirmed"}[oauth]
517
+ # The stdio registration line — the primary path when OAuth is absent, and
518
+ # the fallback hint when discovery was unreachable. One definition, so the
519
+ # two spots cannot drift.
520
+ stdio_add = f"claude mcp add {name} --env DEEPSIEVE_API_URL={prof.origin} -- uvx deepsieve-mcp"
521
+
522
+ if oauth is False:
523
+ # No remote OAuth on this deployment → local stdio server. The key is
524
+ # never inlined: the server inherits DEEPSIEVE_API_KEY from the
525
+ # environment, the rule setup.md enforces (a key in a config file gets
526
+ # committed/logged).
527
+ cfg = {
528
+ "command": "uvx",
529
+ "args": ["deepsieve-mcp"],
530
+ "env": {"DEEPSIEVE_API_URL": prof.origin},
531
+ }
532
+ if args.json:
533
+ out({"mcpServers": {name: cfg}, "oauth_discovery": discovery}, True)
534
+ return EXIT_OK
535
+ print(f"This deployment has no browser login — register the local {name} MCP server.\n")
536
+ print(
537
+ f"Set DEEPSIEVE_API_KEY in your environment (an Agent key from "
538
+ f"{prof.origin}/settings/api-keys), then:\n"
539
+ )
540
+ print(f" {stdio_add}\n")
541
+ print(
542
+ "Or add to your agent's MCP config (the server inherits DEEPSIEVE_API_KEY "
543
+ "from the environment — do not inline the key):\n"
544
+ )
545
+ print(json.dumps({"mcpServers": {name: cfg}}, indent=2))
546
+ print(f"\nFull setup, including skills and rules: {prof.origin}/setup.md")
547
+ return EXIT_OK
548
+
549
+ # OAuth confirmed (200) OR unreachable (None): both register the HTTP
550
+ # endpoint, but the unreachable case says so in words — an unverified default
551
+ # dressed as a verified one walks the agent into the exact dead Authenticate
552
+ # button this ticket exists to remove.
553
+ url = f"{prof.origin}/mcp"
462
554
  if args.json:
463
- out({"mcpServers": {name: {"url": url}}}, True)
555
+ out({"mcpServers": {name: {"url": url}}, "oauth_discovery": discovery}, True)
464
556
  return EXIT_OK
557
+ if oauth is None:
558
+ print(
559
+ f"Could not confirm {prof.origin} offers browser login (no response, or an "
560
+ "unexpected status); defaulting to the HTTP endpoint.\n"
561
+ )
465
562
  print(f"Register the {name} MCP server (browser login, no key needed):\n")
466
563
  print(f" claude mcp add --transport http {name} {url}\n")
467
564
  print("Or add to your agent's MCP config:\n")
468
565
  print(json.dumps({"mcpServers": {name: {"url": url}}}, indent=2))
566
+ if oauth is None:
567
+ print(
568
+ "\nIf Authenticate does nothing, this deployment has no remote OAuth — "
569
+ "re-run when it is reachable, or use the local stdio server:\n"
570
+ f" {stdio_add}\n"
571
+ "(the stdio server needs DEEPSIEVE_API_KEY in the environment)."
572
+ )
469
573
  print(f"\nFull setup, including skills and rules: {prof.origin}/setup.md")
470
574
  return EXIT_OK
471
575
 
@@ -531,6 +635,10 @@ def build_parser() -> argparse.ArgumentParser:
531
635
  rc = runs.add_parser("create", help="start a run", parents=[common])
532
636
  rc.add_argument("--query")
533
637
  rc.add_argument("--seeds", help="comma-separated names or URLs")
638
+ rc.add_argument("--allowed-source", action="append", metavar="PATTERN",
639
+ help="restrict evidence to this source (repeatable): a host, *.gov, "
640
+ "https://host/path/*, or blueprint:primary; a cell cited only "
641
+ "elsewhere is not written")
534
642
  rc.add_argument("--depth", default="standard", choices=["standard", "max"])
535
643
  rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
536
644
  rc.add_argument("--no-monitor", dest="monitored", action="store_false",
@@ -386,7 +386,14 @@ def test_setup_mcp_reads_the_name_from_the_deployment(monkeypatch, capsys):
386
386
  cfg.save_profile(cfg.Profile(name="staging", origin="https://staging.test", api_key="k"))
387
387
 
388
388
  def handler(request: httpx.Request) -> httpx.Response:
389
- assert request.url.path == "/setup.md", "must not need an authenticated endpoint"
389
+ # Both endpoints the command touches (the name lookup and the OAuth
390
+ # discovery probe) are unauthenticated; neither may be /api/system/status.
391
+ assert request.url.path in (
392
+ "/setup.md",
393
+ "/.well-known/oauth-protected-resource",
394
+ ), "must not need an authenticated endpoint"
395
+ if request.url.path == "/.well-known/oauth-protected-resource":
396
+ return httpx.Response(200, json={"resource": "https://staging.test/mcp"})
390
397
  return httpx.Response(
391
398
  200,
392
399
  text=(
@@ -420,6 +427,140 @@ def test_setup_mcp_falls_back_to_the_brand_not_the_hostname(monkeypatch, capsys)
420
427
  assert " staging " not in printed, f"leaked a hostname label as the name: {printed}"
421
428
 
422
429
 
430
+ # ── #1798: the transport must match what the deployment actually serves ───────
431
+ #
432
+ # setup.md and `deepsieve setup mcp` used to hardcode the remote HTTP + browser
433
+ # OAuth path. On a deployment where OAuth is not configured, the discovery doc
434
+ # 404s and no /mcp transport is mounted, so an agent following the snippet
435
+ # dead-ends at an inoperative Authenticate button. Both tests assert the PROBE
436
+ # FIRED and the BRANCH TAKEN — a snippet that renders correctly while the probe
437
+ # never ran is the exact failure we are fixing.
438
+
439
+
440
+ def _setup_mcp_handler(seen, *, oauth_status):
441
+ """A handler that records paths and answers the OAuth discovery probe with
442
+ `oauth_status`. /setup.md carries the canonical name line."""
443
+
444
+ def handler(request: httpx.Request) -> httpx.Response:
445
+ seen.append(request.url.path)
446
+ if request.url.path == "/.well-known/oauth-protected-resource":
447
+ if oauth_status == 200:
448
+ return httpx.Response(200, json={"resource": "https://x.test/mcp"})
449
+ return httpx.Response(oauth_status, json={"detail": "MCP OAuth not configured"})
450
+ if request.url.path == "/setup.md":
451
+ return httpx.Response(
452
+ 200,
453
+ text="```bash\nclaude mcp add --transport http deepsieve https://x.test/mcp\n```\n",
454
+ )
455
+ return httpx.Response(404, json={"detail": "Not Found"})
456
+
457
+ return handler
458
+
459
+
460
+ def test_setup_mcp_emits_http_when_oauth_is_configured(monkeypatch, capsys):
461
+ """A 200 on the discovery doc → the browser-OAuth HTTP registration."""
462
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="ds_live_SECRET"))
463
+ seen: list[str] = []
464
+ monkeypatch.setattr(httpx, "Client", _mock(_setup_mcp_handler(seen, oauth_status=200)))
465
+
466
+ assert main(["setup", "mcp", "--json"]) == 0
467
+ out = capsys.readouterr().out
468
+ body = json.loads(out)
469
+
470
+ assert "/.well-known/oauth-protected-resource" in seen, "OAuth probe never fired"
471
+ server = body["mcpServers"]["deepsieve"]
472
+ assert server == {"url": "https://x.test/mcp"}, server
473
+ assert "uvx" not in out and "command" not in out, "took the stdio branch on a 200"
474
+ assert body["oauth_discovery"] == "confirmed", body
475
+
476
+
477
+ def test_setup_mcp_falls_back_to_stdio_when_oauth_unconfigured(monkeypatch, capsys):
478
+ """A 404 on the discovery doc → the local stdio server, key from the env.
479
+
480
+ Reds against the pre-fix command, which emitted the HTTP `/mcp` URL
481
+ unconditionally regardless of what the deployment serves.
482
+ """
483
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="ds_live_SECRET"))
484
+ seen: list[str] = []
485
+ monkeypatch.setattr(httpx, "Client", _mock(_setup_mcp_handler(seen, oauth_status=404)))
486
+
487
+ assert main(["setup", "mcp", "--json"]) == 0
488
+ out = capsys.readouterr().out
489
+ body = json.loads(out)
490
+
491
+ assert "/.well-known/oauth-protected-resource" in seen, "OAuth probe never fired"
492
+ server = body["mcpServers"]["deepsieve"]
493
+ assert server["command"] == "uvx", server
494
+ assert server["args"] == ["deepsieve-mcp"], server
495
+ assert server["env"]["DEEPSIEVE_API_URL"] == "https://x.test", server
496
+ assert "url" not in server, "emitted the HTTP transport on a 404"
497
+ assert "ds_live_SECRET" not in out, "inlined the API key into the config snippet"
498
+ assert body["oauth_discovery"] == "absent", body
499
+
500
+
501
+ def test_setup_mcp_marks_http_as_unverified_when_discovery_unreachable(monkeypatch, capsys):
502
+ """Unreachable discovery keeps the HTTP default (a network blip must not read
503
+ as "OAuth off") but MUST say so — "could not look" and a confirmed 200 must
504
+ never render identically (#1798). Asserts the uncertainty marker is present,
505
+ not merely that the HTTP snippet renders.
506
+ """
507
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="ds_live_SECRET"))
508
+ seen: list[str] = []
509
+
510
+ def handler(request: httpx.Request) -> httpx.Response:
511
+ seen.append(request.url.path)
512
+ raise httpx.ConnectError("boom") # the whole deployment is unreachable
513
+
514
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
515
+
516
+ # --json: distinct marker, HTTP kept as the fallback (not stdio)
517
+ assert main(["setup", "mcp", "--json"]) == 0
518
+ body = json.loads(capsys.readouterr().out)
519
+ assert "/.well-known/oauth-protected-resource" in seen, "OAuth probe never fired"
520
+ assert body["mcpServers"]["deepsieve"] == {"url": "https://x.test/mcp"}, body
521
+ assert body["oauth_discovery"] == "unconfirmed", body
522
+
523
+ # human: HTTP snippet AND an explicit uncertainty line — not identical to a 200
524
+ assert main(["setup", "mcp"]) == 0
525
+ human = capsys.readouterr().out
526
+ assert "--transport http" in human, human
527
+ assert "Could not confirm" in human, "unverified default rendered as if confirmed"
528
+
529
+
530
+ def test_setup_mcp_5xx_is_unconfirmed_not_absent(monkeypatch, capsys):
531
+ """A 5xx (e.g. a 503 deploy blip) is "could not determine", NOT "OAuth off".
532
+ Only the documented 404 is the definitive off-signal. A 5xx must render as
533
+ `unconfirmed`/HTTP, never as the categorical stdio "no browser login" — that
534
+ would relocate the found-nothing/could-not-look collapse to the 5xx boundary
535
+ (Seat A finding). Reds against a version that mapped every non-2xx to stdio.
536
+ """
537
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="ds_live_SECRET"))
538
+ seen: list[str] = []
539
+ monkeypatch.setattr(httpx, "Client", _mock(_setup_mcp_handler(seen, oauth_status=503)))
540
+
541
+ assert main(["setup", "mcp", "--json"]) == 0
542
+ body = json.loads(capsys.readouterr().out)
543
+ assert "/.well-known/oauth-protected-resource" in seen, "OAuth probe never fired"
544
+ assert body["oauth_discovery"] == "unconfirmed", body
545
+ server = body["mcpServers"]["deepsieve"]
546
+ assert server == {"url": "https://x.test/mcp"}, "a 5xx wrongly routed to stdio"
547
+
548
+
549
+ def test_setup_mcp_human_output_matches_the_probe(monkeypatch, capsys):
550
+ """The human (non --json) output derives from the SAME probe, so the two
551
+ output modes can never advertise different transports (#1798 anti-drift)."""
552
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="ds_live_SECRET"))
553
+ seen: list[str] = []
554
+ monkeypatch.setattr(httpx, "Client", _mock(_setup_mcp_handler(seen, oauth_status=404)))
555
+
556
+ assert main(["setup", "mcp"]) == 0
557
+ out = capsys.readouterr().out
558
+ assert "/.well-known/oauth-protected-resource" in seen, "OAuth probe never fired"
559
+ assert "uvx deepsieve-mcp" in out, out
560
+ assert "--transport http" not in out, "advertised HTTP in human output on a 404"
561
+ assert "ds_live_SECRET" not in out, "printed the API key"
562
+
563
+
423
564
  def test_failed_run_exits_failure_not_success(monkeypatch):
424
565
  """README's table says 1 = failure; `runs get` consulted only `done`."""
425
566
  import httpx
@@ -505,3 +646,24 @@ def test_members_list_surfaces_a_scope_refusal_as_a_typed_error(monkeypatch, cap
505
646
  captured = capsys.readouterr()
506
647
  assert "insufficient_scope" in captured.err or "members:read" in captured.err
507
648
  assert "owner@example.com" not in captured.out
649
+
650
+
651
+ def test_run_create_forwards_each_allowed_source(monkeypatch):
652
+ """#1802 — `--allowed-source` is repeatable and reaches the body as a list;
653
+ absent, the key is absent (the server applies the workspace default)."""
654
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
655
+ seen = {}
656
+
657
+ def handler(request: httpx.Request) -> httpx.Response:
658
+ seen["body"] = json.loads(request.content)
659
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
660
+
661
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
662
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run",
663
+ "--allowed-source", "*.gov", "--allowed-source", " dot.state.al.us "]) == 0
664
+ assert seen["body"]["allowed_sources"] == ["*.gov", "dot.state.al.us"]
665
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run"]) == 0
666
+ assert "allowed_sources" not in seen["body"]
667
+ # Only blanks → no key at all, never `[]` (the server refuses an empty list).
668
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run", "--allowed-source", " "]) == 0
669
+ assert "allowed_sources" not in seen["body"]