qbraid-cli 0.13.1__tar.gz → 0.13.2__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.
- {qbraid_cli-0.13.1/qbraid_cli.egg-info → qbraid_cli-0.13.2}/PKG-INFO +1 -1
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/pyproject.toml +1 -1
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/admin/app.py +3 -3
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/admin/validation.py +2 -1
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/agents/app.py +604 -4
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/app.py +90 -25
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/lab/app.py +5 -3
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2/qbraid_cli.egg-info}/PKG-INFO +1 -1
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/LICENSE +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/README.md +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli.egg-info/SOURCES.txt +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli.egg-info/requires.txt +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/setup.cfg +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/tests/test_cli_e2e.py +0 -0
- {qbraid_cli-0.13.1 → qbraid_cli-0.13.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.2
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qbraid-cli"
|
|
7
|
-
version = "0.13.
|
|
7
|
+
version = "0.13.2"
|
|
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" }]
|
|
@@ -6,7 +6,7 @@ Module defining commands in the 'qbraid admin' namespace.
|
|
|
6
6
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from typing import Optional
|
|
9
|
+
from typing import List, Optional
|
|
10
10
|
|
|
11
11
|
import typer
|
|
12
12
|
|
|
@@ -22,11 +22,11 @@ admin_app = typer.Typer(
|
|
|
22
22
|
|
|
23
23
|
@admin_app.command(name="headers")
|
|
24
24
|
def admin_headers(
|
|
25
|
-
src_paths:
|
|
25
|
+
src_paths: List[str] = typer.Argument(
|
|
26
26
|
..., help="Source file or directory paths to verify.", callback=validate_paths_exist
|
|
27
27
|
),
|
|
28
28
|
header_type: HeaderType = typer.Option("default", "--type", "-t", help="Type of header to use."),
|
|
29
|
-
skip_files:
|
|
29
|
+
skip_files: List[str] = typer.Option([], "--skip", "-s", help="Files to skip during verification."),
|
|
30
30
|
fix: bool = typer.Option(
|
|
31
31
|
False, "--fix", "-f", help="Whether to fix the headers instead of just verifying."
|
|
32
32
|
),
|
|
@@ -7,13 +7,14 @@ Module for validating command arguments for qBraid admin commands.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
from typing import List
|
|
10
11
|
|
|
11
12
|
import typer
|
|
12
13
|
|
|
13
14
|
from qbraid_cli.handlers import _format_list_items
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
def validate_paths_exist(paths:
|
|
17
|
+
def validate_paths_exist(paths: List[str]) -> List[str]:
|
|
17
18
|
"""Verifies that each path in the provided list exists."""
|
|
18
19
|
non_existent_paths = [path for path in paths if not os.path.exists(path)]
|
|
19
20
|
if non_existent_paths:
|
|
@@ -9,7 +9,7 @@ import signal
|
|
|
9
9
|
import time
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
12
|
+
from typing import List, Optional
|
|
13
13
|
|
|
14
14
|
import click
|
|
15
15
|
import typer
|
|
@@ -49,6 +49,20 @@ hooks_app = typer.Typer(
|
|
|
49
49
|
|
|
50
50
|
agents_app.add_typer(hooks_app, name="hooks")
|
|
51
51
|
|
|
52
|
+
inbox_app = typer.Typer(
|
|
53
|
+
help=(
|
|
54
|
+
"Read messages sent to this agent's inbox.\n\n"
|
|
55
|
+
"Follow, filter unread, and mark-read messages sent to this agent\n"
|
|
56
|
+
"session's inbox. Inboxes are per-session JSONL files at\n"
|
|
57
|
+
"~/.qbraid/inboxes/. Every agent session has one; peers use\n"
|
|
58
|
+
"``qbraid agents send-to`` to write to it."
|
|
59
|
+
),
|
|
60
|
+
no_args_is_help=False,
|
|
61
|
+
invoke_without_command=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
agents_app.add_typer(inbox_app, name="inbox")
|
|
65
|
+
|
|
52
66
|
console = Console()
|
|
53
67
|
|
|
54
68
|
|
|
@@ -267,7 +281,7 @@ def agents_deregister():
|
|
|
267
281
|
def agents_list(
|
|
268
282
|
tool: Optional[str] = typer.Option(None, "--tool", "-t", help="Filter by tool (claude, codeq, codex)"),
|
|
269
283
|
status: Optional[str] = typer.Option(None, "--status", help="Filter by status"),
|
|
270
|
-
tag: Optional[
|
|
284
|
+
tag: Optional[List[str]] = typer.Option(None, "--tag", help="Filter by tag (repeatable)"),
|
|
271
285
|
since: Optional[str] = typer.Option(None, "--since", help="Time window (e.g., '1h', '30m', '2d')"),
|
|
272
286
|
compute: Optional[str] = typer.Option(
|
|
273
287
|
None, "--compute", "-c", help="SSH alias for remote compute (runs list on remote)"
|
|
@@ -704,7 +718,7 @@ def agents_status(
|
|
|
704
718
|
@agents_app.command("update")
|
|
705
719
|
def agents_update(
|
|
706
720
|
session_id: str = typer.Argument(..., help="Session ID (full or prefix)"),
|
|
707
|
-
tags: Optional[
|
|
721
|
+
tags: Optional[List[str]] = typer.Option(
|
|
708
722
|
None, "--tag", help="Tag to add (can be repeated)", show_default=False
|
|
709
723
|
),
|
|
710
724
|
name: Optional[str] = typer.Option(None, "--name", help="Set terminal name for the session"),
|
|
@@ -1098,7 +1112,7 @@ def agents_launch(
|
|
|
1098
1112
|
),
|
|
1099
1113
|
parent: Optional[str] = typer.Option(None, "--parent", help="Parent session ID"),
|
|
1100
1114
|
team: Optional[str] = typer.Option(None, "--team", help="Team ID"),
|
|
1101
|
-
tag: Optional[
|
|
1115
|
+
tag: Optional[List[str]] = typer.Option(None, "--tag", help="Tags (repeatable)"),
|
|
1102
1116
|
compute: Optional[str] = typer.Option(
|
|
1103
1117
|
None, "--compute", "-c", help="SSH alias for remote compute server"
|
|
1104
1118
|
),
|
|
@@ -2261,3 +2275,589 @@ def hooks_uninstall():
|
|
|
2261
2275
|
except Exception as e:
|
|
2262
2276
|
handle_error(message=str(e))
|
|
2263
2277
|
raise typer.Exit(code=1)
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
# =============================================================================
|
|
2281
|
+
# Messaging: send-to / inbox / inbox mark-read
|
|
2282
|
+
# =============================================================================
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def _resolve_from_session(explicit: Optional[str]) -> str:
|
|
2286
|
+
"""Determine the sender session id.
|
|
2287
|
+
|
|
2288
|
+
Priority: explicit --from > QBRAID_SESSION_ID env var (set by the qBraid
|
|
2289
|
+
launcher for every agent it spawns) > fail with a helpful error.
|
|
2290
|
+
"""
|
|
2291
|
+
if explicit:
|
|
2292
|
+
return explicit
|
|
2293
|
+
env_sid = os.environ.get("QBRAID_SESSION_ID")
|
|
2294
|
+
if env_sid:
|
|
2295
|
+
return env_sid
|
|
2296
|
+
console.print(
|
|
2297
|
+
"[red]No sender session id available.[/red] "
|
|
2298
|
+
"Pass --from <session-id>, or run this inside a qBraid-managed "
|
|
2299
|
+
"agent shell (which sets QBRAID_SESSION_ID)."
|
|
2300
|
+
)
|
|
2301
|
+
raise typer.Exit(code=1)
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _resolve_session_id(
|
|
2305
|
+
explicit: Optional[str], registry=None, allow_missing_in_registry: bool = True
|
|
2306
|
+
) -> str:
|
|
2307
|
+
"""Determine which session to operate on (for inbox read/mark-read).
|
|
2308
|
+
|
|
2309
|
+
Priority:
|
|
2310
|
+
1. ``explicit`` (from --session): full id, prefix, or tmux/terminal name.
|
|
2311
|
+
Resolved via the registry so prefixes and names work (fixes Persona A's
|
|
2312
|
+
"``inbox --session <prefix>`` returned empty" finding).
|
|
2313
|
+
2. ``$QBRAID_SESSION_ID`` env var (set by the launcher for managed shells).
|
|
2314
|
+
3. Fail with a helpful error.
|
|
2315
|
+
|
|
2316
|
+
When ``allow_missing_in_registry`` is True (the default for inbox ops),
|
|
2317
|
+
a name/prefix that doesn't resolve is returned as-is — the inbox file
|
|
2318
|
+
might still exist even if the registry entry has been pruned. This
|
|
2319
|
+
preserves the "list old inbox that outlived its session" case.
|
|
2320
|
+
"""
|
|
2321
|
+
if explicit:
|
|
2322
|
+
if registry is not None:
|
|
2323
|
+
match = _find_session_by_prefix(registry, explicit)
|
|
2324
|
+
if match is not None:
|
|
2325
|
+
return match.session_id
|
|
2326
|
+
if allow_missing_in_registry:
|
|
2327
|
+
return explicit
|
|
2328
|
+
console.print(
|
|
2329
|
+
f"[red]No session matched '{explicit}'.[/red] "
|
|
2330
|
+
"Try 'qbraid agents list' to see available sessions."
|
|
2331
|
+
)
|
|
2332
|
+
raise typer.Exit(code=1)
|
|
2333
|
+
|
|
2334
|
+
env_sid = os.environ.get("QBRAID_SESSION_ID")
|
|
2335
|
+
if env_sid:
|
|
2336
|
+
return env_sid
|
|
2337
|
+
console.print(
|
|
2338
|
+
"[red]No session id.[/red] Pass --session <id>, or run this inside a "
|
|
2339
|
+
"qBraid-managed agent shell (which sets QBRAID_SESSION_ID)."
|
|
2340
|
+
)
|
|
2341
|
+
raise typer.Exit(code=1)
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
def _resolve_target_session(registry, identifier: str) -> str:
|
|
2345
|
+
"""Resolve a target-session identifier (full id / prefix / name) to a
|
|
2346
|
+
session_id. Prints suggestions on miss.
|
|
2347
|
+
"""
|
|
2348
|
+
match = _find_session_by_prefix(registry, identifier)
|
|
2349
|
+
if match is None:
|
|
2350
|
+
console.print(
|
|
2351
|
+
f"[red]No session matched '{identifier}'.[/red] "
|
|
2352
|
+
"Try 'qbraid agents list' to see available sessions."
|
|
2353
|
+
)
|
|
2354
|
+
raise typer.Exit(code=1)
|
|
2355
|
+
return match.session_id
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def _resolve_team_targets(registry, team_id: str, exclude_session_id: Optional[str] = None) -> list:
|
|
2359
|
+
"""Return every session in a team, optionally excluding one (usually the sender).
|
|
2360
|
+
|
|
2361
|
+
Returns a list of ``AgentSession`` objects. Empty list if the team has no
|
|
2362
|
+
members other than the excluded one.
|
|
2363
|
+
"""
|
|
2364
|
+
all_sessions = registry.load_registry().sessions
|
|
2365
|
+
members = [s for s in all_sessions if s.team_id == team_id and s.session_id != exclude_session_id]
|
|
2366
|
+
return members
|
|
2367
|
+
|
|
2368
|
+
|
|
2369
|
+
def _parse_target_csv(csv: str, registry) -> list[str]:
|
|
2370
|
+
"""Parse ``--to A,B,C`` into a list of resolved session_ids.
|
|
2371
|
+
|
|
2372
|
+
Each token goes through ``_find_session_by_prefix`` — id, prefix, or
|
|
2373
|
+
tmux/terminal name. Fails the whole call if ANY token doesn't resolve
|
|
2374
|
+
(bulk sends are all-or-nothing to avoid partial delivery surprises).
|
|
2375
|
+
"""
|
|
2376
|
+
tokens = [t.strip() for t in csv.split(",") if t.strip()]
|
|
2377
|
+
resolved: list[str] = []
|
|
2378
|
+
unresolved: list[str] = []
|
|
2379
|
+
for tok in tokens:
|
|
2380
|
+
match = _find_session_by_prefix(registry, tok)
|
|
2381
|
+
if match is None:
|
|
2382
|
+
unresolved.append(tok)
|
|
2383
|
+
else:
|
|
2384
|
+
resolved.append(match.session_id)
|
|
2385
|
+
if unresolved:
|
|
2386
|
+
console.print(
|
|
2387
|
+
f"[red]No session matched: {', '.join(unresolved)}[/red]. " "Broadcast aborted; nothing sent."
|
|
2388
|
+
)
|
|
2389
|
+
raise typer.Exit(code=1)
|
|
2390
|
+
return resolved
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
def _deliver_to_many(
|
|
2394
|
+
targets: list[str],
|
|
2395
|
+
text: str,
|
|
2396
|
+
from_sid: str,
|
|
2397
|
+
message_type: str,
|
|
2398
|
+
registry,
|
|
2399
|
+
validate_permissions: bool,
|
|
2400
|
+
) -> int:
|
|
2401
|
+
"""Deliver one message to each target session_id; render results.
|
|
2402
|
+
|
|
2403
|
+
Returns a shell exit code: 0 = all delivered, 1 = zero delivered,
|
|
2404
|
+
3 = partial success. Kept out of the command bodies so send-to and
|
|
2405
|
+
broadcast can share it without duplicating error rendering.
|
|
2406
|
+
"""
|
|
2407
|
+
# pylint: disable-next=import-outside-toplevel
|
|
2408
|
+
from qbraid_core.services.agents import send_message
|
|
2409
|
+
|
|
2410
|
+
# pylint: disable-next=import-outside-toplevel
|
|
2411
|
+
from qbraid_core.services.agents.exceptions import AgentMessagingError
|
|
2412
|
+
|
|
2413
|
+
delivered: list[tuple[str, str]] = []
|
|
2414
|
+
failed: list[tuple[str, str]] = []
|
|
2415
|
+
for target_sid in targets:
|
|
2416
|
+
try:
|
|
2417
|
+
msg = send_message(
|
|
2418
|
+
from_session_id=from_sid,
|
|
2419
|
+
to_session_id=target_sid,
|
|
2420
|
+
text=text,
|
|
2421
|
+
message_type=message_type,
|
|
2422
|
+
registry=registry,
|
|
2423
|
+
validate_permissions=validate_permissions,
|
|
2424
|
+
)
|
|
2425
|
+
delivered.append((target_sid, msg.id))
|
|
2426
|
+
except AgentMessagingError as err:
|
|
2427
|
+
failed.append((target_sid, str(err)))
|
|
2428
|
+
except Exception as err: # pylint: disable=broad-exception-caught
|
|
2429
|
+
failed.append((target_sid, f"{type(err).__name__}: {err}"))
|
|
2430
|
+
|
|
2431
|
+
for sid, mid in delivered:
|
|
2432
|
+
console.print(
|
|
2433
|
+
f"[green]→[/green] Sent to [bold]{sid}[/bold] " f"[dim](id={mid}, type={message_type})[/dim]"
|
|
2434
|
+
)
|
|
2435
|
+
for sid, reason in failed:
|
|
2436
|
+
console.print(f"[red]✗[/red] [bold]{sid}[/bold]: {reason}")
|
|
2437
|
+
|
|
2438
|
+
if failed and not delivered:
|
|
2439
|
+
return 1
|
|
2440
|
+
if failed:
|
|
2441
|
+
return 3
|
|
2442
|
+
return 0
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
@agents_app.command("send-to")
|
|
2446
|
+
def agents_send_to(
|
|
2447
|
+
to: str = typer.Argument(
|
|
2448
|
+
...,
|
|
2449
|
+
help="Target session id (full or prefix), tmux window name, or terminal name.",
|
|
2450
|
+
),
|
|
2451
|
+
text: str = typer.Argument(..., help="Message body."),
|
|
2452
|
+
from_: Optional[str] = typer.Option(
|
|
2453
|
+
None,
|
|
2454
|
+
"--from",
|
|
2455
|
+
help=(
|
|
2456
|
+
"Sender session id. Defaults to $QBRAID_SESSION_ID (set by the "
|
|
2457
|
+
"qBraid launcher for every managed agent shell)."
|
|
2458
|
+
),
|
|
2459
|
+
),
|
|
2460
|
+
message_type: str = typer.Option(
|
|
2461
|
+
"message",
|
|
2462
|
+
"--type",
|
|
2463
|
+
help=(
|
|
2464
|
+
"One of: message, task_assignment, status_update, shutdown_request. "
|
|
2465
|
+
"Controls how the recipient's UI/hooks categorize it."
|
|
2466
|
+
),
|
|
2467
|
+
),
|
|
2468
|
+
no_permission_check: bool = typer.Option(
|
|
2469
|
+
False,
|
|
2470
|
+
"--no-permission-check",
|
|
2471
|
+
help=(
|
|
2472
|
+
"Skip the sender→target permission check (agent-type rules from "
|
|
2473
|
+
"qbraid_core.services.agents.types.can_message). Use for testing."
|
|
2474
|
+
),
|
|
2475
|
+
),
|
|
2476
|
+
) -> None:
|
|
2477
|
+
"""Send a message to another agent's inbox.
|
|
2478
|
+
|
|
2479
|
+
For multi-recipient / team broadcast, use ``qbraid agents broadcast``.
|
|
2480
|
+
|
|
2481
|
+
Delivery is a JSONL append at ~/.qbraid/inboxes/<to-session-id>.jsonl
|
|
2482
|
+
(local host only — cross-host messaging isn't yet supported and will raise
|
|
2483
|
+
a clear error). Permission rules (leader/worker/observer/reviewer) are
|
|
2484
|
+
enforced by default.
|
|
2485
|
+
"""
|
|
2486
|
+
try:
|
|
2487
|
+
# pylint: disable-next=import-outside-toplevel,unused-import
|
|
2488
|
+
from qbraid_core.services.agents import send_message # noqa: F401
|
|
2489
|
+
except ImportError as err:
|
|
2490
|
+
handle_error(message=f"qbraid-core messaging unavailable: {err}", include_traceback=False)
|
|
2491
|
+
raise typer.Exit(code=1) from err
|
|
2492
|
+
|
|
2493
|
+
registry = _get_registry()
|
|
2494
|
+
from_sid = _resolve_from_session(from_)
|
|
2495
|
+
to_sid = _resolve_target_session(registry, to)
|
|
2496
|
+
|
|
2497
|
+
code = _deliver_to_many(
|
|
2498
|
+
[to_sid],
|
|
2499
|
+
text,
|
|
2500
|
+
from_sid=from_sid,
|
|
2501
|
+
message_type=message_type,
|
|
2502
|
+
registry=registry,
|
|
2503
|
+
validate_permissions=not no_permission_check,
|
|
2504
|
+
)
|
|
2505
|
+
if code:
|
|
2506
|
+
raise typer.Exit(code=code)
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
@agents_app.command("broadcast")
|
|
2510
|
+
def agents_broadcast(
|
|
2511
|
+
text: str = typer.Argument(..., help="Message body — delivered to every target."),
|
|
2512
|
+
team: Optional[str] = typer.Option(
|
|
2513
|
+
None,
|
|
2514
|
+
"--team",
|
|
2515
|
+
help=(
|
|
2516
|
+
"Deliver to every session with this team_id (excluding the sender). " "Use one of --team or --to."
|
|
2517
|
+
),
|
|
2518
|
+
),
|
|
2519
|
+
to_csv: Optional[str] = typer.Option(
|
|
2520
|
+
None,
|
|
2521
|
+
"--to",
|
|
2522
|
+
help=(
|
|
2523
|
+
"Comma-separated list of target session ids/prefixes/names for "
|
|
2524
|
+
"multi-recipient send. Use one of --team or --to."
|
|
2525
|
+
),
|
|
2526
|
+
),
|
|
2527
|
+
from_: Optional[str] = typer.Option(
|
|
2528
|
+
None,
|
|
2529
|
+
"--from",
|
|
2530
|
+
help="Sender session id. Defaults to $QBRAID_SESSION_ID.",
|
|
2531
|
+
),
|
|
2532
|
+
message_type: str = typer.Option(
|
|
2533
|
+
"message",
|
|
2534
|
+
"--type",
|
|
2535
|
+
help=(
|
|
2536
|
+
"One of: message, task_assignment, status_update, shutdown_request. "
|
|
2537
|
+
"Controls how the recipient's UI/hooks categorize it."
|
|
2538
|
+
),
|
|
2539
|
+
),
|
|
2540
|
+
no_permission_check: bool = typer.Option(
|
|
2541
|
+
False,
|
|
2542
|
+
"--no-permission-check",
|
|
2543
|
+
help="Skip the sender→target permission check. Use for testing.",
|
|
2544
|
+
),
|
|
2545
|
+
) -> None:
|
|
2546
|
+
"""Broadcast to multiple agent inboxes.
|
|
2547
|
+
|
|
2548
|
+
Two selectors — pick exactly one:
|
|
2549
|
+
* ``--team <team_id>`` — every session with that team_id (except sender)
|
|
2550
|
+
* ``--to A,B,C`` — explicit id/prefix/name list
|
|
2551
|
+
|
|
2552
|
+
Delivery is all-or-nothing on target resolution: if any target can't be
|
|
2553
|
+
resolved, NOTHING is sent (avoids partial-delivery surprises). Once
|
|
2554
|
+
resolved, per-target send failures are reported individually; a partial
|
|
2555
|
+
delivery exits with code 3.
|
|
2556
|
+
"""
|
|
2557
|
+
try:
|
|
2558
|
+
# pylint: disable-next=import-outside-toplevel,unused-import
|
|
2559
|
+
from qbraid_core.services.agents import send_message # noqa: F401
|
|
2560
|
+
except ImportError as err:
|
|
2561
|
+
handle_error(message=f"qbraid-core messaging unavailable: {err}", include_traceback=False)
|
|
2562
|
+
raise typer.Exit(code=1) from err
|
|
2563
|
+
|
|
2564
|
+
modes_used = sum(bool(x) for x in (team, to_csv))
|
|
2565
|
+
if modes_used != 1:
|
|
2566
|
+
# Use typer's own error path so exit code is 2 (usage error) not 1.
|
|
2567
|
+
console.print("[red]Error:[/red] Use exactly one of --team or --to.")
|
|
2568
|
+
raise typer.Exit(code=2)
|
|
2569
|
+
|
|
2570
|
+
registry = _get_registry()
|
|
2571
|
+
from_sid = _resolve_from_session(from_)
|
|
2572
|
+
|
|
2573
|
+
if team is not None:
|
|
2574
|
+
members = _resolve_team_targets(registry, team, exclude_session_id=from_sid)
|
|
2575
|
+
if not members:
|
|
2576
|
+
handle_error(
|
|
2577
|
+
message=(
|
|
2578
|
+
f"No sessions found in team '{team}' (other than the sender). "
|
|
2579
|
+
"Check 'qbraid agents list' for team_id values."
|
|
2580
|
+
),
|
|
2581
|
+
include_traceback=False,
|
|
2582
|
+
)
|
|
2583
|
+
raise typer.Exit(code=1)
|
|
2584
|
+
targets = [m.session_id for m in members]
|
|
2585
|
+
else:
|
|
2586
|
+
# to_csv is not None due to the modes_used check above
|
|
2587
|
+
assert to_csv is not None
|
|
2588
|
+
targets = _parse_target_csv(to_csv, registry)
|
|
2589
|
+
|
|
2590
|
+
code = _deliver_to_many(
|
|
2591
|
+
targets,
|
|
2592
|
+
text,
|
|
2593
|
+
from_sid=from_sid,
|
|
2594
|
+
message_type=message_type,
|
|
2595
|
+
registry=registry,
|
|
2596
|
+
validate_permissions=not no_permission_check,
|
|
2597
|
+
)
|
|
2598
|
+
if code:
|
|
2599
|
+
raise typer.Exit(code=code)
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def _render_messages_table(messages, session_id: str, full_ids: bool = False) -> None:
|
|
2603
|
+
"""Render a list of AgentMessage to the console as a table.
|
|
2604
|
+
|
|
2605
|
+
Args:
|
|
2606
|
+
messages: list of AgentMessage.
|
|
2607
|
+
session_id: session whose inbox we're rendering (for the title).
|
|
2608
|
+
full_ids: when True, show full session_ids in the ``From`` column and
|
|
2609
|
+
the table title. Default is a 12-char prefix so the table stays
|
|
2610
|
+
narrow — but when ``list`` shows multiple sessions with the same
|
|
2611
|
+
prefix, the prefix version is ambiguous (Persona A + B finding).
|
|
2612
|
+
"""
|
|
2613
|
+
if not messages:
|
|
2614
|
+
display_sid = session_id if full_ids else session_id[:12]
|
|
2615
|
+
console.print(f"[dim]Inbox for {display_sid} is empty.[/dim]")
|
|
2616
|
+
return
|
|
2617
|
+
|
|
2618
|
+
title_sid = session_id if full_ids else session_id[:12]
|
|
2619
|
+
table = Table(show_header=True, header_style="bold", title=f"Inbox: {title_sid}")
|
|
2620
|
+
table.add_column("Time", style="dim", no_wrap=True)
|
|
2621
|
+
table.add_column("From", style="cyan", no_wrap=full_ids)
|
|
2622
|
+
table.add_column("Type", style="yellow", no_wrap=True)
|
|
2623
|
+
table.add_column("R", justify="center", no_wrap=True)
|
|
2624
|
+
table.add_column("Message")
|
|
2625
|
+
|
|
2626
|
+
for msg in messages:
|
|
2627
|
+
ts = datetime.fromtimestamp(msg.timestamp).strftime("%m-%d %H:%M")
|
|
2628
|
+
read_marker = "[dim]✓[/dim]" if msg.read else "[bold red]•[/bold red]"
|
|
2629
|
+
from_display = msg.from_session if full_ids else msg.from_session[:12]
|
|
2630
|
+
table.add_row(
|
|
2631
|
+
ts,
|
|
2632
|
+
from_display,
|
|
2633
|
+
msg.type.value,
|
|
2634
|
+
read_marker,
|
|
2635
|
+
msg.text,
|
|
2636
|
+
)
|
|
2637
|
+
console.print(table)
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
|
+
@inbox_app.callback()
|
|
2641
|
+
def inbox_default( # pylint: disable=too-many-arguments
|
|
2642
|
+
ctx: typer.Context,
|
|
2643
|
+
session: Optional[str] = typer.Option(
|
|
2644
|
+
None,
|
|
2645
|
+
"--session",
|
|
2646
|
+
"-s",
|
|
2647
|
+
help=(
|
|
2648
|
+
"Session whose inbox to read. Accepts full id, prefix, or "
|
|
2649
|
+
"tmux/terminal name (resolved via the registry). Defaults to "
|
|
2650
|
+
"$QBRAID_SESSION_ID."
|
|
2651
|
+
),
|
|
2652
|
+
),
|
|
2653
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Stream new messages as they arrive."),
|
|
2654
|
+
unread: bool = typer.Option(False, "--unread", "-u", help="Show only unread messages."),
|
|
2655
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max messages to show (most recent)."),
|
|
2656
|
+
mark_read_flag: bool = typer.Option(
|
|
2657
|
+
True,
|
|
2658
|
+
"--mark-read/--no-mark-read",
|
|
2659
|
+
help="Mark shown messages as read after display. Default on.",
|
|
2660
|
+
),
|
|
2661
|
+
wide: bool = typer.Option(
|
|
2662
|
+
False,
|
|
2663
|
+
"--wide",
|
|
2664
|
+
"-w",
|
|
2665
|
+
help=(
|
|
2666
|
+
"Show full session ids in the From column and title (not the "
|
|
2667
|
+
"12-char prefix). Use when multiple senders share a prefix."
|
|
2668
|
+
),
|
|
2669
|
+
),
|
|
2670
|
+
) -> None:
|
|
2671
|
+
"""Read messages from an agent session's inbox.
|
|
2672
|
+
|
|
2673
|
+
Without a subcommand this reads the inbox (optionally streams with
|
|
2674
|
+
--follow). Use ``qbraid agents inbox mark-read`` to mark manually.
|
|
2675
|
+
"""
|
|
2676
|
+
if ctx.invoked_subcommand is not None:
|
|
2677
|
+
return
|
|
2678
|
+
|
|
2679
|
+
try:
|
|
2680
|
+
# pylint: disable-next=import-outside-toplevel
|
|
2681
|
+
from qbraid_core.services.agents import mark_read, read_inbox, watch_inbox
|
|
2682
|
+
except ImportError as err:
|
|
2683
|
+
handle_error(message=f"qbraid-core messaging unavailable: {err}", include_traceback=False)
|
|
2684
|
+
raise typer.Exit(code=1) from err
|
|
2685
|
+
|
|
2686
|
+
# Registry-aware resolution so `--session <prefix>` and tmux names work
|
|
2687
|
+
# — Persona A's #1 finding was that inbox silently returned "empty"
|
|
2688
|
+
# for a valid prefix. Registry is only touched when an explicit
|
|
2689
|
+
# --session was passed, so the env-var-fallback code path stays cheap
|
|
2690
|
+
# and testable without mocking the registry.
|
|
2691
|
+
session_id = _resolve_session_id(session, registry=_get_registry() if session else None)
|
|
2692
|
+
|
|
2693
|
+
if follow:
|
|
2694
|
+
existing = read_inbox(session_id, unread_only=unread, limit=limit)
|
|
2695
|
+
_render_messages_table(existing, session_id, full_ids=wide)
|
|
2696
|
+
if mark_read_flag and existing:
|
|
2697
|
+
mark_read(session_id, message_ids=[m.id for m in existing if not m.read])
|
|
2698
|
+
|
|
2699
|
+
console.print("[dim]Watching for new messages… Ctrl-C to stop.[/dim]")
|
|
2700
|
+
try:
|
|
2701
|
+
for msg in watch_inbox(session_id):
|
|
2702
|
+
ts = datetime.fromtimestamp(msg.timestamp).strftime("%H:%M:%S")
|
|
2703
|
+
from_display = msg.from_session if wide else msg.from_session[:12]
|
|
2704
|
+
console.print(
|
|
2705
|
+
f"[dim]{ts}[/dim] "
|
|
2706
|
+
f"[cyan]{from_display}[/cyan] "
|
|
2707
|
+
f"[yellow]({msg.type.value})[/yellow] {msg.text}"
|
|
2708
|
+
)
|
|
2709
|
+
if mark_read_flag:
|
|
2710
|
+
mark_read(session_id, message_ids=[msg.id])
|
|
2711
|
+
except KeyboardInterrupt:
|
|
2712
|
+
console.print("[dim]Stopped watching.[/dim]")
|
|
2713
|
+
return
|
|
2714
|
+
|
|
2715
|
+
messages = read_inbox(session_id, unread_only=unread, limit=limit)
|
|
2716
|
+
_render_messages_table(messages, session_id, full_ids=wide)
|
|
2717
|
+
|
|
2718
|
+
if mark_read_flag and messages:
|
|
2719
|
+
unread_ids = [m.id for m in messages if not m.read]
|
|
2720
|
+
if unread_ids:
|
|
2721
|
+
n = mark_read(session_id, message_ids=unread_ids)
|
|
2722
|
+
if n:
|
|
2723
|
+
console.print(f"[dim]Marked {n} message(s) as read.[/dim]")
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
@inbox_app.command("mark-read")
|
|
2727
|
+
def inbox_mark_read(
|
|
2728
|
+
session: Optional[str] = typer.Option(
|
|
2729
|
+
None,
|
|
2730
|
+
"--session",
|
|
2731
|
+
"-s",
|
|
2732
|
+
help=(
|
|
2733
|
+
"Session whose inbox to update. Accepts full id, prefix, or "
|
|
2734
|
+
"tmux/terminal name. Defaults to $QBRAID_SESSION_ID."
|
|
2735
|
+
),
|
|
2736
|
+
),
|
|
2737
|
+
message_id: Optional[List[str]] = typer.Option(
|
|
2738
|
+
None,
|
|
2739
|
+
"--message-id",
|
|
2740
|
+
"-m",
|
|
2741
|
+
help=(
|
|
2742
|
+
"Specific message id(s) to mark. May be passed multiple times. "
|
|
2743
|
+
"Omit to mark ALL unread messages in the inbox."
|
|
2744
|
+
),
|
|
2745
|
+
),
|
|
2746
|
+
) -> None:
|
|
2747
|
+
"""Mark inbox messages as read.
|
|
2748
|
+
|
|
2749
|
+
Without --message-id, marks every unread message in the session's inbox.
|
|
2750
|
+
"""
|
|
2751
|
+
try:
|
|
2752
|
+
# pylint: disable-next=import-outside-toplevel
|
|
2753
|
+
from qbraid_core.services.agents import mark_read
|
|
2754
|
+
except ImportError as err:
|
|
2755
|
+
handle_error(message=f"qbraid-core messaging unavailable: {err}", include_traceback=False)
|
|
2756
|
+
raise typer.Exit(code=1) from err
|
|
2757
|
+
|
|
2758
|
+
session_id = _resolve_session_id(session, registry=_get_registry() if session else None)
|
|
2759
|
+
ids = list(message_id) if message_id else None
|
|
2760
|
+
|
|
2761
|
+
n = mark_read(session_id, message_ids=ids)
|
|
2762
|
+
console.print(f"[green]Marked {n} message(s) as read[/green] for {session_id[:12]}.")
|
|
2763
|
+
|
|
2764
|
+
|
|
2765
|
+
# =============================================================================
|
|
2766
|
+
# `qbraid agents team <id>` — leader-centric roster view
|
|
2767
|
+
# =============================================================================
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
@agents_app.command("team")
|
|
2771
|
+
def agents_team(
|
|
2772
|
+
team_id: str = typer.Argument(..., help="Team identifier (matches AgentSession.team_id)."),
|
|
2773
|
+
wide: bool = typer.Option(
|
|
2774
|
+
False,
|
|
2775
|
+
"--wide",
|
|
2776
|
+
"-w",
|
|
2777
|
+
help="Show full session ids instead of the 12-char prefix.",
|
|
2778
|
+
),
|
|
2779
|
+
) -> None:
|
|
2780
|
+
"""Show a team roster with unread counts.
|
|
2781
|
+
|
|
2782
|
+
Answers the leader-orchestrator question "who is on my team, how many
|
|
2783
|
+
unread messages do they have, and what was the last thing they heard from
|
|
2784
|
+
anyone?" — which today requires peeking each worker's inbox individually.
|
|
2785
|
+
|
|
2786
|
+
Rows are sorted with the current session first (identified via
|
|
2787
|
+
$QBRAID_SESSION_ID), then by agent_type (leader → worker → observer →
|
|
2788
|
+
reviewer), then by session_id.
|
|
2789
|
+
"""
|
|
2790
|
+
try:
|
|
2791
|
+
# pylint: disable-next=import-outside-toplevel
|
|
2792
|
+
from qbraid_core.services.agents import read_inbox
|
|
2793
|
+
except ImportError as err:
|
|
2794
|
+
handle_error(message=f"qbraid-core messaging unavailable: {err}", include_traceback=False)
|
|
2795
|
+
raise typer.Exit(code=1) from err
|
|
2796
|
+
|
|
2797
|
+
registry = _get_registry()
|
|
2798
|
+
all_sessions = registry.load_registry().sessions
|
|
2799
|
+
members = [s for s in all_sessions if s.team_id == team_id]
|
|
2800
|
+
|
|
2801
|
+
if not members:
|
|
2802
|
+
console.print(
|
|
2803
|
+
f"[yellow]No sessions found with team_id '{team_id}'.[/yellow] "
|
|
2804
|
+
"Check 'qbraid agents list' for known team_ids."
|
|
2805
|
+
)
|
|
2806
|
+
raise typer.Exit(code=0)
|
|
2807
|
+
|
|
2808
|
+
me_sid = os.environ.get("QBRAID_SESSION_ID")
|
|
2809
|
+
role_order = {"leader": 0, "worker": 1, "reviewer": 2, "observer": 3}
|
|
2810
|
+
|
|
2811
|
+
def _sort_key(session):
|
|
2812
|
+
return (
|
|
2813
|
+
0 if session.session_id == me_sid else 1,
|
|
2814
|
+
role_order.get(session.agent_type or "worker", 99),
|
|
2815
|
+
session.session_id,
|
|
2816
|
+
)
|
|
2817
|
+
|
|
2818
|
+
members.sort(key=_sort_key)
|
|
2819
|
+
|
|
2820
|
+
table = Table(
|
|
2821
|
+
show_header=True,
|
|
2822
|
+
header_style="bold",
|
|
2823
|
+
title=f"Team: {team_id} ({len(members)} member{'s' if len(members) != 1 else ''})",
|
|
2824
|
+
)
|
|
2825
|
+
table.add_column("You", justify="center", no_wrap=True)
|
|
2826
|
+
table.add_column("Session", style="cyan", no_wrap=wide)
|
|
2827
|
+
table.add_column("Type", style="yellow", no_wrap=True)
|
|
2828
|
+
table.add_column("Status", no_wrap=True)
|
|
2829
|
+
table.add_column("Unread", justify="right", no_wrap=True)
|
|
2830
|
+
table.add_column("Last msg from", style="dim")
|
|
2831
|
+
table.add_column("Last msg text", style="dim")
|
|
2832
|
+
|
|
2833
|
+
status_styles = {"idle": "green", "active": "green", "stopped": "red"}
|
|
2834
|
+
|
|
2835
|
+
for s in members:
|
|
2836
|
+
try:
|
|
2837
|
+
# Read up to 10K so the unread count reflects the full inbox
|
|
2838
|
+
# for realistic sizes (matches watch_inbox's cap). Members with
|
|
2839
|
+
# >10K messages will still undercount, but that's the same
|
|
2840
|
+
# boundary the follower uses.
|
|
2841
|
+
inbox = read_inbox(s.session_id, limit=10_000)
|
|
2842
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
2843
|
+
inbox = []
|
|
2844
|
+
unread_count = sum(1 for m in inbox if not m.read)
|
|
2845
|
+
last = inbox[-1] if inbox else None
|
|
2846
|
+
|
|
2847
|
+
session_display = s.session_id if wide else s.session_id[:12]
|
|
2848
|
+
last_from = (last.from_session if wide else last.from_session[:12]) if last else "-"
|
|
2849
|
+
last_text = (last.text[:60] + "…") if last and len(last.text) > 60 else (last.text if last else "-")
|
|
2850
|
+
status_style = status_styles.get(s.status or "", "dim")
|
|
2851
|
+
unread_style = "bold red" if unread_count else "dim"
|
|
2852
|
+
|
|
2853
|
+
table.add_row(
|
|
2854
|
+
"★" if s.session_id == me_sid else "",
|
|
2855
|
+
session_display,
|
|
2856
|
+
s.agent_type or "worker",
|
|
2857
|
+
f"[{status_style}]{s.status or 'unknown'}[/{status_style}]",
|
|
2858
|
+
f"[{unread_style}]{unread_count}[/{unread_style}]",
|
|
2859
|
+
last_from,
|
|
2860
|
+
last_text,
|
|
2861
|
+
)
|
|
2862
|
+
|
|
2863
|
+
console.print(table)
|
|
@@ -10,7 +10,7 @@ import shlex
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import sys
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import TYPE_CHECKING, Optional
|
|
13
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
14
14
|
|
|
15
15
|
import typer
|
|
16
16
|
from qbraid_core.services.environments.schema import EnvironmentConfig
|
|
@@ -359,11 +359,23 @@ def envs_create( # pylint: disable=too-many-statements
|
|
|
359
359
|
@envs_app.command(name="uninstall")
|
|
360
360
|
def envs_uninstall(
|
|
361
361
|
name: str = typer.Option(..., "-n", "--name", help="Name of the environment to remove"),
|
|
362
|
+
delete_cloud: bool = typer.Option(
|
|
363
|
+
False,
|
|
364
|
+
"--delete-cloud",
|
|
365
|
+
"-c",
|
|
366
|
+
help=(
|
|
367
|
+
"Also delete the environment from qBraid cloud (requires ownership). "
|
|
368
|
+
"By default only the local copy is removed and the cloud slug is preserved, "
|
|
369
|
+
"so you can reinstall it later."
|
|
370
|
+
),
|
|
371
|
+
),
|
|
362
372
|
auto_confirm: bool = typer.Option(False, "--yes", "-y", help="Automatically answer 'yes' to all prompts"),
|
|
363
373
|
) -> None:
|
|
364
374
|
"""Remove a local environment.
|
|
365
375
|
|
|
366
|
-
|
|
376
|
+
By default this removes only the local copy — the cloud environment is
|
|
377
|
+
preserved so you can reinstall it later. Pass ``--delete-cloud`` to also
|
|
378
|
+
retire the cloud environment (requires ownership).
|
|
367
379
|
"""
|
|
368
380
|
import asyncio
|
|
369
381
|
|
|
@@ -374,38 +386,55 @@ def envs_uninstall(
|
|
|
374
386
|
emc = EnvironmentManagerClient()
|
|
375
387
|
|
|
376
388
|
# Run async uninstall method
|
|
377
|
-
result = asyncio.run(
|
|
389
|
+
result = asyncio.run(
|
|
390
|
+
emc.uninstall_environment_local(slug, delete_metadata=delete_cloud, force=auto_confirm)
|
|
391
|
+
)
|
|
378
392
|
return result
|
|
379
393
|
|
|
380
|
-
def gather_local_data(env_name: str) -> tuple[Path | None, str | None]:
|
|
381
|
-
"""
|
|
394
|
+
def gather_local_data(env_name: str) -> tuple[Path | None, str | None, str | None]:
|
|
395
|
+
"""Return ``(path, env_id, slug)`` for a name/env_id/slug lookup.
|
|
396
|
+
|
|
397
|
+
``slug`` is ``None`` when the env was never uploaded (local-only env);
|
|
398
|
+
the ``--delete-cloud`` hint below uses this to avoid pointing users at
|
|
399
|
+
a flag that would be a no-op for their env.
|
|
400
|
+
"""
|
|
401
|
+
from qbraid_core.services.environments.registry import EnvironmentRegistryManager
|
|
402
|
+
|
|
382
403
|
installed, aliases = installed_envs_data()
|
|
404
|
+
registry_mgr = None
|
|
405
|
+
|
|
406
|
+
def _slug_for(env_id: str) -> str | None:
|
|
407
|
+
nonlocal registry_mgr
|
|
408
|
+
try:
|
|
409
|
+
if registry_mgr is None:
|
|
410
|
+
registry_mgr = EnvironmentRegistryManager()
|
|
411
|
+
entry = registry_mgr.registry.environments.get(env_id)
|
|
412
|
+
return entry.slug if entry else None
|
|
413
|
+
except Exception:
|
|
414
|
+
return None
|
|
415
|
+
|
|
383
416
|
# Try to find by name first, then by env_id
|
|
384
417
|
if env_name in aliases:
|
|
385
418
|
env_id = aliases[env_name]
|
|
386
|
-
|
|
387
|
-
return path, env_id
|
|
419
|
+
return installed[env_id], env_id, _slug_for(env_id)
|
|
388
420
|
if env_name in installed:
|
|
389
|
-
|
|
390
|
-
path = installed[env_name]
|
|
391
|
-
return path, env_name
|
|
421
|
+
return installed[env_name], env_name, _slug_for(env_name)
|
|
392
422
|
|
|
393
423
|
# Fall back to slug lookup via registry
|
|
394
424
|
try:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
registry_mgr = EnvironmentRegistryManager()
|
|
425
|
+
if registry_mgr is None:
|
|
426
|
+
registry_mgr = EnvironmentRegistryManager()
|
|
398
427
|
match = registry_mgr.find_by_slug(env_name)
|
|
399
428
|
if match:
|
|
400
429
|
env_id, entry = match
|
|
401
430
|
if entry.path.exists():
|
|
402
|
-
return entry.path, env_id
|
|
431
|
+
return entry.path, env_id, entry.slug
|
|
403
432
|
except Exception:
|
|
404
433
|
pass
|
|
405
434
|
|
|
406
|
-
return None, None
|
|
435
|
+
return None, None, None
|
|
407
436
|
|
|
408
|
-
env_path, env_id = gather_local_data(name)
|
|
437
|
+
env_path, env_id, env_slug = gather_local_data(name)
|
|
409
438
|
|
|
410
439
|
if env_path is None:
|
|
411
440
|
console = Console()
|
|
@@ -414,12 +443,28 @@ def envs_uninstall(
|
|
|
414
443
|
console.print("[dim]Run 'qbraid envs list' to see all installed environments.[/dim]")
|
|
415
444
|
raise typer.Exit(0)
|
|
416
445
|
|
|
446
|
+
# Only hint at --delete-cloud when there's actually a cloud copy to delete.
|
|
447
|
+
# Local-only envs (never uploaded → no slug) would treat --delete-cloud as
|
|
448
|
+
# a no-op, so mentioning it there is confusing.
|
|
449
|
+
if delete_cloud:
|
|
450
|
+
scope_line = (
|
|
451
|
+
"This will remove the local install AND permanently delete the environment "
|
|
452
|
+
"from qBraid cloud. This operation CANNOT be undone."
|
|
453
|
+
)
|
|
454
|
+
elif env_slug:
|
|
455
|
+
scope_line = (
|
|
456
|
+
"This will remove only the LOCAL copy. The cloud environment is preserved "
|
|
457
|
+
"so you can reinstall it later — pass --delete-cloud to also delete from "
|
|
458
|
+
"qBraid cloud."
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
scope_line = (
|
|
462
|
+
"This will remove the local copy. (No cloud environment associated — this "
|
|
463
|
+
"env was never uploaded.)"
|
|
464
|
+
)
|
|
417
465
|
confirmation_message = (
|
|
418
|
-
f"⚠️
|
|
419
|
-
f"
|
|
420
|
-
"This will remove all local packages and permanently delete all "
|
|
421
|
-
"of its associated qBraid environment metadata.\n"
|
|
422
|
-
"This operation CANNOT be undone.\n\n"
|
|
466
|
+
f"⚠️ About to remove the environment '{name}' at '{env_path}'.\n"
|
|
467
|
+
f"{scope_line}\n\n"
|
|
423
468
|
"Are you sure you want to continue?"
|
|
424
469
|
)
|
|
425
470
|
|
|
@@ -433,9 +478,28 @@ def envs_uninstall(
|
|
|
433
478
|
)
|
|
434
479
|
|
|
435
480
|
if result.get("deleted_metadata"):
|
|
436
|
-
typer.echo(f"✅ Environment '{name}'
|
|
481
|
+
typer.echo(f"✅ Environment '{name}' removed (local files and cloud metadata).")
|
|
482
|
+
elif delete_cloud:
|
|
483
|
+
# User asked for cloud delete but it didn't happen. Point them at
|
|
484
|
+
# the actionable diagnostic — a missing slug is the common cause;
|
|
485
|
+
# otherwise re-run with QBRAID_LOG_LEVEL=DEBUG to surface the API
|
|
486
|
+
# error the core swallowed at line 3098 of environments/client.py.
|
|
487
|
+
reason = (
|
|
488
|
+
"no cloud slug is registered for this environment"
|
|
489
|
+
if not env_slug
|
|
490
|
+
else "re-run with QBRAID_LOG_LEVEL=DEBUG to see the underlying API error"
|
|
491
|
+
)
|
|
492
|
+
typer.echo(
|
|
493
|
+
f"✅ Environment '{name}' removed locally. Cloud delete was requested "
|
|
494
|
+
f"but skipped ({reason})."
|
|
495
|
+
)
|
|
496
|
+
elif env_slug:
|
|
497
|
+
typer.echo(
|
|
498
|
+
f"✅ Environment '{name}' removed locally. "
|
|
499
|
+
"Cloud copy preserved (pass --delete-cloud to also remove from qBraid cloud)."
|
|
500
|
+
)
|
|
437
501
|
else:
|
|
438
|
-
typer.echo(f"✅ Environment '{name}'
|
|
502
|
+
typer.echo(f"✅ Environment '{name}' removed locally.")
|
|
439
503
|
|
|
440
504
|
|
|
441
505
|
@envs_app.command(name="delete")
|
|
@@ -1243,6 +1307,7 @@ def envs_install(
|
|
|
1243
1307
|
overwrite=yes,
|
|
1244
1308
|
progress_callback=progress_callback,
|
|
1245
1309
|
auto_install_python=False, # We handled Python above
|
|
1310
|
+
env_info=env_info, # skip the internal fetch — we already have it
|
|
1246
1311
|
)
|
|
1247
1312
|
|
|
1248
1313
|
console.print("")
|
|
@@ -1698,8 +1763,8 @@ def envs_config(
|
|
|
1698
1763
|
tags: Optional[str] = typer.Option(
|
|
1699
1764
|
None, "--tags", help="Replace tags (comma-separated, use '' to clear)"
|
|
1700
1765
|
),
|
|
1701
|
-
add_tag: Optional[
|
|
1702
|
-
remove_tag: Optional[
|
|
1766
|
+
add_tag: Optional[List[str]] = typer.Option(None, "--add-tag", help="Append a tag (repeatable)"),
|
|
1767
|
+
remove_tag: Optional[List[str]] = typer.Option(None, "--remove-tag", help="Remove a tag (repeatable)"),
|
|
1703
1768
|
kernel_name: Optional[str] = typer.Option(
|
|
1704
1769
|
None, "--kernel-name", help="Update Jupyter kernel display name"
|
|
1705
1770
|
),
|
|
@@ -12,7 +12,6 @@ rejected before our code runs.
|
|
|
12
12
|
import os
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
import click
|
|
16
15
|
import typer
|
|
17
16
|
|
|
18
17
|
app = typer.Typer(help="Launch qBraid Lab (JupyterLab).")
|
|
@@ -43,6 +42,9 @@ def lab_command(_ctx: typer.Context):
|
|
|
43
42
|
try:
|
|
44
43
|
os.execvp("jupyter-lab", ["jupyter-lab"] + jupyter_args)
|
|
45
44
|
except FileNotFoundError:
|
|
46
|
-
|
|
47
|
-
"jupyter-lab is not installed or not on PATH.
|
|
45
|
+
typer.echo(
|
|
46
|
+
"Error: jupyter-lab is not installed or not on PATH. "
|
|
47
|
+
"Install qBraid Lab first: pip install qbraid-lab",
|
|
48
|
+
err=True,
|
|
48
49
|
)
|
|
50
|
+
raise typer.Exit(code=1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.2
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|