deepsieve-cli 0.4.0__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepsieve-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -27,14 +27,14 @@ approval screen lists the extra scopes.
27
27
  ## Commands
28
28
 
29
29
  ```bash
30
- deepsieve whoami # identity, workspace, scopes
30
+ deepsieve whoami # identity, organization, active Blueprint, scopes
31
31
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
32
- deepsieve runs create --query "..." --wait # real run: spends credits
33
- deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
32
+ deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
33
+ deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
34
34
  deepsieve runs list
35
35
  deepsieve runs get <id> # exit 4 while still running
36
36
  deepsieve runs cancel <id>
37
- deepsieve members list # who is in the workspace + seat usage
37
+ deepsieve members list # who is in the organization + seat usage
38
38
  deepsieve changes list --live # merges, renames, successor links
39
39
  deepsieve changes undo <id> # asks first; --yes in scripts
40
40
  deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
@@ -19,14 +19,14 @@ approval screen lists the extra scopes.
19
19
  ## Commands
20
20
 
21
21
  ```bash
22
- deepsieve whoami # identity, workspace, scopes
22
+ deepsieve whoami # identity, organization, active Blueprint, scopes
23
23
  deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
24
- deepsieve runs create --query "..." --wait # real run: spends credits
25
- deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
24
+ deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
25
+ deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
26
26
  deepsieve runs list
27
27
  deepsieve runs get <id> # exit 4 while still running
28
28
  deepsieve runs cancel <id>
29
- deepsieve members list # who is in the workspace + seat usage
29
+ deepsieve members list # who is in the organization + seat usage
30
30
  deepsieve changes list --live # merges, renames, successor links
31
31
  deepsieve changes undo <id> # asks first; --yes in scripts
32
32
  deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -21,10 +21,19 @@ USER_AGENT = "deepsieve-cli"
21
21
 
22
22
 
23
23
  class ApiError(Exception):
24
- def __init__(self, code: str, message: str, *, request_id: str | None = None) -> None:
24
+ def __init__(
25
+ self,
26
+ code: str,
27
+ message: str,
28
+ *,
29
+ request_id: str | None = None,
30
+ details: dict | None = None,
31
+ ) -> None:
25
32
  self.code = code
26
33
  self.message = message
27
34
  self.request_id = request_id
35
+ #: Structured facts from the error envelope (`locked_until`), when the API sent them.
36
+ self.details = details or {}
28
37
  super().__init__(message)
29
38
 
30
39
  def __str__(self) -> str:
@@ -87,6 +96,7 @@ class Client:
87
96
  err.get("code", f"http_{r.status_code}"),
88
97
  err.get("message", f"HTTP {r.status_code} from {path}"),
89
98
  request_id=err.get("request_id"),
99
+ details=err.get("details"),
90
100
  )
91
101
  return body
92
102
 
@@ -28,6 +28,14 @@ from . import config
28
28
  from .client import ApiError, Client
29
29
 
30
30
  EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_AUTH, EXIT_PENDING = 0, 1, 2, 3, 4
31
+ #: gh #2307: the organization's model allowance is used for this period. Distinct from a
32
+ #: failure, so a script waits for the reset (or stops) instead of retrying.
33
+ EXIT_LIMIT = 5
34
+
35
+
36
+ def _api_exit(exc) -> int:
37
+ """The exit code for a refused API call."""
38
+ return EXIT_LIMIT if getattr(exc, "code", None) == "model_allowance_exhausted" else EXIT_FAIL
31
39
 
32
40
  CLIENT_NAME = "DeepSieve CLI"
33
41
 
@@ -36,9 +44,19 @@ CLIENT_NAME = "DeepSieve CLI"
36
44
 
37
45
 
38
46
  def _tty() -> bool:
47
+ """Whether OUTPUT goes to a terminal (formatting only)."""
39
48
  return sys.stdout.isatty()
40
49
 
41
50
 
51
+ def _interactive() -> bool:
52
+ """Whether a y/N question can really be put to a person: stdout AND stdin are terminals.
53
+
54
+ gh #2345 review: the gate checked stdout only, but every prompt reads stdin, so
55
+ `yes | deepsieve runs monitoring RID --on` from a terminal answered both questions and
56
+ committed 30 days of spend. A piped answer is a script, not a person saying yes."""
57
+ return _tty() and sys.stdin.isatty()
58
+
59
+
42
60
  def out(data: Any, as_json: bool, human: str | None = None) -> None:
43
61
  if as_json or human is None:
44
62
  print(json.dumps(data, indent=2, default=str))
@@ -256,6 +274,12 @@ def cmd_runs_create(args) -> int:
256
274
  if args.query and args.seeds:
257
275
  err("pass at most one of --query or --seeds")
258
276
  return EXIT_USAGE
277
+ # gh #1282 B1: a billable run must NAME its Blueprint (the API refuses one that does not);
278
+ # say so before anything else, with where the id comes from. A dry run is free and may omit it.
279
+ if not args.dry_run and not getattr(args, "blueprint", None):
280
+ err("pass --blueprint <id>: the Blueprint to research. `deepsieve whoami` shows your "
281
+ "active Blueprint's id (or add --dry-run for a free simulated run)")
282
+ return EXIT_USAGE
259
283
 
260
284
  body: dict[str, Any] = {"depth": args.depth, "dry_run": args.dry_run, "monitored": args.monitored}
261
285
  if args.query:
@@ -273,15 +297,14 @@ def cmd_runs_create(args) -> int:
273
297
  # A real run spends money. Confirm interactively unless told not to — and
274
298
  # never silently in a pipe, where nobody is watching.
275
299
  if not args.dry_run and not args.yes:
276
- if not _tty():
300
+ if not _interactive():
277
301
  err("refusing to start a billable run non-interactively; pass --yes (or --dry-run)")
278
302
  return EXIT_USAGE
279
303
  print(f"This starts a REAL research run on {prof.origin} — it spends credits "
280
304
  f"and takes 15-60 minutes.")
281
- # gh #1282: say which Blueprint BEFORE it spends; the 202 is too late.
282
- target = (f"Blueprint {args.blueprint}" if getattr(args, "blueprint", None)
283
- else "your active Blueprint (see `deepsieve whoami`; choose with --blueprint)")
284
- print(f"It runs on {target}.")
305
+ # gh #1282: say which Blueprint BEFORE it spends; the 202 is too late. (B1: a billable
306
+ # run always names one, checked above.)
307
+ print(f"It runs on Blueprint {args.blueprint}.")
285
308
  if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
286
309
  print("Cancelled.")
287
310
  return EXIT_OK
@@ -295,7 +318,8 @@ def cmd_runs_create(args) -> int:
295
318
  )
296
319
  except ApiError as exc:
297
320
  err(str(exc))
298
- return EXIT_FAIL
321
+ # A --query is parsed by a model: the org's model allowance can refuse it (gh #2307).
322
+ return _api_exit(exc)
299
323
 
300
324
  if args.json and not args.wait:
301
325
  out(created, True)
@@ -351,6 +375,103 @@ def cmd_runs_cancel(args) -> int:
351
375
  return EXIT_OK
352
376
 
353
377
 
378
+ def _last_billed_day(locked_until: str | None) -> str:
379
+ """`locked_until` is an EXCLUSIVE 00:00Z instant; the last billed UTC day is the day before."""
380
+ from datetime import datetime, timedelta # noqa: PLC0415
381
+
382
+ try:
383
+ return (datetime.fromisoformat(str(locked_until)) - timedelta(days=1)).date().isoformat()
384
+ except ValueError:
385
+ return str(locked_until)
386
+
387
+
388
+ def _yes(prompt: str) -> bool:
389
+ """A y/N answer; end of input is a no, never a traceback."""
390
+ try:
391
+ return input(prompt).strip().lower() in ("y", "yes")
392
+ except EOFError:
393
+ return False
394
+
395
+
396
+ def cmd_runs_monitoring(args) -> int:
397
+ """Turn a report's monitoring on or off (gh #2345). A UTC day bills if monitoring was on at
398
+ any moment of it: OFF stops billing from tomorrow, ON bills today. Both are confirmed like
399
+ an undo: interactively, or with --yes, never silently in a pipe.
400
+
401
+ Turning it back ON after the free 30 days commits 30 UTC days (gh #2359). The API refuses
402
+ that ON (`lock_confirmation_required`) until it is sent with `commit_lock`: here, a second
403
+ explicit question, or --commit-lock. --yes alone never commits 30 days. The yes is bound to
404
+ the date shown (`expected_locked_until`): if a UTC midnight passes before it is sent, the
405
+ API answers `lock_quote_changed` with the new date and nothing is committed."""
406
+ rid = _uuid_arg(args.run_id, "run_id")
407
+ if rid is None:
408
+ return EXIT_USAGE
409
+ client, _ = _require_auth(args)
410
+ if not args.yes:
411
+ if not _interactive():
412
+ err("refusing to change monitoring non-interactively; pass --yes")
413
+ return EXIT_USAGE
414
+ if args.active:
415
+ what = "Turn monitoring ON? Past its free 30 days this report bills each UTC day it is on"
416
+ what += ", and this commits 30 days." if args.commit_lock else "."
417
+ else:
418
+ what = "Turn monitoring OFF? It stops being kept fresh, and billing, from tomorrow."
419
+ if not _yes(f"{what} [y/N] "):
420
+ print("Cancelled.")
421
+ return EXIT_OK
422
+ path = f"/v1/research/runs/{rid}/monitoring"
423
+ payload: dict = {"active": args.active}
424
+ if args.active and args.commit_lock:
425
+ payload["commit_lock"] = True
426
+ if args.expected_locked_until:
427
+ payload["expected_locked_until"] = args.expected_locked_until
428
+ # At most two refusals: the first quote, and one re-quote if a UTC midnight passed while the
429
+ # person was deciding. A third would be the API contradicting itself; stop rather than loop.
430
+ for _attempt in range(3):
431
+ try:
432
+ body = client.request("PATCH", path, json_body=payload)
433
+ break
434
+ except ApiError as exc:
435
+ if exc.code not in ("lock_confirmation_required", "lock_quote_changed"):
436
+ err(str(exc))
437
+ return _api_exit(exc)
438
+ until = exc.details.get("locked_until")
439
+ last = _last_billed_day(until)
440
+ moved = "The UTC day changed, so the date moved. " if exc.code == "lock_quote_changed" else ""
441
+ ask = (
442
+ f"{moved}Turning this back on commits 30 days: it stays on, and bills each UTC "
443
+ f"day, through {last}."
444
+ )
445
+ if not _interactive():
446
+ if args.json:
447
+ print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
448
+ err(f"{ask} Pass --commit-lock --expected-locked-until {until} to commit to that.")
449
+ return EXIT_USAGE
450
+ if not _yes(f"{ask} Commit to 30 days? [y/N] "):
451
+ print("Cancelled. Monitoring is still off.")
452
+ return EXIT_OK
453
+ payload = {"active": True, "commit_lock": True, "expected_locked_until": until}
454
+ else:
455
+ err("the API kept refusing the commitment; nothing was changed")
456
+ return EXIT_FAIL
457
+ if body.get("active") and body.get("stop_scheduled_at"):
458
+ human = (
459
+ f"monitoring stays ON for {rid} through {_last_billed_day(body['stop_scheduled_at'])}, "
460
+ "the end of the 30 days committed to; it stops after that"
461
+ )
462
+ elif body.get("active"):
463
+ state = "billing daily" if body.get("billing") else (
464
+ f"free until {body['free_until']}" if body.get("free_until") else "not started yet"
465
+ )
466
+ if body.get("locked_until"):
467
+ state += f", committed through {_last_billed_day(body['locked_until'])}"
468
+ human = f"monitoring ON for {rid} ({state})"
469
+ else:
470
+ human = f"monitoring OFF for {rid}; it stops billing from tomorrow (today still counts)"
471
+ out(body, args.json, human)
472
+ return EXIT_OK
473
+
474
+
354
475
  def _uuid_arg(value: str, what: str) -> str | None:
355
476
  """An id interpolated into a URL path must be a UUID, never a path fragment."""
356
477
  import uuid as _uuid # noqa: PLC0415
@@ -426,7 +547,7 @@ def cmd_chat_revert(args) -> int:
426
547
 
427
548
 
428
549
  def cmd_members_list(args) -> int:
429
- """Who is in this workspace and how many editor seats are in use (#985).
550
+ """Who is in this organization and how many editor seats are in use (#985).
430
551
  Read-only; inviting is a human-admin action in the UI."""
431
552
  client, _ = _require_auth(args)
432
553
  try:
@@ -507,7 +628,7 @@ def cmd_changes_undo(args) -> int:
507
628
  err("change id must be a UUID (from `deepsieve changes list`)")
508
629
  return EXIT_USAGE
509
630
  if not args.yes:
510
- if not _tty():
631
+ if not _interactive():
511
632
  err("refusing to change data non-interactively; pass --yes")
512
633
  return EXIT_USAGE
513
634
  if input(f"Undo change {change_id}? This changes your dataset. [y/N] ").strip().lower() not in ("y", "yes"):
@@ -574,25 +695,25 @@ def cmd_holds_list(args) -> int:
574
695
 
575
696
 
576
697
  def _hold_verb(args, verb: str) -> int:
577
- """Apply or release one held cell. Both change what the dataset protects, so they are
578
- confirmed like an undo: interactively, or with --yes, never silently in a pipe."""
698
+ """Apply, keep or release one held cell. Each changes what the dataset protects, so they
699
+ are confirmed like an undo: interactively, or with --yes, never silently in a pipe."""
579
700
  client, _ = _require_auth(args)
580
701
  if not _CELL_ID.fullmatch(args.cell_id or ""):
581
702
  err("cell id must be one `deepsieve holds list` printed (it starts with cel_)")
582
703
  return EXIT_USAGE
583
704
  body: dict[str, Any] = {}
584
- if verb == "apply" and args.finding:
705
+ if verb in ("apply", "keep") and args.finding:
585
706
  try:
586
707
  body["finding_evidence_id"] = str(uuid.UUID(args.finding))
587
708
  except ValueError:
588
709
  err("--finding must be the finding's evidence id (a UUID)")
589
710
  return EXIT_USAGE
590
711
  if not args.yes:
591
- if not _tty():
712
+ if not _interactive():
592
713
  err("refusing to change data non-interactively; pass --yes")
593
714
  return EXIT_USAGE
594
- what = "Take research's newer value for" if verb == "apply" else "Stop protecting"
595
- if input(f"{what} this cell? [y/N] ").strip().lower() not in ("y", "yes"):
715
+ what = _HOLD_ASK[verb]
716
+ if input(f"{what}? [y/N] ").strip().lower() not in ("y", "yes"):
596
717
  print("Cancelled.")
597
718
  return EXIT_OK
598
719
  headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
@@ -603,15 +724,30 @@ def _hold_verb(args, verb: str) -> int:
603
724
  except ApiError as exc:
604
725
  err(str(exc))
605
726
  return EXIT_FAIL
606
- done = "applied; the cell stays protected" if verb == "apply" else "released; research may update it"
607
- out(result, args.json, f"{done}: {result.get('column')} = {result.get('value')!r}")
727
+ out(result, args.json, f"{_HOLD_DONE[verb]}: {result.get('column')} = {result.get('value')!r}")
608
728
  return EXIT_OK
609
729
 
610
730
 
731
+ _HOLD_ASK = {
732
+ "apply": "Take research's newer value for this cell",
733
+ "keep": "Keep your value and dismiss research's newer value for this cell",
734
+ "release": "Stop protecting this cell",
735
+ }
736
+ _HOLD_DONE = {
737
+ "apply": "applied; the cell stays protected",
738
+ "keep": "kept; research's value is dismissed and the cell stays protected",
739
+ "release": "released; research may update it",
740
+ }
741
+
742
+
611
743
  def cmd_holds_apply(args) -> int:
612
744
  return _hold_verb(args, "apply")
613
745
 
614
746
 
747
+ def cmd_holds_keep(args) -> int:
748
+ return _hold_verb(args, "keep")
749
+
750
+
615
751
  def cmd_holds_release(args) -> int:
616
752
  return _hold_verb(args, "release")
617
753
 
@@ -651,6 +787,10 @@ def _print_merge(body: dict) -> None:
651
787
  "or pass --accept-survivor-on-ties")
652
788
 
653
789
 
790
+ #: Merge refusals fixed by changing the command, not by retrying: exit 2, not 1.
791
+ _MERGE_USAGE_CODES = frozenset({"ties_unresolved", "merge_across_parents"})
792
+
793
+
654
794
  def cmd_merge_preview(args) -> int:
655
795
  """What a merge would do; writes nothing (#1577 2c-3)."""
656
796
  client, _ = _require_auth(args)
@@ -661,7 +801,7 @@ def cmd_merge_preview(args) -> int:
661
801
  out_ = client.request("POST", "/v1/merges/preview", json_body=body)
662
802
  except ApiError as exc:
663
803
  err(str(exc))
664
- return EXIT_FAIL
804
+ return EXIT_USAGE if exc.code in _MERGE_USAGE_CODES else EXIT_FAIL
665
805
  if args.json:
666
806
  out(out_, True)
667
807
  else:
@@ -678,7 +818,7 @@ def cmd_merge_apply(args) -> int:
678
818
  return EXIT_USAGE
679
819
  body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
680
820
  if not args.yes:
681
- if not _tty():
821
+ if not _interactive():
682
822
  err("refusing to change data non-interactively; pass --yes")
683
823
  return EXIT_USAGE
684
824
  prompt = f"Merge {len(body['loser_ids'])} row(s) into {args.survivor}? This changes your dataset. [y/N] "
@@ -692,8 +832,9 @@ def cmd_merge_apply(args) -> int:
692
832
  err(str(exc))
693
833
  # An open tie is fixed by changing the command (--choice, or
694
834
  # --accept-survivor-on-ties), so it exits as a usage error: a script can tell it
695
- # apart from a failure without --json.
696
- return EXIT_USAGE if exc.code == "ties_unresolved" else EXIT_FAIL
835
+ # apart from a failure without --json. So is a merge across parents (#2254): no retry
836
+ # can succeed, only a different set of rows.
837
+ return EXIT_USAGE if exc.code in _MERGE_USAGE_CODES else EXIT_FAIL
697
838
  if args.json:
698
839
  out(out_, True)
699
840
  else:
@@ -1005,8 +1146,8 @@ def build_parser() -> argparse.ArgumentParser:
1005
1146
  help="restrict evidence to this source (repeatable): a host, *.gov, "
1006
1147
  "https://host/path/*, or blueprint:primary; a cell cited only "
1007
1148
  "elsewhere is not written")
1008
- rc.add_argument("--blueprint", help="Blueprint id to research (default: your active one, "
1009
- "which `deepsieve whoami` shows)")
1149
+ rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
1150
+ "(`deepsieve whoami` shows your active Blueprint's id)")
1010
1151
  rc.add_argument("--depth", default="standard", choices=["standard", "max"])
1011
1152
  rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
1012
1153
  rc.add_argument("--no-monitor", dest="monitored", action="store_false",
@@ -1028,6 +1169,28 @@ def build_parser() -> argparse.ArgumentParser:
1028
1169
  rx.add_argument("run_id")
1029
1170
  rx.set_defaults(func=cmd_runs_cancel)
1030
1171
 
1172
+ rm = runs.add_parser(
1173
+ "monitoring",
1174
+ help="turn a report's monitoring on or off (ON bills daily past the free 30 days)",
1175
+ parents=[common],
1176
+ )
1177
+ rm.add_argument("run_id")
1178
+ onoff = rm.add_mutually_exclusive_group(required=True)
1179
+ onoff.add_argument("--on", dest="active", action="store_true")
1180
+ onoff.add_argument("--off", dest="active", action="store_false")
1181
+ rm.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1182
+ rm.add_argument(
1183
+ "--commit-lock",
1184
+ action="store_true",
1185
+ help="with --on after the free 30 days: commit to 30 days of monitoring (billed daily)",
1186
+ )
1187
+ rm.add_argument(
1188
+ "--expected-locked-until",
1189
+ default="",
1190
+ help="with --commit-lock: the locked_until you were quoted; refused if the date has moved",
1191
+ )
1192
+ rm.set_defaults(func=cmd_runs_monitoring)
1193
+
1031
1194
  chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
1032
1195
  ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
1033
1196
  ct.add_argument("run_id")
@@ -1041,8 +1204,8 @@ def build_parser() -> argparse.ArgumentParser:
1041
1204
  cp.add_argument("action_id")
1042
1205
  cp.set_defaults(func=fn)
1043
1206
 
1044
- members = sub.add_parser("members", help="your workspace's team", parents=[common]).add_subparsers(dest="sub", required=True)
1045
- ml = members.add_parser("list", help="who is in the workspace, and seat usage", parents=[common])
1207
+ members = sub.add_parser("members", help="your organization's team", parents=[common]).add_subparsers(dest="sub", required=True)
1208
+ ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
1046
1209
  ml.set_defaults(func=cmd_members_list)
1047
1210
 
1048
1211
  changes = sub.add_parser("changes", help="merges, renames and successor links", parents=[common]).add_subparsers(dest="sub", required=True)
@@ -1077,6 +1240,12 @@ def build_parser() -> argparse.ArgumentParser:
1077
1240
  ha.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1078
1241
  ha.add_argument("--idempotency-key", help="make a retry safe")
1079
1242
  ha.set_defaults(func=cmd_holds_apply)
1243
+ hk = holds.add_parser("keep", help="keep your value and dismiss research's newer one (needs data:write)", parents=[common])
1244
+ hk.add_argument("cell_id")
1245
+ hk.add_argument("--finding", help="the `finding` id `holds list` printed for the value you reviewed (a newer one since is refused)")
1246
+ hk.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1247
+ hk.add_argument("--idempotency-key", help="make a retry safe")
1248
+ hk.set_defaults(func=cmd_holds_keep)
1080
1249
  hr = holds.add_parser("release", help="stop protecting a cell (needs data:write)", parents=[common])
1081
1250
  hr.add_argument("cell_id")
1082
1251
  hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
@@ -1143,7 +1312,7 @@ def main(argv: list[str] | None = None) -> int:
1143
1312
  return EXIT_FAIL
1144
1313
  except ApiError as exc:
1145
1314
  err(str(exc))
1146
- return EXIT_FAIL
1315
+ return _api_exit(exc)
1147
1316
 
1148
1317
 
1149
1318
  if __name__ == "__main__": # pragma: no cover
@@ -208,7 +208,7 @@ def test_commands_refuse_without_a_credential(capsys):
208
208
  def test_billable_run_is_refused_non_interactively_without_yes(monkeypatch, capsys):
209
209
  cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
210
210
  monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
211
- code = main(["runs", "create", "--query", "anything"])
211
+ code = main(["runs", "create", "--query", "anything", "--blueprint", "bp-a"])
212
212
  assert code == 2
213
213
  assert "refusing to start a billable run" in capsys.readouterr().err
214
214
 
@@ -976,6 +976,19 @@ def test_an_unsettled_tie_surfaces_the_servers_refusal(monkeypatch, capsys):
976
976
  assert f"author::{_L}" in capsys.readouterr().err
977
977
 
978
978
 
979
+ def test_a_merge_across_parents_exits_as_a_usage_error(monkeypatch, capsys):
980
+ """#2254: no retry can succeed, only different rows, so a script sees 2, never 1."""
981
+ _signed_in(monkeypatch)
982
+ body = {"error": {"type": "conflict_error", "code": "merge_across_parents", "retriable": False,
983
+ "message": "These books belong to different publishers (A, B).",
984
+ "param": "loser_ids"}}
985
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
986
+ for argv in (["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"],
987
+ ["merge", "preview", "books", "--survivor", _S, "--loser", _L]):
988
+ assert main(argv) == 2, argv
989
+ assert "different publishers" in capsys.readouterr().err
990
+
991
+
979
992
  # ---------------------------------------------------------------------------
980
993
  # #2196 PR 2: holds list | apply | release
981
994
  # ---------------------------------------------------------------------------
@@ -1045,6 +1058,31 @@ def test_holds_apply_posts_the_finding_and_the_idempotency_key(monkeypatch, caps
1045
1058
  assert "stays protected" in capsys.readouterr().out
1046
1059
 
1047
1060
 
1061
+ def test_holds_keep_posts_the_finding_to_keep_and_asks_in_a_pipe(monkeypatch, capsys):
1062
+ """#2294: Keep mine, the third verb, confirmed like the other two."""
1063
+ _signed_in(monkeypatch)
1064
+ seen = {}
1065
+
1066
+ def handler(request: httpx.Request) -> httpx.Response:
1067
+ seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
1068
+ seen["body"] = json.loads(request.content or b"{}")
1069
+ return httpx.Response(200, json={"object": "cell", "column": "author", "value": "Austen",
1070
+ "held": True})
1071
+
1072
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1073
+ rc = main(["holds", "keep", CELL, "--finding", CID, "--yes", "--idempotency-key", "k-3"])
1074
+ assert rc == 0
1075
+ assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/keep", "k-3")
1076
+ assert seen["body"] == {"finding_evidence_id": CID}
1077
+ assert "dismissed" in capsys.readouterr().out
1078
+
1079
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1080
+ seen.clear()
1081
+ assert main(["holds", "keep", CELL]) == 2
1082
+ assert "pass --yes" in capsys.readouterr().err
1083
+ assert seen == {}, "nothing sent"
1084
+
1085
+
1048
1086
  def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch, capsys):
1049
1087
  """The id goes into the URL path, so a crafted one could reach another endpoint."""
1050
1088
  _signed_in(monkeypatch)
@@ -1052,3 +1090,271 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
1052
1090
  monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1053
1091
  assert main(["holds", "release", "../research/runs/x/cancel#", "--yes"]) == 2
1054
1092
  assert calls == []
1093
+
1094
+
1095
+ def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
1096
+ """gh #1282 B1: the API now requires `blueprint_id` on run create; the CLI says so first, with
1097
+ where the id comes from, and sends nothing. A dry run may omit it (the API's own exception)."""
1098
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
1099
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1100
+ sent: list[str] = []
1101
+
1102
+ def handler(request: httpx.Request) -> httpx.Response:
1103
+ sent.append(f"{request.method} {request.url.path}")
1104
+ return httpx.Response(202, json={"id": "run_1", "status": "queued"})
1105
+
1106
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1107
+ code = main(["runs", "create", "--query", "anything", "--yes"])
1108
+ err = capsys.readouterr().err
1109
+ assert code == 2, err
1110
+ assert "--blueprint" in err and "whoami" in err
1111
+ assert sent == [], sent
1112
+ # Positive control: the same handler DOES see a request when the call is allowed.
1113
+ assert main(["runs", "create", "--query", "anything", "--dry-run"]) == 0
1114
+ assert sent == ["POST /v1/research/runs"], sent
1115
+
1116
+
1117
+ def test_a_run_refused_by_the_model_allowance_exits_5_not_1(monkeypatch, capsys):
1118
+ """gh #2307: a script can tell "the org's model allowance is used" from a failure."""
1119
+ _signed_in(monkeypatch)
1120
+ body = {"error": {"type": "rate_limit_error", "code": "model_allowance_exhausted",
1121
+ "retriable": False, "param": None,
1122
+ "message": "Your organization has used this period's allowance."}}
1123
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(429, json=body)))
1124
+ assert main(["runs", "create", "--query", "EU payment vendors", "--blueprint", "bp-a", "--yes"]) == 5
1125
+ assert "allowance" in capsys.readouterr().err
1126
+
1127
+
1128
+ # ── runs monitoring (gh #2345) ───────────────────────────────────────────────
1129
+
1130
+ RUN = "3f2a8e1c-0000-4000-8000-000000002345"
1131
+
1132
+
1133
+ def _monitoring_body(active: bool) -> dict:
1134
+ return {
1135
+ "object": "monitoring",
1136
+ "run_id": RUN,
1137
+ "active": active,
1138
+ "units": 2,
1139
+ "in_free_window": True,
1140
+ "free_until": "2026-10-28",
1141
+ "billing": False,
1142
+ "reruns": {"status": "ok"},
1143
+ }
1144
+
1145
+
1146
+ @pytest.mark.parametrize("flag,active", [("--on", True), ("--off", False)])
1147
+ def test_runs_monitoring_patches_the_state_asked_for(monkeypatch, capsys, flag, active):
1148
+ _signed_in(monkeypatch)
1149
+ seen = {}
1150
+
1151
+ def handler(request: httpx.Request) -> httpx.Response:
1152
+ seen["method"], seen["path"] = request.method, request.url.path
1153
+ seen["body"] = json.loads(request.content)
1154
+ return httpx.Response(200, json=_monitoring_body(active))
1155
+
1156
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1157
+ assert main(["runs", "monitoring", RUN, flag, "--yes"]) == 0
1158
+ assert seen == {
1159
+ "method": "PATCH",
1160
+ "path": f"/v1/research/runs/{RUN}/monitoring",
1161
+ "body": {"active": active},
1162
+ }
1163
+ text = capsys.readouterr().out
1164
+ assert ("ON" if active else "OFF") in text
1165
+
1166
+
1167
+ def test_runs_monitoring_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
1168
+ _signed_in(monkeypatch)
1169
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1170
+ calls = []
1171
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1172
+ assert main(["runs", "monitoring", RUN, "--on"]) == 2
1173
+ assert "pass --yes" in capsys.readouterr().err
1174
+ assert calls == []
1175
+
1176
+
1177
+ def test_runs_monitoring_needs_on_or_off_and_a_uuid(monkeypatch, capsys):
1178
+ _signed_in(monkeypatch)
1179
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: pytest.fail("no request expected")))
1180
+ with pytest.raises(SystemExit) as exc:
1181
+ main(["runs", "monitoring", RUN, "--yes"]) # neither --on nor --off
1182
+ assert exc.value.code == 2
1183
+ assert main(["runs", "monitoring", "../billing", "--off", "--yes"]) == 2
1184
+
1185
+
1186
+ def test_runs_monitoring_surfaces_a_404(monkeypatch, capsys):
1187
+ _signed_in(monkeypatch)
1188
+
1189
+ def handler(request: httpx.Request) -> httpx.Response:
1190
+ return httpx.Response(
1191
+ 404,
1192
+ json={"error": {"code": "not_found", "message": "This run has no monitoring to set"}},
1193
+ )
1194
+
1195
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1196
+ assert main(["runs", "monitoring", RUN, "--off", "--yes"]) == 1
1197
+ assert "no monitoring" in capsys.readouterr().err
1198
+
1199
+
1200
+ # ── runs monitoring: the 30-day re-enable lock (gh #2345 x #2359) ────────────
1201
+
1202
+ _LOCK_409 = {
1203
+ "error": {
1204
+ "code": "lock_confirmation_required",
1205
+ "message": "Turning this back on commits 30 days",
1206
+ "param": "commit_lock",
1207
+ "retriable": False,
1208
+ "details": {"locked_until": "2026-10-28T00:00:00+00:00"},
1209
+ }
1210
+ }
1211
+
1212
+
1213
+ def _lock_handler(seen: list):
1214
+ def handler(request: httpx.Request) -> httpx.Response:
1215
+ body = json.loads(request.content)
1216
+ seen.append(body)
1217
+ if not body.get("commit_lock"):
1218
+ return httpx.Response(409, json=_LOCK_409)
1219
+ return httpx.Response(
1220
+ 200,
1221
+ json={**_monitoring_body(True), "locked_until": "2026-10-28T00:00:00+00:00"},
1222
+ )
1223
+
1224
+ return handler
1225
+
1226
+
1227
+ def test_a_lock_in_a_pipe_is_refused_naming_the_last_day_and_the_flag(monkeypatch, capsys):
1228
+ """--yes alone never commits 30 days: a script must say --commit-lock."""
1229
+ _signed_in(monkeypatch)
1230
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1231
+ seen: list = []
1232
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1233
+ assert main(["runs", "monitoring", RUN, "--on", "--yes"]) == 2
1234
+ err_text = capsys.readouterr().err
1235
+ print("refusal:", err_text)
1236
+ assert "through 2026-10-27" in err_text and "--commit-lock" in err_text
1237
+ assert "--expected-locked-until 2026-10-28T00:00:00+00:00" in err_text
1238
+ assert seen == [{"active": True}]
1239
+
1240
+
1241
+ def test_commit_lock_commits_in_one_call(monkeypatch, capsys):
1242
+ _signed_in(monkeypatch)
1243
+ seen: list = []
1244
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1245
+ assert main(["runs", "monitoring", RUN, "--on", "--yes", "--commit-lock"]) == 0
1246
+ assert seen == [{"active": True, "commit_lock": True}]
1247
+ assert "committed through 2026-10-27" in capsys.readouterr().out
1248
+
1249
+
1250
+ def test_interactively_the_lock_is_a_second_explicit_question(monkeypatch, capsys):
1251
+ _signed_in(monkeypatch)
1252
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1253
+ answers = iter(["y", "y"])
1254
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
1255
+ seen: list = []
1256
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1257
+ assert main(["runs", "monitoring", RUN, "--on"]) == 0
1258
+ # The yes is bound to the date the person was shown (gh #2345).
1259
+ assert seen == [
1260
+ {"active": True},
1261
+ {
1262
+ "active": True,
1263
+ "commit_lock": True,
1264
+ "expected_locked_until": "2026-10-28T00:00:00+00:00",
1265
+ },
1266
+ ]
1267
+
1268
+
1269
+ def test_declining_the_lock_sends_nothing_more(monkeypatch, capsys):
1270
+ _signed_in(monkeypatch)
1271
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1272
+ answers = iter(["y", "n"])
1273
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
1274
+ seen: list = []
1275
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1276
+ assert main(["runs", "monitoring", RUN, "--on"]) == 0
1277
+ assert seen == [{"active": True}]
1278
+ assert "still off" in capsys.readouterr().out
1279
+
1280
+
1281
+ def test_a_piped_yes_from_a_terminal_never_commits_30_days(monkeypatch, capsys):
1282
+ """gh #2345 review, the seat's exact case: stdout is a terminal, stdin is `yes |`. The gate
1283
+ looked at stdout only, so both questions were answered from the pipe and 30 days committed."""
1284
+ import io
1285
+
1286
+ _signed_in(monkeypatch)
1287
+ monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False)
1288
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: True)
1289
+ monkeypatch.setattr("sys.stdin", io.StringIO("y\ny\ny\n"))
1290
+ seen: list = []
1291
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1292
+ rc = main(["runs", "monitoring", RUN, "--on"])
1293
+ print("piped yes:", rc, seen)
1294
+ assert rc == 2
1295
+ assert seen == [], "no request at all: a pipe is refused before the first question"
1296
+
1297
+
1298
+ def test_a_lock_refusal_under_json_is_machine_readable(monkeypatch, capsys):
1299
+ _signed_in(monkeypatch)
1300
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
1301
+ seen: list = []
1302
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1303
+ assert main(["runs", "monitoring", RUN, "--on", "--yes", "--json"]) == 2
1304
+ out_json = json.loads(capsys.readouterr().out)
1305
+ assert out_json["error"]["code"] == "lock_confirmation_required"
1306
+ assert out_json["error"]["details"] == {"locked_until": "2026-10-28T00:00:00+00:00"}
1307
+
1308
+
1309
+ def test_a_moved_date_is_asked_again_with_the_new_date(monkeypatch, capsys):
1310
+ """Quoted before 00:00Z, confirmed after: the API answers `lock_quote_changed` with the new
1311
+ date; the person is asked again and the new yes is bound to the new date."""
1312
+ _signed_in(monkeypatch)
1313
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1314
+ answers = iter(["y", "y", "y"])
1315
+ prompts: list = []
1316
+ monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or next(answers))
1317
+ seen: list = []
1318
+ moved = {
1319
+ "error": {
1320
+ "code": "lock_quote_changed",
1321
+ "message": "The UTC day changed",
1322
+ "details": {"locked_until": "2026-10-29T00:00:00+00:00"},
1323
+ }
1324
+ }
1325
+
1326
+ def handler(request):
1327
+ body = json.loads(request.content)
1328
+ seen.append(body)
1329
+ if not body.get("commit_lock"):
1330
+ return httpx.Response(409, json=_LOCK_409)
1331
+ if body.get("expected_locked_until") != "2026-10-29T00:00:00+00:00":
1332
+ return httpx.Response(409, json=moved)
1333
+ return httpx.Response(200, json={**_monitoring_body(True), "locked_until": "2026-10-29T00:00:00+00:00"})
1334
+
1335
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1336
+ assert main(["runs", "monitoring", RUN, "--on"]) == 0
1337
+ assert [b.get("expected_locked_until") for b in seen] == [
1338
+ None,
1339
+ "2026-10-28T00:00:00+00:00",
1340
+ "2026-10-29T00:00:00+00:00",
1341
+ ]
1342
+ assert "through 2026-10-28" in prompts[-1] and "date moved" in prompts[-1]
1343
+
1344
+
1345
+ def test_end_of_input_at_the_lock_question_is_a_no_not_a_traceback(monkeypatch, capsys):
1346
+ _signed_in(monkeypatch)
1347
+ monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
1348
+ answers = iter(["y"])
1349
+
1350
+ def _input(prompt=""):
1351
+ try:
1352
+ return next(answers)
1353
+ except StopIteration:
1354
+ raise EOFError from None
1355
+
1356
+ monkeypatch.setattr("builtins.input", _input)
1357
+ seen: list = []
1358
+ monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
1359
+ assert main(["runs", "monitoring", RUN, "--on"]) == 0
1360
+ assert seen == [{"active": True}]
File without changes