qbraid-cli 0.16.15__tar.gz → 0.16.17__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.
Files changed (77) hide show
  1. {qbraid_cli-0.16.15/qbraid_cli.egg-info → qbraid_cli-0.16.17}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/queue.py +180 -12
  4. qbraid_cli-0.16.17/qbraid_cli/artifacts/__init__.py +7 -0
  5. qbraid_cli-0.16.17/qbraid_cli/artifacts/app.py +245 -0
  6. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/main.py +2 -0
  7. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17/qbraid_cli.egg-info}/PKG-INFO +4 -4
  8. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli.egg-info/SOURCES.txt +2 -0
  9. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli.egg-info/requires.txt +3 -3
  10. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/LICENSE +0 -0
  11. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/README.md +0 -0
  12. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/__init__.py +0 -0
  13. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/_build_info.py +0 -0
  14. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/_version.py +0 -0
  15. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/account/__init__.py +0 -0
  16. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/account/app.py +0 -0
  17. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/admin/__init__.py +0 -0
  18. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/admin/app.py +0 -0
  19. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/admin/headers.py +0 -0
  20. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/admin/validation.py +0 -0
  21. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/__init__.py +0 -0
  22. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/app.py +0 -0
  23. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/keep_alive.py +0 -0
  24. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/permission_response.py +0 -0
  25. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/provider_children.py +0 -0
  26. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/agents/work.py +0 -0
  27. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/ai/__init__.py +0 -0
  28. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/ai/app.py +0 -0
  29. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/ai/token.py +0 -0
  30. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/comms/__init__.py +0 -0
  31. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/comms/app.py +0 -0
  32. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/__init__.py +0 -0
  33. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/app.py +0 -0
  34. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/instances.py +0 -0
  35. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/profiles.py +0 -0
  36. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/server.py +0 -0
  37. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/compute/ssh.py +0 -0
  38. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/configure/__init__.py +0 -0
  39. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/configure/actions.py +0 -0
  40. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/configure/app.py +0 -0
  41. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/devices/__init__.py +0 -0
  42. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/devices/app.py +0 -0
  43. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/devices/validation.py +0 -0
  44. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/__init__.py +0 -0
  45. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/activate.py +0 -0
  46. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/app.py +0 -0
  47. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/create.py +0 -0
  48. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/data_handling.py +0 -0
  49. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/groups.py +0 -0
  50. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/publish.py +0 -0
  51. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/registry.py +0 -0
  52. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/envs/share.py +0 -0
  53. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/exceptions.py +0 -0
  54. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/handlers.py +0 -0
  55. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/jobs/__init__.py +0 -0
  56. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/jobs/app.py +0 -0
  57. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/jobs/groups.py +0 -0
  58. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/kernels/__init__.py +0 -0
  59. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/kernels/app.py +0 -0
  60. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/lab/__init__.py +0 -0
  61. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/lab/app.py +0 -0
  62. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/pip/__init__.py +0 -0
  63. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/pip/app.py +0 -0
  64. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/pip/hooks.py +0 -0
  65. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/projects/__init__.py +0 -0
  66. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/projects/app.py +0 -0
  67. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/py.typed +0 -0
  68. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/skills/__init__.py +0 -0
  69. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/skills/app.py +0 -0
  70. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/troubleshoot/__init__.py +0 -0
  71. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli/troubleshoot/app.py +0 -0
  72. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  73. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli.egg-info/entry_points.txt +0 -0
  74. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/qbraid_cli.egg-info/top_level.txt +0 -0
  75. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/setup.cfg +0 -0
  76. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/tests/test_cli_e2e.py +0 -0
  77. {qbraid_cli-0.16.15 → qbraid_cli-0.16.17}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.15
3
+ Version: 0.16.17
4
4
  Summary: Command Line Interface for interacting with all parts of the qBraid platform.
5
5
  Author-email: qBraid Development Team <contact@qbraid.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
30
30
  Requires-Dist: rich>=10.11.0
31
31
  Requires-Dist: click<=8.5.0
32
32
  Requires-Dist: questionary>=2.0.0
33
- Requires-Dist: qbraid-core>=0.6.34
33
+ Requires-Dist: qbraid-core>=0.6.43
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.43; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.34; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.43; extra == "envs"
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: isort; extra == "dev"
40
40
  Requires-Dist: black; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qbraid-cli"
7
- version = "0.16.15"
7
+ version = "0.16.17"
8
8
  description = "Command Line Interface for interacting with all parts of the qBraid platform."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "qBraid Development Team", email = "contact@qbraid.com" }]
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "rich>=10.11.0",
33
33
  "click<=8.5.0",
34
34
  "questionary>=2.0.0",
35
- "qbraid-core>=0.6.34",
35
+ "qbraid-core>=0.6.43",
36
36
  ]
37
37
  requires-python = ">= 3.10"
38
38
 
@@ -43,8 +43,8 @@ Documentation = "https://docs.qbraid.com/v2/cli/api-reference/qbraid"
43
43
  Discord = "https://discord.gg/KugF6Cnncm"
44
44
 
45
45
  [project.optional-dependencies]
46
- compute = ["qbraid-core[compute]>=0.6.34"]
47
- envs = ["qbraid-core[environments]>=0.6.34"]
46
+ compute = ["qbraid-core[compute]>=0.6.43"]
47
+ envs = ["qbraid-core[environments]>=0.6.43"]
48
48
  dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
49
49
 
50
50
  [project.scripts]
@@ -25,15 +25,17 @@ _MAX_RESPONSE = 2 * 1024 * 1024
25
25
  _SSH_TIMEOUT = 120
26
26
  _STATES = {"queued", "sending", "sent", "uncertain", "cancelled", "stale", "expired", "resolved"}
27
27
  _ACTION_STATES = {"queue_edit": "queued", "queue_cancel": "cancelled", "queue_resolve": "resolved"}
28
+ _HOLD_CAPABILITY = "message-queue-hold-v1"
28
29
 
29
30
 
30
31
  class QueueCommandError(Exception):
31
32
  """A safe failure with a request identifier for reconciliation."""
32
33
 
33
- def __init__(self, code, message, request_id=None):
34
+ def __init__(self, code, message, request_id=None, *, retryable=False):
34
35
  super().__init__(message)
35
36
  self.code = code
36
37
  self.request_id = request_id
38
+ self.retryable = retryable
37
39
 
38
40
 
39
41
  def _plain(value, multiline=False):
@@ -112,6 +114,59 @@ def _valid_receipt(value, session_id, machine_id):
112
114
  )
113
115
 
114
116
 
117
+ def _valid_hold(value, session_id, binding, *, state="held"):
118
+ """Validate the Core hold schema and exact destination binding."""
119
+ keys = {
120
+ "id",
121
+ "sessionId",
122
+ "expectedRuntime",
123
+ "receiver",
124
+ "machineId",
125
+ "bootId",
126
+ "turn",
127
+ "revision",
128
+ "state",
129
+ "reason",
130
+ "createdAt",
131
+ "updatedAt",
132
+ }
133
+ if not isinstance(value, dict) or set(value) != keys or not _valid_runtime(value["expectedRuntime"]):
134
+ return False
135
+ runtime = value["expectedRuntime"]
136
+ if set(runtime) != {"pid", "startedAt", "tool", "terminalId", "serverId", "transcriptPath"}:
137
+ return False
138
+ receiver = value["receiver"]
139
+ native = receiver is None and all(
140
+ isinstance(runtime[key], str) and runtime[key] for key in ("terminalId", "serverId")
141
+ )
142
+ tmux = (
143
+ runtime["terminalId"] is None
144
+ and runtime["serverId"] is None
145
+ and isinstance(receiver, dict)
146
+ and bool(receiver)
147
+ and len(json.dumps(receiver)) <= 4096
148
+ )
149
+ return all(
150
+ (
151
+ _canonical_uuid(value["id"]),
152
+ value["sessionId"] == session_id,
153
+ value["machineId"] == binding["expectedMachineId"],
154
+ value["bootId"] == binding["expectedBootId"],
155
+ _stamp(runtime["startedAt"]),
156
+ isinstance(runtime["transcriptPath"], str) and bool(runtime["transcriptPath"]),
157
+ native or tmux,
158
+ isinstance(value["turn"], str) and re.fullmatch(r"[0-9a-f]{64}", value["turn"]),
159
+ isinstance(value["revision"], int)
160
+ and not isinstance(value["revision"], bool)
161
+ and value["revision"] > 0,
162
+ value["state"] == state,
163
+ isinstance(value["reason"], str),
164
+ _stamp(value["createdAt"]),
165
+ _stamp(value["updatedAt"]),
166
+ )
167
+ )
168
+
169
+
115
170
  def _new_protocol():
116
171
  try:
117
172
  from qbraid_core.services.agents.machine_protocol import MachineProtocol
@@ -281,7 +336,10 @@ class Destination:
281
336
  "UNKNOWN_OUTCOME", "Unconfirmed destination error; inspect request status.", rid
282
337
  )
283
338
  raise QueueCommandError(
284
- error.get("code", "OPERATION_FAILED"), error.get("message", "Request failed."), rid
339
+ error.get("code", "OPERATION_FAILED"),
340
+ error.get("message", "Request failed."),
341
+ rid,
342
+ retryable=error.get("retryable") is True,
285
343
  )
286
344
  result = response.get("result")
287
345
  if not isinstance(result, dict):
@@ -381,6 +439,10 @@ def _emit(result, json_output, request_id=None, *, ok=True):
381
439
  envelope["requestId"] = request_id
382
440
  typer.echo(json.dumps(envelope, ensure_ascii=True))
383
441
  elif "messages" in result:
442
+ if result.get("hold"):
443
+ hold = result["hold"]
444
+ typer.echo(f"Queue paused. Hold {_plain(hold['id'])}, revision {_plain(hold['revision'])}.")
445
+ typer.echo("Use queue continue with this hold ID and revision to allow automatic delivery.")
384
446
  for row in result["messages"]:
385
447
  typer.echo(f"{_plain(row['id'])} {_plain(row['state'])} revision {_plain(row['revision'])}")
386
448
  prose = _plain(row["text"], multiline=True).replace("\n", "\n ")
@@ -396,18 +458,64 @@ def _emit(result, json_output, request_id=None, *, ok=True):
396
458
  typer.echo(status)
397
459
  if result.get("reason"):
398
460
  typer.echo(_plain(result["reason"]))
461
+ elif result.get("interruptRequested") is True:
462
+ typer.echo("Interrupt requested; queued input is paused.")
463
+ elif isinstance(result.get("hold"), dict) and result["hold"].get("state") == "released":
464
+ typer.echo("Queue continued. A running watcher will deliver when the agent is ready.")
465
+ elif result.get("waiting") == "BUSY":
466
+ typer.echo("Queue is busy; the watcher will retry on its next tick.")
467
+ elif "delivered" in result and isinstance(result["delivered"], list):
468
+ typer.echo(f"Delivered {len(result['delivered'])} queued message(s).")
469
+ elif isinstance(result.get("state"), str):
470
+ typer.echo(f"Request status: {_plain(result['state'])}")
471
+ elif type(result.get("canInterrupt")) is bool: # pylint: disable=unidiomatic-typecheck
472
+ if result["canInterrupt"]:
473
+ typer.echo("Interrupt is available for the verified active turn.")
474
+ else:
475
+ reason = result.get("reason")
476
+ typer.echo(
477
+ f"Interrupt unavailable: {_plain(reason)}"
478
+ if isinstance(reason, str)
479
+ else "Interrupt unavailable."
480
+ )
399
481
  else:
400
- typer.echo(_plain(json.dumps(result, ensure_ascii=True)))
482
+ # Never serialize arbitrary protocol results to a human terminal. Some
483
+ # operations include receiver identities and transcript paths.
484
+ typer.echo("Operation completed.")
401
485
 
402
486
 
403
487
  def _mutation(destination, operation, json_output, request_id, **fields):
404
488
  rid = _uuid(request_id)
405
489
  # Written before crossing the transport, so even Ctrl+C leaves the ID visible.
406
490
  typer.echo(f"Request ID: {rid}", err=True)
407
- result = destination.call(operation, rid, **fields)
491
+ result = None
492
+ for attempt in range(4):
493
+ try:
494
+ result = destination.call(operation, rid, **fields)
495
+ break
496
+ except QueueCommandError as error:
497
+ # Core marks only queue-lock refusals that happened before any
498
+ # durable state or terminal write as retryable. A BUSY code alone
499
+ # is insufficient: transport-queue BUSY may not carry that proof.
500
+ if not error.retryable or error.code != "BUSY" or attempt == 3:
501
+ raise
502
+ time.sleep(0.1 * (2**attempt))
503
+ assert result is not None # every successful destination call returns a JSON object
408
504
  if operation == "interrupt":
409
505
  confirmed = (
410
- result.get("interruptRequested") is True and result.get("sessionId") == fields["sessionId"]
506
+ result.get("interruptRequested") is True
507
+ and result.get("sessionId") == fields["sessionId"]
508
+ and _valid_hold(result.get("hold"), fields["sessionId"], destination.binding)
509
+ and result["hold"]["expectedRuntime"] == fields["expectedRuntime"]
510
+ and result["hold"]["turn"] == fields["expectedTurn"]
511
+ )
512
+ elif operation == "queue_resume":
513
+ hold = result.get("hold")
514
+ confirmed = (
515
+ _valid_hold(hold, fields["sessionId"], destination.binding, state="released")
516
+ and hold["id"] == fields["holdId"]
517
+ and hold["revision"] == fields["revision"] + 1
518
+ and hold["expectedRuntime"] == fields["expectedRuntime"]
411
519
  )
412
520
  else:
413
521
  expected_id = rid if operation == "queue_enqueue" else fields["messageId"]
@@ -463,8 +571,8 @@ def enqueue(
463
571
  )
464
572
  if not json_output:
465
573
  typer.echo(
466
- "Queued input needs a running queue watcher on the destination. "
467
- "Sent means terminal submission, not a model response."
574
+ "Queued input needs a running queue watcher. Check queue list for its status; "
575
+ "if paused, use queue continue with the displayed hold ID and revision."
468
576
  )
469
577
 
470
578
 
@@ -482,12 +590,29 @@ def list_messages(
482
590
  messages: list[dict] = []
483
591
  seen, seen_ids = set(), set()
484
592
  cursor = None
593
+ hold = None
594
+ hold_supported = _HOLD_CAPABILITY in destination.snapshot["capabilities"]
485
595
  for _ in range(257):
486
596
  page = destination.call(
487
597
  "queue_list", sessionId=session_id, **({"cursor": cursor} if cursor else {})
488
598
  )
489
599
  if not isinstance(page.get("messages"), list) or "nextCursor" not in page:
490
600
  raise QueueCommandError("INVALID_RESPONSE", "Invalid queue listing.")
601
+ if hold_supported:
602
+ current_hold = page.get("hold")
603
+ if (
604
+ "hold" not in page
605
+ or current_hold is not None
606
+ and not _valid_hold(current_hold, session_id, destination.binding)
607
+ ):
608
+ raise QueueCommandError("INVALID_RESPONSE", "Invalid queue hold receipt.")
609
+ if current_hold is not None and current_hold["expectedRuntime"] != destination.runtime(
610
+ session_id
611
+ ):
612
+ raise QueueCommandError("QUEUE_CHANGED", "The paused runtime changed; list it again.")
613
+ if cursor is not None and current_hold != hold:
614
+ raise QueueCommandError("QUEUE_CHANGED", "The queue hold changed; list it again.")
615
+ hold = current_hold
491
616
  for row in page["messages"]:
492
617
  if (
493
618
  not _valid_receipt(row, session_id, destination.snapshot["machineId"])
@@ -499,7 +624,10 @@ def list_messages(
499
624
  messages.append(row)
500
625
  cursor = page["nextCursor"]
501
626
  if cursor is None:
502
- _emit({"messages": messages, "nextCursor": None}, json_output)
627
+ _emit(
628
+ {"messages": messages, "nextCursor": None, **({"hold": hold} if hold_supported else {})},
629
+ json_output,
630
+ )
503
631
  return
504
632
  if (
505
633
  not _canonical_uuid(cursor)
@@ -512,6 +640,33 @@ def list_messages(
512
640
  raise QueueCommandError("QUEUE_CHANGED", "Queue pagination changed; start the list again.")
513
641
 
514
642
 
643
+ @queue_app.command("continue")
644
+ def continue_queue(
645
+ session_id: str = typer.Argument(..., help="Exact destination session ID."),
646
+ hold_id: str = typer.Argument(..., help="Hold UUID shown by queue list."),
647
+ revision: int = typer.Option(..., "--revision", min=1, help="Hold revision shown by queue list."),
648
+ compute: Optional[str] = typer.Option(None, "--compute"),
649
+ request_id: Optional[str] = typer.Option(None, "--request-id"),
650
+ json_output: bool = typer.Option(False, "--json"),
651
+ ):
652
+ """Continue this paused queue.
653
+
654
+ Releases only the displayed hold. The watcher still checks turn completion,
655
+ pending approvals and uncertain messages; no input is sent by this command.
656
+ """
657
+ with _action(json_output, compute, _HOLD_CAPABILITY) as destination:
658
+ _mutation(
659
+ destination,
660
+ "queue_resume",
661
+ json_output,
662
+ request_id,
663
+ sessionId=session_id,
664
+ expectedRuntime=destination.runtime(session_id),
665
+ holdId=_uuid(hold_id),
666
+ revision=revision,
667
+ )
668
+
669
+
515
670
  @queue_app.command("edit")
516
671
  def edit(
517
672
  session_id: str = typer.Argument(...),
@@ -634,7 +789,12 @@ def watch(
634
789
  raise QueueCommandError(
635
790
  "UNSUPPORTED_OPERATION", "Update qbraid-core to host queued delivery."
636
791
  ) from None
637
- watcher = MessageQueue(destination.protocol)
792
+ # Opt in only when this CLI and destination share the hold contract.
793
+ # Older Core versions retain their original constructor signature.
794
+ watcher_options = (
795
+ {"track_native_holds": True} if _HOLD_CAPABILITY in destination.snapshot["capabilities"] else {}
796
+ )
797
+ watcher = MessageQueue(destination.protocol, **watcher_options)
638
798
  if not once and not json_output:
639
799
  typer.echo(f"Watching this machine's queue every {interval:g}s. Ctrl+C pauses delivery.")
640
800
  try:
@@ -670,11 +830,18 @@ def interrupt(
670
830
  ):
671
831
  """Interrupt one verified active turn.
672
832
 
673
- Request one Escape, without ending the agent process.
833
+ Request one Escape and pause queued input, without ending the agent process.
674
834
  """
675
835
  with _action(json_output, compute, "interrupt-v1") as destination:
836
+ if _HOLD_CAPABILITY not in destination.snapshot["capabilities"]:
837
+ raise QueueCommandError(
838
+ "UNSUPPORTED_OPERATION",
839
+ "Update qbraid-core on this destination to pause queued input safely when interrupting.",
840
+ )
676
841
  runtime = destination.runtime(session_id)
677
- offer = destination.call("interrupt_preflight", sessionId=session_id, expectedRuntime=runtime)
842
+ offer = destination.call(
843
+ "interrupt_preflight", sessionId=session_id, expectedRuntime=runtime, requireQueueHold=True
844
+ )
678
845
  if preflight:
679
846
  available = offer.get("canInterrupt") is True
680
847
  _emit(
@@ -697,6 +864,7 @@ def interrupt(
697
864
  sessionId=session_id,
698
865
  expectedRuntime=runtime,
699
866
  expectedTurn=offer["turn"],
867
+ requireQueueHold=True,
700
868
  )
701
869
  if not json_output:
702
- typer.echo("Interrupt requested. This acknowledges Escape input, not that the agent has stopped.")
870
+ typer.echo("This acknowledges Escape input, not that the agent has stopped.")
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2026, qBraid Development Team
2
+ # All rights reserved.
3
+ """Artifact CLI commands."""
4
+
5
+ from .app import artifacts_app
6
+
7
+ __all__ = ["artifacts_app"]
@@ -0,0 +1,245 @@
1
+ # Copyright (c) 2026, qBraid Development Team
2
+ # All rights reserved.
3
+ """CLI surfaces for hosted HTML artifacts; all operations delegate to Core."""
4
+
5
+ import json
6
+ import re
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Callable, Optional, TypeVar
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from qbraid_cli.agents.work import _safe
17
+ from qbraid_cli.handlers import handle_error
18
+
19
+ artifacts_app = typer.Typer(
20
+ help="Publish and share standalone HTML.\n\nLists show only your artifacts.", no_args_is_help=True
21
+ )
22
+
23
+ CORE_MODULE = "qbraid_core.services.artifacts"
24
+ OLD_CORE = "qbraid artifacts requires qbraid-core 0.6.43 or newer; run: pip install -U qbraid-core"
25
+ PUBLIC_NOTE = "Anyone with the link can view this artifact."
26
+ MAX_ERROR = 300
27
+ _ID = re.compile(r"[a-f0-9]{32}")
28
+ _EMAIL = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
29
+ # An error page (HTML/XML) rather than an API message: show the status instead.
30
+ _MARKUP = re.compile(r"<\s*[!/?a-zA-Z]")
31
+ _Result = TypeVar("_Result")
32
+
33
+
34
+ class Access(str, Enum):
35
+ """Artifact visibility."""
36
+
37
+ PRIVATE = "private"
38
+ SHARED = "shared"
39
+ PUBLIC = "public"
40
+
41
+
42
+ def _client():
43
+ from qbraid_core.services.artifacts import ArtifactsClient
44
+
45
+ return ArtifactsClient()
46
+
47
+
48
+ def _plain(value) -> str:
49
+ """API text made terminal-inert: no controls, escapes, bidi or invisible marks."""
50
+ return _safe(value if isinstance(value, str) else str(value)).plain
51
+
52
+
53
+ def _error_text(err: Exception) -> str:
54
+ if isinstance(err, ModuleNotFoundError) and (err.name or "").startswith(CORE_MODULE):
55
+ return OLD_CORE
56
+ if isinstance(err, UnicodeDecodeError):
57
+ return "The file is not valid UTF-8; save it as UTF-8 and try again"
58
+ text = str(err)
59
+ if _MARKUP.search(text):
60
+ status = getattr(err, "status_code", None)
61
+ text = "Artifact request failed" + (f" (HTTP {status})" if status else "")
62
+ return text + "; the API returned a non-JSON error page"
63
+ text = _plain(text)
64
+ return text if len(text) <= MAX_ERROR else text[: MAX_ERROR - 1] + "…"
65
+
66
+
67
+ def _run(operation: Callable[..., _Result]) -> _Result:
68
+ """Run one operation with a client that is always closed; failures exit 1."""
69
+ client = None
70
+ try:
71
+ client = _client()
72
+ return operation(client)
73
+ except Exception as err: # pylint: disable=broad-exception-caught
74
+ handle_error(message=_error_text(err), include_traceback=False)
75
+ raise # unreachable: handle_error exits
76
+ finally:
77
+ if client is not None:
78
+ client.close()
79
+
80
+
81
+ def _fail(message: str) -> None:
82
+ handle_error(message=message, include_traceback=False)
83
+
84
+
85
+ def _check_id(slug: str) -> str:
86
+ if not _ID.fullmatch(slug):
87
+ raise typer.BadParameter("use the 32-character ID shown by 'qbraid artifacts list'")
88
+ return slug
89
+
90
+
91
+ def _check_file(path: Optional[Path]) -> None:
92
+ """Say precisely why a file is refused before any request; Core re-checks."""
93
+ if path is None:
94
+ return
95
+ if path.is_symlink():
96
+ _fail("Symbolic links are not supported; pass the HTML file itself")
97
+ if path.suffix.lower() not in (".html", ".htm"):
98
+ _fail("Provide one .html or .htm file")
99
+
100
+
101
+ def _check_email(email: str) -> str:
102
+ if not _EMAIL.fullmatch(email):
103
+ raise typer.BadParameter("Provide a valid email address")
104
+ return email
105
+
106
+
107
+ def _dump(model) -> str:
108
+ # ASCII-escaped JSON: raw data for scripts, inert in a terminal.
109
+ return json.dumps(model.model_dump(mode="json", by_alias=True))
110
+
111
+
112
+ def _emit(artifact, as_json: bool):
113
+ if as_json:
114
+ print(_dump(artifact))
115
+ return
116
+ print(f"{_plain(artifact.title)} ({_plain(artifact.visibility)})\n{_plain(artifact.url)}")
117
+ if artifact.visibility == "public":
118
+ print(PUBLIC_NOTE)
119
+
120
+
121
+ ID_ARG = typer.Argument(
122
+ ..., metavar="ID", callback=_check_id, help="Artifact ID from 'qbraid artifacts list'."
123
+ )
124
+ EMAIL_OPT = typer.Option(..., "--email", callback=_check_email)
125
+
126
+
127
+ @artifacts_app.command("publish")
128
+ def publish(
129
+ file: Path = typer.Argument(..., exists=True, dir_okay=False),
130
+ title: Optional[str] = typer.Option(None, "--title"),
131
+ visibility: Access = typer.Option(Access.PRIVATE, "--visibility"),
132
+ as_json: bool = typer.Option(False, "--json"),
133
+ new: bool = typer.Option(False, "--new", help="Publish a copy even if this file is already published."),
134
+ ):
135
+ """Publish one standalone HTML file.
136
+
137
+ Publishing is immediate; there is no review queue. Publishing the same file
138
+ with the same title and access returns the existing artifact.
139
+ """
140
+ _check_file(file)
141
+ artifact = _run(
142
+ lambda client: client.publish(file, title=title, visibility=visibility.value, duplicate=new)
143
+ )
144
+ if artifact.created is False and not as_json:
145
+ print("Already published")
146
+ _emit(artifact, as_json)
147
+
148
+
149
+ @artifacts_app.command("list")
150
+ def list_artifacts(
151
+ page: int = typer.Option(1, min=1),
152
+ limit: int = typer.Option(50, min=1, max=100),
153
+ as_json: bool = typer.Option(False, "--json"),
154
+ ):
155
+ """List your published HTML artifacts.
156
+
157
+ This never lists a public or global catalog.
158
+ """
159
+ result = _run(lambda client: client.list_artifacts(page=page, limit=limit))
160
+ if as_json:
161
+ print(_dump(result))
162
+ return
163
+ # IDs never wrap or truncate so they can be copied; long titles and links fold.
164
+ table = Table()
165
+ table.add_column("ID", no_wrap=True, min_width=32)
166
+ table.add_column("Title", overflow="fold")
167
+ table.add_column("Access", no_wrap=True)
168
+ table.add_column("Link", overflow="fold")
169
+ for item in result.data:
170
+ table.add_row(*(Text(_plain(v)) for v in (item.slug, item.title, item.visibility, item.url)))
171
+ Console().print(table)
172
+ print(f"Page {page}; {result.pagination.total} artifacts total")
173
+
174
+
175
+ @artifacts_app.command("get")
176
+ def get(slug: str = ID_ARG, as_json: bool = typer.Option(False, "--json")):
177
+ """Show one artifact you can view."""
178
+ _emit(_run(lambda client: client.get_artifact(slug)), as_json)
179
+
180
+
181
+ @artifacts_app.command("update")
182
+ def update(
183
+ slug: str = ID_ARG,
184
+ file: Optional[Path] = typer.Option(None, "--file", exists=True, dir_okay=False),
185
+ title: Optional[str] = typer.Option(None, "--title"),
186
+ visibility: Optional[Access] = typer.Option(None, "--visibility"),
187
+ as_json: bool = typer.Option(False, "--json"),
188
+ ):
189
+ """Update HTML, title, or access.
190
+
191
+ The share link is preserved.
192
+ """
193
+ _check_file(file)
194
+ artifact = _run(
195
+ lambda client: client.update(
196
+ slug, path=file, title=title, visibility=visibility.value if visibility else None
197
+ )
198
+ )
199
+ _emit(artifact, as_json)
200
+
201
+
202
+ @artifacts_app.command("share")
203
+ def share(slug: str = ID_ARG, email: str = EMAIL_OPT, as_json: bool = typer.Option(False, "--json")):
204
+ """Grant a qBraid user viewing access.
205
+
206
+ The recipient must sign in. Send them the link after granting access.
207
+ """
208
+ _emit(_run(lambda client: client.share(slug, email)), as_json)
209
+
210
+
211
+ @artifacts_app.command("revoke")
212
+ def revoke(slug: str = ID_ARG, email: str = EMAIL_OPT, as_json: bool = typer.Option(False, "--json")):
213
+ """Remove a collaborator viewing grant.
214
+
215
+ Public artifacts remain accessible via their link.
216
+ """
217
+ _emit(_run(lambda client: client.revoke(slug, email)), as_json)
218
+
219
+
220
+ @artifacts_app.command("unpublish")
221
+ def unpublish(slug: str = ID_ARG, as_json: bool = typer.Option(False, "--json")):
222
+ """Make an artifact owner-only.
223
+
224
+ Disables both public and shared viewing.
225
+ """
226
+ _emit(_run(lambda client: client.update(slug, visibility="private")), as_json)
227
+
228
+
229
+ @artifacts_app.command("delete")
230
+ def delete(
231
+ slug: str = ID_ARG,
232
+ yes: bool = typer.Option(False, "--yes"),
233
+ as_json: bool = typer.Option(False, "--json"),
234
+ ):
235
+ """Delete an artifact and its HTML.
236
+
237
+ Use --yes in non-interactive scripts; --json requires --yes.
238
+ """
239
+ if as_json and not yes:
240
+ print(json.dumps({"deleted": False, "slug": slug, "error": "--json requires --yes"}))
241
+ raise typer.Exit(code=1)
242
+ if not yes:
243
+ typer.confirm("Delete this artifact and its HTML?", abort=True, err=True)
244
+ _run(lambda client: client.delete(slug))
245
+ print(json.dumps({"deleted": True, "slug": slug}) if as_json else "Artifact deleted")
@@ -13,6 +13,7 @@ from qbraid_cli.account import account_app
13
13
  from qbraid_cli.admin import admin_app
14
14
  from qbraid_cli.agents import agents_app
15
15
  from qbraid_cli.ai.app import ai_app
16
+ from qbraid_cli.artifacts import artifacts_app
16
17
  from qbraid_cli.comms import comms_app
17
18
  from qbraid_cli.compute import compute_app
18
19
  from qbraid_cli.configure import configure_app
@@ -55,6 +56,7 @@ app.add_typer(devices_app, name="devices")
55
56
  app.add_typer(jobs_app, name="jobs")
56
57
  app.add_typer(projects_app, name="projects")
57
58
  app.add_typer(skills_app, name="skills")
59
+ app.add_typer(artifacts_app, name="artifacts")
58
60
  app.add_typer(troubleshoot_app, name="troubleshoot")
59
61
 
60
62
  if ENVS_COMMANDS is True:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.15
3
+ Version: 0.16.17
4
4
  Summary: Command Line Interface for interacting with all parts of the qBraid platform.
5
5
  Author-email: qBraid Development Team <contact@qbraid.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
30
30
  Requires-Dist: rich>=10.11.0
31
31
  Requires-Dist: click<=8.5.0
32
32
  Requires-Dist: questionary>=2.0.0
33
- Requires-Dist: qbraid-core>=0.6.34
33
+ Requires-Dist: qbraid-core>=0.6.43
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.43; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.34; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.43; extra == "envs"
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: isort; extra == "dev"
40
40
  Requires-Dist: black; extra == "dev"
@@ -30,6 +30,8 @@ qbraid_cli/agents/work.py
30
30
  qbraid_cli/ai/__init__.py
31
31
  qbraid_cli/ai/app.py
32
32
  qbraid_cli/ai/token.py
33
+ qbraid_cli/artifacts/__init__.py
34
+ qbraid_cli/artifacts/app.py
33
35
  qbraid_cli/comms/__init__.py
34
36
  qbraid_cli/comms/app.py
35
37
  qbraid_cli/compute/__init__.py
@@ -2,10 +2,10 @@ typer>=0.12.1
2
2
  rich>=10.11.0
3
3
  click<=8.5.0
4
4
  questionary>=2.0.0
5
- qbraid-core>=0.6.34
5
+ qbraid-core>=0.6.43
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.34
8
+ qbraid-core[compute]>=0.6.43
9
9
 
10
10
  [dev]
11
11
  isort
@@ -15,4 +15,4 @@ pytest-cov
15
15
  pytest-asyncio
16
16
 
17
17
  [envs]
18
- qbraid-core[environments]>=0.6.34
18
+ qbraid-core[environments]>=0.6.43
File without changes
File without changes
File without changes