deepsieve-cli 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -20,6 +20,10 @@ scoped API key. Nothing to copy, nothing pasted into your shell history. The
20
20
  credential appears in **Settings → API keys** on your deployment and can be
21
21
  revoked there at any time.
22
22
 
23
+ The key covers the CLI's own commands and cannot create a Blueprint. If an agent
24
+ will use it to create Blueprints, run `deepsieve login --preset agent`; the
25
+ approval screen lists the extra scopes.
26
+
23
27
  ## Commands
24
28
 
25
29
  ```bash
@@ -31,8 +35,16 @@ deepsieve runs list
31
35
  deepsieve runs get <id> # exit 4 while still running
32
36
  deepsieve runs cancel <id>
33
37
  deepsieve members list # who is in the workspace + seat usage
38
+ deepsieve changes list --live # merges, renames, successor links
39
+ deepsieve changes undo <id> # asks first; --yes in scripts
40
+ deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
41
+ deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
34
42
  deepsieve data catalog # entities + columns (never guess)
35
43
  deepsieve data get companies --receipts # rows with per-cell citations
44
+ deepsieve chat thread <run-id> # changes, with status + warnings
45
+ deepsieve chat confirm <action-id> # apply a change that waits for confirmation
46
+ deepsieve chat dismiss <action-id> # decline it; nothing is written
47
+ deepsieve chat revert <action-id> # undo a change a chat answer applied
36
48
  deepsieve setup mcp # MCP registration for this origin
37
49
  ```
38
50
 
@@ -12,6 +12,10 @@ scoped API key. Nothing to copy, nothing pasted into your shell history. The
12
12
  credential appears in **Settings → API keys** on your deployment and can be
13
13
  revoked there at any time.
14
14
 
15
+ The key covers the CLI's own commands and cannot create a Blueprint. If an agent
16
+ will use it to create Blueprints, run `deepsieve login --preset agent`; the
17
+ approval screen lists the extra scopes.
18
+
15
19
  ## Commands
16
20
 
17
21
  ```bash
@@ -23,8 +27,16 @@ deepsieve runs list
23
27
  deepsieve runs get <id> # exit 4 while still running
24
28
  deepsieve runs cancel <id>
25
29
  deepsieve members list # who is in the workspace + seat usage
30
+ deepsieve changes list --live # merges, renames, successor links
31
+ deepsieve changes undo <id> # asks first; --yes in scripts
32
+ deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
33
+ deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
26
34
  deepsieve data catalog # entities + columns (never guess)
27
35
  deepsieve data get companies --receipts # rows with per-cell citations
36
+ deepsieve chat thread <run-id> # changes, with status + warnings
37
+ deepsieve chat confirm <action-id> # apply a change that waits for confirmation
38
+ deepsieve chat dismiss <action-id> # decline it; nothing is written
39
+ deepsieve chat revert <action-id> # undo a change a chat answer applied
28
40
  deepsieve setup mcp # MCP registration for this origin
29
41
  ```
30
42
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepsieve-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "DeepSieve CLI — run cited deep research from your terminal"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -110,7 +110,7 @@ def cmd_login(args) -> int:
110
110
  return EXIT_OK
111
111
 
112
112
  try:
113
- auth = client.start_device_authorization(CLIENT_NAME)
113
+ auth = client.start_device_authorization(CLIENT_NAME, preset=args.preset)
114
114
  except ApiError as exc:
115
115
  err(str(exc))
116
116
  return EXIT_FAIL
@@ -172,6 +172,10 @@ def cmd_whoami(args) -> int:
172
172
  print(f"profile {prof.name}")
173
173
  print(f"origin {prof.origin}")
174
174
  print(f"org {me.get('org_id')}")
175
+ # gh #1282: the Blueprint un-addressed calls act on, by name and by the id every
176
+ # `--blueprint` / `blueprint_id` accepts — not the internal schema name.
177
+ bp = me.get("blueprint") or {}
178
+ print(f"blueprint {bp.get('name')} ({bp.get('id')})" if bp else "blueprint (none)")
175
179
  print(f"workspace {me.get('workspace_schema')}")
176
180
  print(f"role {me.get('role')}")
177
181
  print(f"scopes {', '.join(me.get('scopes') or []) or '(full)'}")
@@ -200,6 +204,8 @@ def cmd_runs_list(args) -> int:
200
204
  params: dict[str, Any] = {"limit": args.limit}
201
205
  if args.status:
202
206
  params["status"] = args.status
207
+ if args.blueprint:
208
+ params["blueprint_id"] = args.blueprint
203
209
  try:
204
210
  body = client.request("GET", "/v1/research/runs", params=params)
205
211
  except ApiError as exc:
@@ -213,6 +219,9 @@ def cmd_runs_list(args) -> int:
213
219
  (r.get("title") or r.get("query") or "")[:48]]
214
220
  for r in body.get("data", [])
215
221
  ]
222
+ bp = body.get("blueprint") or {}
223
+ if bp:
224
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
216
225
  print(_table(rows, ["id", "status", "created", "title"]) if rows else "No runs yet.")
217
226
  return EXIT_OK
218
227
 
@@ -229,6 +238,9 @@ def cmd_runs_get(args) -> int:
229
238
  else:
230
239
  print(f"id {body.get('id')}")
231
240
  print(f"status {body.get('status')} (done={body.get('done')})")
241
+ bp = body.get("blueprint") or {}
242
+ if bp:
243
+ print(f"blueprint {bp.get('name')} ({bp.get('id')})")
232
244
  if body.get("error"):
233
245
  print(f"error {body['error'].get('message')}")
234
246
  if not body.get("done"):
@@ -255,6 +267,8 @@ def cmd_runs_create(args) -> int:
255
267
  # #1802 — repeatable; the server resolves and refuses, the CLI only carries.
256
268
  # Only blanks given → no list at all, never an empty one (the server 400s on []).
257
269
  body["allowed_sources"] = allowed
270
+ if getattr(args, "blueprint", None):
271
+ body["blueprint_id"] = args.blueprint
258
272
 
259
273
  # A real run spends money. Confirm interactively unless told not to — and
260
274
  # never silently in a pipe, where nobody is watching.
@@ -264,6 +278,10 @@ def cmd_runs_create(args) -> int:
264
278
  return EXIT_USAGE
265
279
  print(f"This starts a REAL research run on {prof.origin} — it spends credits "
266
280
  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}.")
267
285
  if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
268
286
  print("Cancelled.")
269
287
  return EXIT_OK
@@ -284,6 +302,9 @@ def cmd_runs_create(args) -> int:
284
302
  return EXIT_OK
285
303
  run_id = created.get("id")
286
304
  print(f"started run {run_id} ({'dry run' if args.dry_run else 'billable'})")
305
+ bp = created.get("blueprint") or {}
306
+ if bp:
307
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
287
308
  if not args.wait:
288
309
  print(f"poll with: deepsieve runs get {run_id}")
289
310
  return EXIT_OK
@@ -330,6 +351,80 @@ def cmd_runs_cancel(args) -> int:
330
351
  return EXIT_OK
331
352
 
332
353
 
354
+ def _uuid_arg(value: str, what: str) -> str | None:
355
+ """An id interpolated into a URL path must be a UUID, never a path fragment."""
356
+ import uuid as _uuid # noqa: PLC0415
357
+
358
+ try:
359
+ return str(_uuid.UUID(value))
360
+ except ValueError:
361
+ err(f"{what} must be a UUID")
362
+ return None
363
+
364
+
365
+ def cmd_chat_thread(args) -> int:
366
+ """A run's chat thread: questions, answers, and each change with its status. A change
367
+ `pending_confirmation` waits for `chat confirm` or `chat dismiss` (#2187)."""
368
+ rid = _uuid_arg(args.run_id, "run_id")
369
+ if rid is None:
370
+ return EXIT_FAIL
371
+ client, _ = _require_auth(args)
372
+ try:
373
+ body = client.request("GET", f"/v1/research/runs/{rid}/chat")
374
+ except ApiError as exc:
375
+ err(str(exc))
376
+ return EXIT_FAIL
377
+ if args.json:
378
+ out(body, True)
379
+ return EXIT_OK
380
+ rows = []
381
+ for message in body.get("messages") or []:
382
+ for action in message.get("actions") or []:
383
+ warnings = "; ".join(w.get("message", "") for w in action.get("warnings") or [])
384
+ rows.append(
385
+ [
386
+ action.get("id", ""),
387
+ action.get("status", ""),
388
+ action.get("action_type", ""),
389
+ f"{action.get('record_name') or ''} {action.get('column_name') or ''}".strip(),
390
+ warnings or action.get("note") or "",
391
+ ]
392
+ )
393
+ print(f"{len(body.get('messages') or [])} message(s)")
394
+ if rows:
395
+ print(_table(rows, ["action", "status", "type", "cell", "warning / note"]))
396
+ return EXIT_OK
397
+
398
+
399
+ def _chat_action(args, op: str, done: str) -> int:
400
+ aid = _uuid_arg(args.action_id, "action_id")
401
+ if aid is None:
402
+ return EXIT_FAIL
403
+ client, _ = _require_auth(args)
404
+ try:
405
+ body = client.request("POST", f"/v1/chat/actions/{aid}/{op}")
406
+ except ApiError as exc:
407
+ err(str(exc))
408
+ return EXIT_FAIL
409
+ out(body, args.json, f"{done} {aid}")
410
+ return EXIT_OK
411
+
412
+
413
+ def cmd_chat_confirm(args) -> int:
414
+ """Apply a waiting change anyway, having read its warnings (`chat thread`)."""
415
+ return _chat_action(args, "confirm", "applied")
416
+
417
+
418
+ def cmd_chat_dismiss(args) -> int:
419
+ """Decline a waiting change: nothing is written."""
420
+ return _chat_action(args, "dismiss", "dismissed")
421
+
422
+
423
+ def cmd_chat_revert(args) -> int:
424
+ """Undo a change a chat answer applied."""
425
+ return _chat_action(args, "revert", "reverted")
426
+
427
+
333
428
  def cmd_members_list(args) -> int:
334
429
  """Who is in this workspace and how many editor seats are in use (#985).
335
430
  Read-only; inviting is a human-admin action in the UI."""
@@ -351,6 +446,262 @@ def cmd_members_list(args) -> int:
351
446
  return EXIT_OK
352
447
 
353
448
 
449
+ def _describe_change(c: dict) -> str:
450
+ """One line a person can read: what the change did, in its own words."""
451
+ kind = c.get("kind")
452
+ if kind == "merge":
453
+ folded = ", ".join(c.get("merged_names") or [])
454
+ return f"merged {folded} into {c.get('survivor_name')}"
455
+ if kind == "renamed":
456
+ return f"renamed {c.get('old_name')} to {c.get('new_name')}"
457
+ if kind == "succeeded_by":
458
+ return f"{c.get('old_name')} succeeded by {c.get('new_name')}"
459
+ return str(kind)
460
+
461
+
462
+ def cmd_changes_list(args) -> int:
463
+ """Merges, renames and successor links, newest first (#1577)."""
464
+ client, _ = _require_auth(args)
465
+ params: dict[str, Any] = {"limit": args.limit}
466
+ for flag, key in (("entity", "entity"), ("kind", "kind"), ("by", "by"), ("cursor", "cursor"),
467
+ ("blueprint", "blueprint_id")):
468
+ if getattr(args, flag, None):
469
+ params[key] = getattr(args, flag)
470
+ if args.live:
471
+ params["live"] = "true"
472
+ try:
473
+ body = client.request("GET", "/v1/changes", params=params)
474
+ except ApiError as exc:
475
+ err(str(exc))
476
+ return EXIT_FAIL
477
+ if args.json:
478
+ out(body, True)
479
+ return EXIT_OK
480
+ bp = body.get("blueprint") or {}
481
+ if bp:
482
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
483
+ rows = []
484
+ for c in body.get("data", []):
485
+ if c.get("undone_at"):
486
+ state = "undone"
487
+ elif c.get("undoable"):
488
+ state = "undoable"
489
+ else:
490
+ state = "cannot undo"
491
+ rows.append([c.get("id", ""), str(c.get("created_at", ""))[:19], c.get("entity", ""),
492
+ _describe_change(c)[:60], state])
493
+ print(_table(rows, ["id", "when", "entity", "change", "state"]) if rows else "No changes.")
494
+ if body.get("has_more"):
495
+ print(f"\nMore: --cursor {body.get('next_cursor')}")
496
+ return EXIT_OK
497
+
498
+
499
+ def cmd_changes_undo(args) -> int:
500
+ """Undo one change. It changes the dataset, so it is confirmed like a billable
501
+ run: interactively, or with --yes, and never silently in a pipe."""
502
+ client, _ = _require_auth(args)
503
+ try:
504
+ # The id goes into the URL path; a non-UUID could reach another endpoint.
505
+ change_id = str(uuid.UUID(args.change_id))
506
+ except ValueError:
507
+ err("change id must be a UUID (from `deepsieve changes list`)")
508
+ return EXIT_USAGE
509
+ if not args.yes:
510
+ if not _tty():
511
+ err("refusing to change data non-interactively; pass --yes")
512
+ return EXIT_USAGE
513
+ if input(f"Undo change {change_id}? This changes your dataset. [y/N] ").strip().lower() not in ("y", "yes"):
514
+ print("Cancelled.")
515
+ return EXIT_OK
516
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
517
+ payload = {"overwrite_changes": True} if getattr(args, "overwrite_changes", False) else None
518
+ try:
519
+ body = client.request(
520
+ "POST", f"/v1/changes/{change_id}/undo", json_body=payload, headers=headers
521
+ )
522
+ except ApiError as exc:
523
+ # The API names its body field; a CLI user has a flag for it (second review, n3).
524
+ err(str(exc).replace("Send `overwrite_changes: true`", "Run again with --overwrite-changes"))
525
+ return EXIT_FAIL
526
+ out(body, args.json, f"undone: {_describe_change(body)}")
527
+ return EXIT_OK
528
+
529
+
530
+
531
+ #: A cell id as /v1 issues them. It goes into a URL path; anything else is refused here.
532
+ _CELL_ID = re.compile(r"cel_[A-Za-z0-9_-]{8,1024}")
533
+
534
+
535
+ def cmd_holds_list(args) -> int:
536
+ """Cells you set on purpose, and what research found since (#2196)."""
537
+ client, _ = _require_auth(args)
538
+ params: dict[str, Any] = {"limit": args.limit}
539
+ for flag, key in (("entity", "entity"), ("cursor", "cursor"), ("blueprint", "blueprint_id")):
540
+ if getattr(args, flag, None):
541
+ params[key] = getattr(args, flag)
542
+ if args.pending:
543
+ params["pending"] = "true"
544
+ try:
545
+ body = client.request("GET", "/v1/holds", params=params)
546
+ except ApiError as exc:
547
+ err(str(exc))
548
+ return EXIT_FAIL
549
+ if args.json:
550
+ out(body, True)
551
+ return EXIT_OK
552
+ bp = body.get("blueprint") or {}
553
+ if bp:
554
+ print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
555
+ rows = []
556
+ for h in body.get("data", []):
557
+ pf = h.get("pending_finding") or {}
558
+ rows.append([
559
+ h.get("cell_id", ""),
560
+ f"{h.get('record_name') or h.get('record_id')}.{h.get('column')}"[:40],
561
+ str(h.get("value"))[:30],
562
+ str(pf.get("value"))[:30] if pf else "-",
563
+ # Printed so `holds apply --finding` can name what you saw (a newer one is refused).
564
+ pf.get("evidence_id") or "-",
565
+ ])
566
+ print(
567
+ _table(rows, ["cell", "cell of", "your value", "research found", "finding"])
568
+ if rows
569
+ else "No held cells."
570
+ )
571
+ if body.get("has_more"):
572
+ print(f"\nMore: --cursor {body.get('next_cursor')}")
573
+ return EXIT_OK
574
+
575
+
576
+ 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."""
579
+ client, _ = _require_auth(args)
580
+ if not _CELL_ID.fullmatch(args.cell_id or ""):
581
+ err("cell id must be one `deepsieve holds list` printed (it starts with cel_)")
582
+ return EXIT_USAGE
583
+ body: dict[str, Any] = {}
584
+ if verb == "apply" and args.finding:
585
+ try:
586
+ body["finding_evidence_id"] = str(uuid.UUID(args.finding))
587
+ except ValueError:
588
+ err("--finding must be the finding's evidence id (a UUID)")
589
+ return EXIT_USAGE
590
+ if not args.yes:
591
+ if not _tty():
592
+ err("refusing to change data non-interactively; pass --yes")
593
+ 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"):
596
+ print("Cancelled.")
597
+ return EXIT_OK
598
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
599
+ try:
600
+ result = client.request(
601
+ "POST", f"/v1/cells/{args.cell_id}/{verb}", json_body=body or None, headers=headers
602
+ )
603
+ except ApiError as exc:
604
+ err(str(exc))
605
+ 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}")
608
+ return EXIT_OK
609
+
610
+
611
+ def cmd_holds_apply(args) -> int:
612
+ return _hold_verb(args, "apply")
613
+
614
+
615
+ def cmd_holds_release(args) -> int:
616
+ return _hold_verb(args, "release")
617
+
618
+ def _merge_body(args) -> dict[str, Any] | None:
619
+ choices: dict[str, str] = {}
620
+ for c in args.choice or []:
621
+ key, sep, side = c.rpartition("=")
622
+ if not sep or side not in ("survivor", "loser"):
623
+ err(f"--choice takes KEY=survivor or KEY=loser, not {c!r}")
624
+ return None
625
+ choices[key] = side
626
+ body: dict[str, Any] = {
627
+ "entity": args.entity,
628
+ "survivor_id": args.survivor,
629
+ "loser_ids": list(args.loser),
630
+ "choices": choices,
631
+ }
632
+ if getattr(args, "blueprint", None):
633
+ body["blueprint_id"] = args.blueprint
634
+ return body
635
+
636
+
637
+ def _print_merge(body: dict) -> None:
638
+ print(f"survivor: {body.get('survivor_name')} ({body.get('survivor_id')})")
639
+ print(f"merges: {', '.join(n or '?' for n in body.get('merged_names') or [])}")
640
+ for f in body.get("fills") or []:
641
+ print(f" fill {f.get('column')}: {f.get('value')} (from {f.get('from')})")
642
+ for c in body.get("conflicts") or []:
643
+ how = {"tie": "TIE", "held": "set on purpose"}.get(c.get("basis"), "evidence")
644
+ mark = " (your choice)" if c.get("chosen") else ""
645
+ print(f" conflict {c.get('column')}: keeps {c.get('resolved_to')} [{how}]{mark} "
646
+ f"survivor={c.get('survivor_value')!r} {c.get('loser_name')}={c.get('loser_value')!r} "
647
+ f"key={c.get('choice_key')}")
648
+ ties = body.get("unresolved_ties") or []
649
+ if ties:
650
+ print(f"\nopen ties ({len(ties)}): settle each with --choice KEY=survivor|loser, "
651
+ "or pass --accept-survivor-on-ties")
652
+
653
+
654
+ def cmd_merge_preview(args) -> int:
655
+ """What a merge would do; writes nothing (#1577 2c-3)."""
656
+ client, _ = _require_auth(args)
657
+ body = _merge_body(args)
658
+ if body is None:
659
+ return EXIT_USAGE
660
+ try:
661
+ out_ = client.request("POST", "/v1/merges/preview", json_body=body)
662
+ except ApiError as exc:
663
+ err(str(exc))
664
+ return EXIT_FAIL
665
+ if args.json:
666
+ out(out_, True)
667
+ else:
668
+ _print_merge(out_)
669
+ return EXIT_OK
670
+
671
+
672
+ def cmd_merge_apply(args) -> int:
673
+ """Merge rows into a survivor. It changes the dataset, so it is confirmed like a
674
+ billable run: interactively, or with --yes, never silently in a pipe."""
675
+ client, _ = _require_auth(args)
676
+ body = _merge_body(args)
677
+ if body is None:
678
+ return EXIT_USAGE
679
+ body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
680
+ if not args.yes:
681
+ if not _tty():
682
+ err("refusing to change data non-interactively; pass --yes")
683
+ return EXIT_USAGE
684
+ prompt = f"Merge {len(body['loser_ids'])} row(s) into {args.survivor}? This changes your dataset. [y/N] "
685
+ if input(prompt).strip().lower() not in ("y", "yes"):
686
+ print("Cancelled.")
687
+ return EXIT_OK
688
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
689
+ try:
690
+ out_ = client.request("POST", "/v1/merges", json_body=body, headers=headers)
691
+ except ApiError as exc:
692
+ err(str(exc))
693
+ # An open tie is fixed by changing the command (--choice, or
694
+ # --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
697
+ if args.json:
698
+ out(out_, True)
699
+ else:
700
+ _print_merge(out_)
701
+ print(f"\nmerged. Undo: deepsieve changes undo {out_.get('change_id')}")
702
+ return EXIT_OK
703
+
704
+
354
705
  def _blueprint_id(client, args) -> str | None:
355
706
  """The Blueprint these commands address: ``--blueprint``, else the active one.
356
707
 
@@ -610,12 +961,22 @@ def build_parser() -> argparse.ArgumentParser:
610
961
  lg.add_argument("--origin", help="deployment URL (e.g. https://staging.deepsieve.ai)")
611
962
  lg.add_argument("--api-key", help="headless: use a key instead of the browser flow")
612
963
  lg.add_argument("--no-browser", action="store_true", help="print the URL, don't open it")
964
+ # #1799: the default `cli` token carries no `blueprint:create`; it has no
965
+ # Blueprint command to spend that consent on. `--preset agent` is the explicit
966
+ # widening for a developer driving an agent from this credential, and the
967
+ # consent screen lists the wider set. The server clamps to `agent` either way.
968
+ lg.add_argument(
969
+ "--preset",
970
+ choices=("cli", "agent"),
971
+ default="cli",
972
+ help="scope preset to request: cli (default) or agent (adds Blueprint creation)",
973
+ )
613
974
  lg.set_defaults(func=cmd_login)
614
975
 
615
976
  lo = sub.add_parser("logout", help="forget this profile's stored credential", parents=[common])
616
977
  lo.set_defaults(func=cmd_logout)
617
978
 
618
- wa = sub.add_parser("whoami", help="show the resolved identity, workspace and scopes", parents=[common])
979
+ wa = sub.add_parser("whoami", help="show the resolved identity, active Blueprint and scopes", parents=[common])
619
980
  wa.set_defaults(func=cmd_whoami)
620
981
 
621
982
  pr = sub.add_parser("profiles", help="list configured profiles", parents=[common])
@@ -626,6 +987,11 @@ def build_parser() -> argparse.ArgumentParser:
626
987
  rl = runs.add_parser("list", help="recent runs", parents=[common])
627
988
  rl.add_argument("--status")
628
989
  rl.add_argument("--limit", type=int, default=20)
990
+ rl.add_argument(
991
+ "--blueprint",
992
+ help="list one Blueprint's runs, by id (`deepsieve whoami` shows your active "
993
+ "one's); omitted: your active Blueprint",
994
+ )
629
995
  rl.set_defaults(func=cmd_runs_list)
630
996
 
631
997
  rg = runs.add_parser("get", help="one run's status", parents=[common])
@@ -639,6 +1005,8 @@ def build_parser() -> argparse.ArgumentParser:
639
1005
  help="restrict evidence to this source (repeatable): a host, *.gov, "
640
1006
  "https://host/path/*, or blueprint:primary; a cell cited only "
641
1007
  "elsewhere is not written")
1008
+ rc.add_argument("--blueprint", help="Blueprint id to research (default: your active one, "
1009
+ "which `deepsieve whoami` shows)")
642
1010
  rc.add_argument("--depth", default="standard", choices=["standard", "max"])
643
1011
  rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
644
1012
  rc.add_argument("--no-monitor", dest="monitored", action="store_false",
@@ -660,10 +1028,79 @@ def build_parser() -> argparse.ArgumentParser:
660
1028
  rx.add_argument("run_id")
661
1029
  rx.set_defaults(func=cmd_runs_cancel)
662
1030
 
1031
+ chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
1032
+ ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
1033
+ ct.add_argument("run_id")
1034
+ ct.set_defaults(func=cmd_chat_thread)
1035
+ for name, fn, text in (
1036
+ ("confirm", cmd_chat_confirm, "apply a change that is waiting for confirmation"),
1037
+ ("dismiss", cmd_chat_dismiss, "decline a change that is waiting for confirmation"),
1038
+ ("revert", cmd_chat_revert, "undo a change a chat answer applied"),
1039
+ ):
1040
+ cp = chat.add_parser(name, help=text, parents=[common])
1041
+ cp.add_argument("action_id")
1042
+ cp.set_defaults(func=fn)
1043
+
663
1044
  members = sub.add_parser("members", help="your workspace's team", parents=[common]).add_subparsers(dest="sub", required=True)
664
1045
  ml = members.add_parser("list", help="who is in the workspace, and seat usage", parents=[common])
665
1046
  ml.set_defaults(func=cmd_members_list)
666
1047
 
1048
+ changes = sub.add_parser("changes", help="merges, renames and successor links", parents=[common]).add_subparsers(dest="sub", required=True)
1049
+ cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
1050
+ cl.add_argument("--entity", help="only this entity's records (an entity key)")
1051
+ cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by"])
1052
+ cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
1053
+ cl.add_argument("--live", action="store_true", help="leave out undone changes")
1054
+ cl.add_argument("--limit", type=int, default=25)
1055
+ cl.add_argument("--cursor")
1056
+ cl.add_argument("--blueprint", help="Blueprint id (default: the active one)")
1057
+ cl.set_defaults(func=cmd_changes_list)
1058
+ cu = changes.add_parser("undo", help="undo one change (needs data:write)", parents=[common])
1059
+ cu.add_argument("change_id")
1060
+ cu.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1061
+ cu.add_argument("--idempotency-key", help="make a retry safe")
1062
+ cu.add_argument("--overwrite-changes", action="store_true",
1063
+ help="undo a merge even though cells it filled changed since")
1064
+ cu.set_defaults(func=cmd_changes_undo)
1065
+
1066
+ holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
1067
+ hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
1068
+ hl.add_argument("--entity", help="only this entity's cells (an entity key)")
1069
+ hl.add_argument("--pending", action="store_true", help="only cells where research found something newer")
1070
+ hl.add_argument("--limit", type=int, default=25)
1071
+ hl.add_argument("--cursor")
1072
+ hl.add_argument("--blueprint", help="Blueprint id (default: the active one)")
1073
+ hl.set_defaults(func=cmd_holds_list)
1074
+ ha = holds.add_parser("apply", help="take research's newer value (needs data:write)", parents=[common])
1075
+ ha.add_argument("cell_id")
1076
+ ha.add_argument("--finding", help="the `finding` id `holds list` printed for the value you reviewed (a newer one since is refused)")
1077
+ ha.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1078
+ ha.add_argument("--idempotency-key", help="make a retry safe")
1079
+ ha.set_defaults(func=cmd_holds_apply)
1080
+ hr = holds.add_parser("release", help="stop protecting a cell (needs data:write)", parents=[common])
1081
+ hr.add_argument("cell_id")
1082
+ hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1083
+ hr.add_argument("--idempotency-key", help="make a retry safe")
1084
+ hr.set_defaults(func=cmd_holds_release)
1085
+
1086
+ merge = sub.add_parser("merge", help="mark rows as duplicates of one another", parents=[common]).add_subparsers(dest="sub", required=True)
1087
+ for name, helptext, func in (
1088
+ ("preview", "what a merge would do (writes nothing)", cmd_merge_preview),
1089
+ ("apply", "merge rows into a survivor (needs data:write)", cmd_merge_apply),
1090
+ ):
1091
+ mp = merge.add_parser(name, help=helptext, parents=[common])
1092
+ mp.add_argument("entity", help="entity key (see `deepsieve data catalog`)")
1093
+ mp.add_argument("--survivor", required=True, help="the row that stays")
1094
+ mp.add_argument("--loser", required=True, action="append", help="a row merged into it (repeatable)")
1095
+ mp.add_argument("--choice", action="append", help="settle a tie: KEY=survivor|loser (repeatable)")
1096
+ mp.add_argument("--blueprint", help="Blueprint id (default: the active one)")
1097
+ if name == "apply":
1098
+ mp.add_argument("--accept-survivor-on-ties", action="store_true",
1099
+ help="keep the survivor's value on every tie you did not settle")
1100
+ mp.add_argument("--yes", action="store_true", help="do not ask for confirmation")
1101
+ mp.add_argument("--idempotency-key", help="make a retry safe")
1102
+ mp.set_defaults(func=func)
1103
+
667
1104
  data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
668
1105
 
669
1106
  dc = data.add_parser("catalog", help="entities and columns (never guess these)", parents=[common])
@@ -177,6 +177,29 @@ def test_login_rejects_a_bad_key_without_storing_it(monkeypatch):
177
177
  assert cfg.load_profile().api_key is None, "a rejected key must never be persisted"
178
178
 
179
179
 
180
+ @pytest.mark.parametrize(("argv", "expected"), [([], "cli"), (["--preset", "agent"], "agent")])
181
+ def test_device_login_requests_the_chosen_preset(monkeypatch, argv, expected):
182
+ """#1799: the default `cli` token has no `blueprint:create`; `--preset agent`
183
+ is the explicit widening. Assert what goes on the wire, and the credential
184
+ the flow stores, so a flag parsed but never sent cannot pass."""
185
+ sent: list[dict] = []
186
+
187
+ def handler(request: httpx.Request) -> httpx.Response:
188
+ if request.url.path == "/v1/auth/device/code":
189
+ sent.append(json.loads(request.content))
190
+ return httpx.Response(201, json={
191
+ "device_code": "dc", "user_code": "A", "verification_uri": "u",
192
+ "verification_uri_complete": "u", "expires_in": 600, "interval": 0,
193
+ })
194
+ return httpx.Response(200, json={"api_key": "ds_live_dev", "key_name": "CLI"})
195
+
196
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
197
+ rc = main(["login", "--origin", "https://x.test", "--no-browser", *argv])
198
+ assert rc == 0
199
+ assert [b["preset"] for b in sent] == [expected]
200
+ assert cfg.load_profile().api_key == "ds_live_dev"
201
+
202
+
180
203
  def test_commands_refuse_without_a_credential(capsys):
181
204
  assert main(["whoami"]) == 3
182
205
  assert "deepsieve login" in capsys.readouterr().err
@@ -333,6 +356,10 @@ def test_unfinished_run_exits_pending(monkeypatch):
333
356
  ["runs", "list", "--profile", "staging"],
334
357
  ["whoami", "--json"],
335
358
  ["setup", "mcp", "--json"],
359
+ ["changes", "list", "--live", "--json"],
360
+ ["merge", "preview", "books", "--survivor", "s", "--loser", "l", "--json"],
361
+ ["merge", "apply", "books", "--survivor", "s", "--loser", "l", "--loser", "m", "--yes"],
362
+ ["changes", "undo", "00000000-0000-0000-0000-000000000000", "--yes"],
336
363
  ],
337
364
  )
338
365
  def test_documented_command_lines_parse(argv):
@@ -667,3 +694,361 @@ def test_run_create_forwards_each_allowed_source(monkeypatch):
667
694
  # Only blanks → no key at all, never `[]` (the server refuses an empty list).
668
695
  assert main(["runs", "create", "--seeds", "Acme", "--dry-run", "--allowed-source", " "]) == 0
669
696
  assert "allowed_sources" not in seen["body"]
697
+
698
+
699
+ def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
700
+ """gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
701
+ printed, so a run on the wrong one is visible at once."""
702
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
703
+ seen = {}
704
+
705
+ def handler(request: httpx.Request) -> httpx.Response:
706
+ seen["body"] = json.loads(request.content)
707
+ return httpx.Response(
708
+ 202,
709
+ json={"id": "run_1", "status": "queued", "blueprint": {"id": "bp-a", "name": "Widgets"}},
710
+ )
711
+
712
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
713
+ assert main(["runs", "create", "--seeds", "Acme", "--dry-run", "--blueprint", "bp-a"]) == 0
714
+ assert seen["body"]["blueprint_id"] == "bp-a"
715
+ assert "Blueprint: Widgets (bp-a)" in capsys.readouterr().out
716
+
717
+
718
+ def test_runs_get_and_whoami_name_the_blueprint(monkeypatch, capsys):
719
+ """gh #1282: text mode shows the Blueprint a run is on, and whoami keeps its
720
+ workspace line beside the new blueprint line (additive: scripts may parse it)."""
721
+ cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
722
+
723
+ def handler(request: httpx.Request) -> httpx.Response:
724
+ if request.url.path == "/v1/me":
725
+ return httpx.Response(
726
+ 200,
727
+ json={"org_id": "o", "workspace_schema": "ws_x", "role": "owner",
728
+ "blueprint": {"id": "bp-a", "name": "Widgets"}},
729
+ )
730
+ return httpx.Response(
731
+ 200,
732
+ json={"id": "run_1", "status": "completed", "done": True,
733
+ "blueprint": {"id": "bp-a", "name": "Widgets"}},
734
+ )
735
+
736
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
737
+ assert main(["runs", "get", "run_1"]) == 0
738
+ assert main(["whoami"]) == 0
739
+ printed = capsys.readouterr().out
740
+ assert "blueprint Widgets (bp-a)" in printed
741
+ assert "blueprint Widgets (bp-a)" in printed and "workspace ws_x" in printed
742
+
743
+
744
+ # ── chat changes (#2187) ────────────────────────────────────────────────────
745
+
746
+
747
+ _AID = "2d6c5d6e-7a57-4a53-9c1c-7b0e0f6a0b11"
748
+
749
+
750
+ @pytest.mark.parametrize("op", ["confirm", "dismiss", "revert"])
751
+ def test_chat_action_commands_post_to_their_endpoint(monkeypatch, op):
752
+ monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
753
+ seen: list[tuple[str, str]] = []
754
+
755
+ def handler(request):
756
+ seen.append((request.method, request.url.path))
757
+ return httpx.Response(200, json={"action_id": _AID, "status": "ok"})
758
+
759
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
760
+ assert main(["chat", op, _AID, "--json"]) == 0
761
+ assert seen == [("POST", f"/v1/chat/actions/{_AID}/{op}")]
762
+
763
+
764
+ def test_a_chat_action_id_must_be_a_uuid(monkeypatch, capsys):
765
+ monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
766
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: pytest.fail("no request expected")))
767
+ assert main(["chat", "confirm", "../keys"]) != 0
768
+ assert "must be a UUID" in capsys.readouterr().err
769
+
770
+
771
+ def test_chat_thread_lists_each_change_with_its_warning(monkeypatch, capsys):
772
+ monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
773
+ body = {
774
+ "messages": [
775
+ {
776
+ "actions": [
777
+ {
778
+ "id": _AID,
779
+ "status": "pending_confirmation",
780
+ "action_type": "update_cell",
781
+ "record_name": "Acme",
782
+ "column_name": "fee",
783
+ "warnings": [{"code": "unit_conversion", "message": "a unit change"}],
784
+ }
785
+ ]
786
+ }
787
+ ]
788
+ }
789
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
790
+ assert main(["chat", "thread", _AID]) == 0
791
+ printed = capsys.readouterr().out
792
+ assert "pending_confirmation" in printed and "a unit change" in printed and _AID in printed
793
+
794
+
795
+ # ── changes (#1577 2c-2) ─────────────────────────────────────────────────────
796
+
797
+
798
+ CID = "6f1d8b43-95d7-430b-b355-764435da6663"
799
+
800
+
801
+ def _change(**over):
802
+ base = {"object": "change", "id": "c-1", "kind": "renamed", "entity": "books",
803
+ "created_at": "2026-09-25T10:00:00+00:00", "undone_at": None, "undoable": True,
804
+ "old_name": "Bronte Hall", "new_name": "Bronte House", "survivor_name": None,
805
+ "merged_names": None}
806
+ return base | over
807
+
808
+
809
+ def test_changes_list_says_what_each_change_did(monkeypatch, capsys):
810
+ _signed_in(monkeypatch)
811
+ seen = {}
812
+
813
+ def handler(request: httpx.Request) -> httpx.Response:
814
+ seen["path"], seen["params"] = request.url.path, dict(request.url.params)
815
+ return httpx.Response(200, json={
816
+ "data": [
817
+ _change(id="m-1", kind="merge", survivor_name="Keep Press",
818
+ merged_names=["Keep Press Ltd"], old_name=None, new_name=None),
819
+ _change(undone_at="2026-09-25T11:00:00+00:00", undoable=False),
820
+ ],
821
+ "has_more": True, "next_cursor": "abc", "blueprint": {"id": "bp", "name": "Library"},
822
+ })
823
+
824
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
825
+ assert main(["changes", "list", "--kind", "merge", "--live", "--entity", "books"]) == 0
826
+ text = capsys.readouterr().out
827
+ assert seen["path"] == "/v1/changes"
828
+ assert seen["params"] == {"limit": "25", "kind": "merge", "live": "true", "entity": "books"}
829
+ assert "merged Keep Press Ltd into Keep Press" in text and "undoable" in text
830
+ assert "renamed Bronte Hall to Bronte House" in text and "undone" in text
831
+ assert "--cursor abc" in text
832
+
833
+
834
+ def test_changes_undo_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
835
+ _signed_in(monkeypatch)
836
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
837
+ calls = []
838
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
839
+ assert main(["changes", "undo", CID]) == 2
840
+ assert "pass --yes" in capsys.readouterr().err
841
+ assert calls == []
842
+
843
+
844
+ def test_changes_undo_with_yes_posts_and_forwards_the_idempotency_key(monkeypatch, capsys):
845
+ _signed_in(monkeypatch)
846
+ seen = {}
847
+
848
+ def handler(request: httpx.Request) -> httpx.Response:
849
+ seen["method"], seen["path"] = request.method, request.url.path
850
+ seen["key"] = request.headers.get("idempotency-key")
851
+ return httpx.Response(200, json=_change(undone_at="2026-09-25T11:00:00+00:00"))
852
+
853
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
854
+ assert main(["changes", "undo", CID, "--yes", "--idempotency-key", "k-1"]) == 0
855
+ assert (seen["method"], seen["path"], seen["key"]) == ("POST", f"/v1/changes/{CID}/undo", "k-1")
856
+ assert "undone: renamed Bronte Hall to Bronte House" in capsys.readouterr().out
857
+
858
+
859
+ def test_changes_undo_surfaces_the_role_floor_refusal(monkeypatch, capsys):
860
+ _signed_in(monkeypatch)
861
+ body = {"error": {"type": "permission_error", "code": "insufficient_role",
862
+ "message": "data:write needs the member role or above", "retriable": False}}
863
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(403, json=body)))
864
+ assert main(["changes", "undo", CID, "--yes"]) == 1
865
+ assert "needs the member role" in capsys.readouterr().err
866
+
867
+
868
+ def test_changes_undo_names_the_flag_when_cells_changed_since(monkeypatch, capsys):
869
+ """#2199 review n3: the API names its body field; the CLI user is told the flag."""
870
+ _signed_in(monkeypatch)
871
+ body = {"error": {"type": "invalid_request_error", "code": "conflict", "param": "overwrite_changes",
872
+ "message": "Changed since the merge: Author. Undoing restores the values from "
873
+ "before the merge and overwrites the newer ones. Send "
874
+ "`overwrite_changes: true` to undo anyway.", "retriable": False}}
875
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
876
+ assert main(["changes", "undo", CID, "--yes"]) == 1
877
+ err_text = capsys.readouterr().err
878
+ assert "Run again with --overwrite-changes to undo anyway." in err_text
879
+ assert "overwrite_changes: true" not in err_text
880
+
881
+
882
+ def test_changes_undo_refuses_a_non_uuid_id_and_sends_nothing(monkeypatch, capsys):
883
+ """2c-2 review F9: the id goes into the URL path, so a crafted one could reach
884
+ another endpoint. Refused before any request, even with --yes."""
885
+ _signed_in(monkeypatch)
886
+ calls = []
887
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
888
+ assert main(["changes", "undo", "../research/runs/x/cancel#", "--yes"]) == 2
889
+ assert "must be a UUID" in capsys.readouterr().err
890
+ assert calls == []
891
+
892
+
893
+ # ── merge (#1577 2c-3) ───────────────────────────────────────────────────────
894
+
895
+ _S, _L = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
896
+
897
+
898
+ def _merge_result(**over):
899
+ base = {"object": "merge", "change_id": None, "applied": False, "survivor_id": _S,
900
+ "survivor_name": "Tie Press", "merged_names": ["Tie Press Ltd"], "fills": [],
901
+ "conflicts": [{"choice_key": f"author::{_L}", "column": "author", "loser_id": _L,
902
+ "loser_name": "Tie Press Ltd", "survivor_value": "Ann", "loser_value": "Bea",
903
+ "basis": "tie", "resolved_to": "survivor", "chosen": False}],
904
+ "unresolved_ties": [f"author::{_L}"], "blueprint": {"id": "bp", "name": "Library"}}
905
+ return base | over
906
+
907
+
908
+ def test_merge_preview_sends_the_body_and_names_the_open_tie(monkeypatch, capsys):
909
+ _signed_in(monkeypatch)
910
+ seen = {}
911
+
912
+ def handler(request: httpx.Request) -> httpx.Response:
913
+ seen["path"], seen["body"] = request.url.path, json.loads(request.content)
914
+ return httpx.Response(200, json=_merge_result())
915
+
916
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
917
+ assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
918
+ assert seen["path"] == "/v1/merges/preview"
919
+ assert seen["body"] == {"entity": "books", "survivor_id": _S, "loser_ids": [_L], "choices": {}}
920
+ text = capsys.readouterr().out
921
+ assert f"key=author::{_L}" in text and "open ties (1)" in text
922
+
923
+
924
+ def test_merge_preview_labels_a_held_conflict_as_set_on_purpose(monkeypatch, capsys):
925
+ """#2199 review m2: a held conflict is not an evidence decision."""
926
+ _signed_in(monkeypatch)
927
+ held = _merge_result(unresolved_ties=[])
928
+ held["conflicts"][0] |= {"basis": "held"}
929
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=held)))
930
+ assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
931
+ text = capsys.readouterr().out
932
+ assert "[set on purpose]" in text and "[evidence]" not in text
933
+
934
+
935
+ def test_merge_apply_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
936
+ _signed_in(monkeypatch)
937
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
938
+ calls = []
939
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(201)))
940
+ assert main(["merge", "apply", "books", "--survivor", _S, "--loser", _L]) == 2
941
+ assert "pass --yes" in capsys.readouterr().err and calls == []
942
+
943
+
944
+ def test_merge_apply_forwards_choices_the_tie_flag_and_the_key(monkeypatch, capsys):
945
+ _signed_in(monkeypatch)
946
+ seen = {}
947
+
948
+ def handler(request: httpx.Request) -> httpx.Response:
949
+ seen["body"], seen["key"] = json.loads(request.content), request.headers.get("idempotency-key")
950
+ return httpx.Response(201, json=_merge_result(applied=True, change_id="c-9", unresolved_ties=[]))
951
+
952
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
953
+ argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L,
954
+ "--choice", f"author::{_L}=loser", "--accept-survivor-on-ties", "--yes",
955
+ "--idempotency-key", "k-1"]
956
+ assert main(argv) == 0
957
+ assert seen["body"]["choices"] == {f"author::{_L}": "loser"}
958
+ assert seen["body"]["accept_survivor_on_ties"] is True and seen["key"] == "k-1"
959
+ assert "deepsieve changes undo c-9" in capsys.readouterr().out
960
+
961
+
962
+ def test_a_malformed_choice_is_refused_before_any_request(monkeypatch, capsys):
963
+ _signed_in(monkeypatch)
964
+ calls = []
965
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
966
+ assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L, "--choice", "author=maybe"]) == 2
967
+ assert "KEY=survivor or KEY=loser" in capsys.readouterr().err and calls == []
968
+
969
+
970
+ def test_an_unsettled_tie_surfaces_the_servers_refusal(monkeypatch, capsys):
971
+ _signed_in(monkeypatch)
972
+ body = {"error": {"type": "conflict_error", "code": "ties_unresolved", "retriable": False,
973
+ "message": f"1 tie(s) have no choice: author::{_L}.", "param": "choices"}}
974
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
975
+ assert main(["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"]) == 2
976
+ assert f"author::{_L}" in capsys.readouterr().err
977
+
978
+
979
+ # ---------------------------------------------------------------------------
980
+ # #2196 PR 2: holds list | apply | release
981
+ # ---------------------------------------------------------------------------
982
+
983
+ CELL = "cel_YmxhaGJsYWhibGFoYmxhaA"
984
+
985
+
986
+ def _hold_item(**kw):
987
+ return {
988
+ "object": "cell_hold",
989
+ "cell_id": CELL,
990
+ "entity": "books",
991
+ "record_id": "00000000-0000-0000-0000-000000000001",
992
+ "record_name": "Emma",
993
+ "column": "author",
994
+ "value": "Austen",
995
+ "source": "chat_edit",
996
+ "set_at": "2026-09-25T10:00:00+00:00",
997
+ "pending_finding": {"evidence_id": "6f1c2d3e-0000-4000-8000-00000000f1d0", "value": "J. Austen", "source_urls": [],
998
+ "found_at": "2026-09-25T11:00:00+00:00", "stage": "extract"},
999
+ **kw,
1000
+ }
1001
+
1002
+
1003
+ def test_holds_list_shows_the_users_value_beside_what_research_found(monkeypatch, capsys):
1004
+ _signed_in(monkeypatch)
1005
+ seen = {}
1006
+
1007
+ def handler(request: httpx.Request) -> httpx.Response:
1008
+ seen["path"], seen["params"] = request.url.path, dict(request.url.params)
1009
+ return httpx.Response(200, json={"data": [_hold_item()], "has_more": False, "next_cursor": None})
1010
+
1011
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1012
+ assert main(["holds", "list", "--pending"]) == 0
1013
+ assert seen["path"] == "/v1/holds" and seen["params"]["pending"] == "true"
1014
+ text = capsys.readouterr().out
1015
+ assert "Austen" in text and "J. Austen" in text
1016
+ # The finding id is printed, so `holds apply --finding` can name the value reviewed.
1017
+ assert _hold_item()["pending_finding"]["evidence_id"] in text
1018
+
1019
+
1020
+ def test_holds_apply_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
1021
+ _signed_in(monkeypatch)
1022
+ monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
1023
+ calls = []
1024
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1025
+ assert main(["holds", "apply", CELL]) == 2
1026
+ assert "pass --yes" in capsys.readouterr().err
1027
+ assert calls == []
1028
+
1029
+
1030
+ def test_holds_apply_posts_the_finding_and_the_idempotency_key(monkeypatch, capsys):
1031
+ _signed_in(monkeypatch)
1032
+ seen = {}
1033
+
1034
+ def handler(request: httpx.Request) -> httpx.Response:
1035
+ seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
1036
+ seen["body"] = json.loads(request.content or b"{}")
1037
+ return httpx.Response(200, json={"object": "cell", "column": "author", "value": "J. Austen",
1038
+ "held": True})
1039
+
1040
+ monkeypatch.setattr(httpx, "Client", _mock(handler))
1041
+ rc = main(["holds", "apply", CELL, "--finding", CID, "--yes", "--idempotency-key", "k-2"])
1042
+ assert rc == 0
1043
+ assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/apply", "k-2")
1044
+ assert seen["body"] == {"finding_evidence_id": CID}
1045
+ assert "stays protected" in capsys.readouterr().out
1046
+
1047
+
1048
+ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch, capsys):
1049
+ """The id goes into the URL path, so a crafted one could reach another endpoint."""
1050
+ _signed_in(monkeypatch)
1051
+ calls = []
1052
+ monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
1053
+ assert main(["holds", "release", "../research/runs/x/cancel#", "--yes"]) == 2
1054
+ assert calls == []
File without changes