hypercli-cli 2026.3.18__tar.gz → 2026.3.25__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.
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/.gitignore +3 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/PKG-INFO +4 -4
- hypercli_cli-2026.3.25/hypercli_cli/__init__.py +1 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/agent.py +4 -4
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/agents.py +70 -10
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/cli.py +2 -1
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/embed.py +3 -3
- hypercli_cli-2026.3.25/hypercli_cli/files.py +111 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/instances.py +28 -1
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/jobs.py +18 -2
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/voice.py +11 -5
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/pyproject.toml +4 -4
- hypercli_cli-2026.3.25/tests/test_agent_env_resolution.py +39 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/tests/test_exec_shell_dryrun.py +63 -0
- hypercli_cli-2026.3.25/tests/test_jobs_list_tags.py +70 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/tests/test_openclaw_config.py +4 -4
- hypercli_cli-2026.3.18/hypercli_cli/__init__.py +0 -1
- hypercli_cli-2026.3.18/tests/test_jobs_list_tags.py +0 -39
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/README.md +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/billing.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/comfyui.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/flow.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/keys.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/onboard.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/output.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/renders.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/stt.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/tui/__init__.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/tui/job_monitor.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/user.py +0 -0
- {hypercli_cli-2026.3.18 → hypercli_cli-2026.3.25}/hypercli_cli/wallet.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hypercli-cli
|
|
3
|
-
Version: 2026.3.
|
|
3
|
+
Version: 2026.3.25
|
|
4
4
|
Summary: CLI for HyperCLI - GPU orchestration and LLM API
|
|
5
5
|
Project-URL: Homepage, https://hypercli.com
|
|
6
6
|
Project-URL: Documentation, https://docs.hypercli.com
|
|
@@ -9,7 +9,7 @@ Author-email: HyperCLI <support@hypercli.com>
|
|
|
9
9
|
License: MIT
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Requires-Dist: httpx>=0.27.0
|
|
12
|
-
Requires-Dist: hypercli-sdk>=2026.3.
|
|
12
|
+
Requires-Dist: hypercli-sdk>=2026.3.25
|
|
13
13
|
Requires-Dist: mutagen>=1.47.0
|
|
14
14
|
Requires-Dist: pyyaml>=6.0
|
|
15
15
|
Requires-Dist: rich>=14.2.0
|
|
@@ -19,11 +19,11 @@ Provides-Extra: all
|
|
|
19
19
|
Requires-Dist: argon2-cffi>=25.0.0; extra == 'all'
|
|
20
20
|
Requires-Dist: eth-account>=0.13.0; extra == 'all'
|
|
21
21
|
Requires-Dist: faster-whisper>=1.1.0; extra == 'all'
|
|
22
|
-
Requires-Dist: hypercli-sdk[comfyui]>=2026.3.
|
|
22
|
+
Requires-Dist: hypercli-sdk[comfyui]>=2026.3.25; extra == 'all'
|
|
23
23
|
Requires-Dist: web3>=7.0.0; extra == 'all'
|
|
24
24
|
Requires-Dist: x402[evm,httpx]>=2.0.0; extra == 'all'
|
|
25
25
|
Provides-Extra: comfyui
|
|
26
|
-
Requires-Dist: hypercli-sdk[comfyui]>=2026.3.
|
|
26
|
+
Requires-Dist: hypercli-sdk[comfyui]>=2026.3.25; extra == 'comfyui'
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
29
29
|
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2026.3.22"
|
|
@@ -353,7 +353,7 @@ def models(
|
|
|
353
353
|
import httpx
|
|
354
354
|
|
|
355
355
|
api_base = DEV_API_BASE if dev else PROD_API_BASE
|
|
356
|
-
key = os.getenv("
|
|
356
|
+
key = os.getenv("HYPER_API_KEY")
|
|
357
357
|
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
358
358
|
|
|
359
359
|
# Prefer OpenAI-compatible endpoint, then fallback to legacy.
|
|
@@ -374,7 +374,7 @@ def models(
|
|
|
374
374
|
if payload is None:
|
|
375
375
|
console.print(
|
|
376
376
|
f"[red]❌ Failed to fetch models from {urls[0]} or {urls[1]} "
|
|
377
|
-
"(set
|
|
377
|
+
"(set HYPER_API_KEY if endpoint requires auth)[/red]"
|
|
378
378
|
)
|
|
379
379
|
raise typer.Exit(1)
|
|
380
380
|
|
|
@@ -548,7 +548,7 @@ def _resolve_api_base(base_url: str | None = None, dev: bool = False) -> str:
|
|
|
548
548
|
"""Resolve API base from flag/env, then fall back to dev/prod defaults."""
|
|
549
549
|
return (
|
|
550
550
|
base_url
|
|
551
|
-
or os.environ.get("
|
|
551
|
+
or os.environ.get("HYPER_API_BASE")
|
|
552
552
|
or (DEV_INFERENCE_API_BASE if dev else PROD_INFERENCE_API_BASE)
|
|
553
553
|
).rstrip("/")
|
|
554
554
|
|
|
@@ -881,7 +881,7 @@ def config_cmd(
|
|
|
881
881
|
help=f"Output format: {', '.join(FORMAT_CHOICES)}. Omit to show all.",
|
|
882
882
|
),
|
|
883
883
|
key: str = typer.Option(None, "--key", "-k", help="API key (sk-...). Falls back to ~/.hypercli/agent-key.json"),
|
|
884
|
-
base_url: str = typer.Option(None, "--base-url", help="HyperClaw API base URL. Falls back to
|
|
884
|
+
base_url: str = typer.Option(None, "--base-url", help="HyperClaw API base URL. Falls back to HYPER_API_BASE, then --dev/prod defaults"),
|
|
885
885
|
placeholder_env: str = typer.Option(None, "--placeholder-env", help="Write ${ENV_VAR} placeholders into generated config instead of literal API keys"),
|
|
886
886
|
apply: bool = typer.Option(False, "--apply", help="Write config to the appropriate file (openclaw/opencode only)"),
|
|
887
887
|
dev: bool = typer.Option(False, "--dev", help="Use dev API"),
|
|
@@ -18,8 +18,6 @@ app = typer.Typer(help="Manage OpenClaw agent pods")
|
|
|
18
18
|
console = Console()
|
|
19
19
|
PROD_API_BASE = "https://api.hypercli.com"
|
|
20
20
|
DEV_API_BASE = "https://api.dev.hypercli.com"
|
|
21
|
-
PROD_AGENTS_WS_URL = "wss://api.agents.hypercli.com/ws"
|
|
22
|
-
DEV_AGENTS_WS_URL = "wss://api.agents.dev.hypercli.com/ws"
|
|
23
21
|
_GLOBAL_DEV = False
|
|
24
22
|
_GLOBAL_AGENTS_WS_URL: str | None = None
|
|
25
23
|
|
|
@@ -49,7 +47,10 @@ def agents_root(
|
|
|
49
47
|
|
|
50
48
|
def _get_agent_api_key() -> str:
|
|
51
49
|
"""Resolve HyperClaw API key from env or saved key file."""
|
|
52
|
-
key = os.environ.get("
|
|
50
|
+
key = os.environ.get("HYPER_AGENTS_API_KEY", "").strip()
|
|
51
|
+
if key:
|
|
52
|
+
return key
|
|
53
|
+
key = os.environ.get("HYPER_API_KEY", "").strip()
|
|
53
54
|
if key:
|
|
54
55
|
return key
|
|
55
56
|
if AGENT_KEY_PATH.exists():
|
|
@@ -59,7 +60,7 @@ def _get_agent_api_key() -> str:
|
|
|
59
60
|
if key:
|
|
60
61
|
return key
|
|
61
62
|
console.print("[red]❌ No HyperClaw API key found.[/red]")
|
|
62
|
-
console.print("Set
|
|
63
|
+
console.print("Set HYPER_AGENTS_API_KEY or HYPER_API_KEY, or subscribe: [bold]hyper agent subscribe 1aiu[/bold]")
|
|
63
64
|
raise typer.Exit(1)
|
|
64
65
|
|
|
65
66
|
|
|
@@ -67,12 +68,13 @@ def _get_deployments_client(agents_ws_url: str | None = None) -> Deployments:
|
|
|
67
68
|
"""Create a Deployments client using the HyperClaw API key."""
|
|
68
69
|
from hypercli.http import HTTPClient
|
|
69
70
|
api_key = _get_agent_api_key()
|
|
70
|
-
api_base =
|
|
71
|
+
api_base = (
|
|
72
|
+
os.environ.get("AGENTS_API_BASE_URL")
|
|
73
|
+
or os.environ.get("HYPER_API_BASE")
|
|
74
|
+
or os.environ.get("HYPERCLI_API_URL")
|
|
75
|
+
or (DEV_API_BASE if _GLOBAL_DEV else PROD_API_BASE)
|
|
76
|
+
)
|
|
71
77
|
resolved_agents_ws_url = agents_ws_url or _GLOBAL_AGENTS_WS_URL or os.environ.get("AGENTS_WS_URL")
|
|
72
|
-
if _GLOBAL_DEV and not resolved_agents_ws_url:
|
|
73
|
-
resolved_agents_ws_url = DEV_AGENTS_WS_URL
|
|
74
|
-
if not _GLOBAL_DEV and not resolved_agents_ws_url and os.environ.get("HYPERCLI_API_URL", "").strip() == "":
|
|
75
|
-
resolved_agents_ws_url = PROD_AGENTS_WS_URL
|
|
76
78
|
http = HTTPClient(api_base, api_key)
|
|
77
79
|
return Deployments(http, api_key=api_key, api_base=api_base, agents_ws_url=resolved_agents_ws_url)
|
|
78
80
|
|
|
@@ -651,7 +653,10 @@ def cp(
|
|
|
651
653
|
local_path = agents.cp_from(pod, src_path, dst_path)
|
|
652
654
|
console.print(f"[green]✓[/green] Copied [bold]{src_agent_id[:12]}:{src_path}[/bold] to [bold]{local_path}[/bold]")
|
|
653
655
|
except Exception as e:
|
|
654
|
-
|
|
656
|
+
message = str(e)
|
|
657
|
+
if message.startswith("Path is a directory:"):
|
|
658
|
+
message = f"{message} Copy expects a file path, not a directory."
|
|
659
|
+
console.print(f"[red]❌ Copy failed: {message}[/red]")
|
|
655
660
|
raise typer.Exit(1)
|
|
656
661
|
|
|
657
662
|
|
|
@@ -998,6 +1003,59 @@ def gateway_cron(
|
|
|
998
1003
|
_run_async(_run())
|
|
999
1004
|
|
|
1000
1005
|
|
|
1006
|
+
@app.command("cron-add")
|
|
1007
|
+
def gateway_cron_add(
|
|
1008
|
+
agent_id: str = typer.Argument(None, help="Agent ID or name"),
|
|
1009
|
+
job_json: str = typer.Argument(..., help='Cron job JSON, e.g. \'{"name":"backup","schedule":"0 * * * *","command":"echo hi"}\''),
|
|
1010
|
+
):
|
|
1011
|
+
"""Add a cron job to an agent's gateway."""
|
|
1012
|
+
pod = _require_openclaw_agent(_get_pod_with_token(agent_id))
|
|
1013
|
+
try:
|
|
1014
|
+
job_data = json.loads(job_json)
|
|
1015
|
+
except json.JSONDecodeError as e:
|
|
1016
|
+
console.print(f"[red]Invalid JSON: {e}[/red]")
|
|
1017
|
+
raise typer.Exit(1)
|
|
1018
|
+
|
|
1019
|
+
async def _run():
|
|
1020
|
+
result = await pod.cron_add(job_data)
|
|
1021
|
+
console.print(f"[green]Cron job added[/green]")
|
|
1022
|
+
console.print_json(json.dumps(result, default=str))
|
|
1023
|
+
|
|
1024
|
+
_run_async(_run())
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@app.command("cron-remove")
|
|
1028
|
+
def gateway_cron_remove(
|
|
1029
|
+
agent_id: str = typer.Argument(None, help="Agent ID or name"),
|
|
1030
|
+
job_id: str = typer.Argument(..., help="Cron job ID to remove"),
|
|
1031
|
+
):
|
|
1032
|
+
"""Remove a cron job from an agent's gateway."""
|
|
1033
|
+
pod = _require_openclaw_agent(_get_pod_with_token(agent_id))
|
|
1034
|
+
|
|
1035
|
+
async def _run():
|
|
1036
|
+
await pod.cron_remove(job_id)
|
|
1037
|
+
console.print(f"[green]Cron job {job_id} removed[/green]")
|
|
1038
|
+
|
|
1039
|
+
_run_async(_run())
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@app.command("cron-run")
|
|
1043
|
+
def gateway_cron_run(
|
|
1044
|
+
agent_id: str = typer.Argument(None, help="Agent ID or name"),
|
|
1045
|
+
job_id: str = typer.Argument(..., help="Cron job ID to trigger"),
|
|
1046
|
+
):
|
|
1047
|
+
"""Manually trigger a cron job on an agent's gateway."""
|
|
1048
|
+
pod = _require_openclaw_agent(_get_pod_with_token(agent_id))
|
|
1049
|
+
|
|
1050
|
+
async def _run():
|
|
1051
|
+
result = await pod.cron_run(job_id)
|
|
1052
|
+
console.print(f"[green]Cron job {job_id} triggered[/green]")
|
|
1053
|
+
if result:
|
|
1054
|
+
console.print_json(json.dumps(result, default=str))
|
|
1055
|
+
|
|
1056
|
+
_run_async(_run())
|
|
1057
|
+
|
|
1058
|
+
|
|
1001
1059
|
@app.command("gateway-chat")
|
|
1002
1060
|
def gateway_chat(
|
|
1003
1061
|
agent_id: str = typer.Argument(None, help="Agent ID or name"),
|
|
@@ -1015,6 +1073,8 @@ def gateway_chat(
|
|
|
1015
1073
|
console.print(f"[dim]{event.text}[/dim]", end="")
|
|
1016
1074
|
elif event.type == "tool_call":
|
|
1017
1075
|
console.print(f"\n[yellow]🔧 {event.data}[/yellow]")
|
|
1076
|
+
elif event.type == "tool_result":
|
|
1077
|
+
console.print(f"\n[cyan]📤 {event.data}[/cyan]")
|
|
1018
1078
|
elif event.type == "error":
|
|
1019
1079
|
console.print(f"\n[red]❌ {event.text}[/red]")
|
|
1020
1080
|
elif event.type == "done":
|
|
@@ -8,7 +8,7 @@ from rich.prompt import Prompt
|
|
|
8
8
|
from hypercli import HyperCLI, APIError, configure
|
|
9
9
|
from hypercli.config import CONFIG_FILE
|
|
10
10
|
|
|
11
|
-
from . import agent, agents, billing, comfyui, flow, instances, jobs, keys, user, wallet
|
|
11
|
+
from . import agent, agents, billing, comfyui, files, flow, instances, jobs, keys, user, wallet
|
|
12
12
|
|
|
13
13
|
console = Console()
|
|
14
14
|
|
|
@@ -61,6 +61,7 @@ app.add_typer(agents.app, name="agents")
|
|
|
61
61
|
app.add_typer(agent.app, name="agent")
|
|
62
62
|
app.add_typer(billing.app, name="billing")
|
|
63
63
|
app.add_typer(comfyui.app, name="comfyui")
|
|
64
|
+
app.add_typer(files.app, name="files")
|
|
64
65
|
app.add_typer(flow.app, name="flow")
|
|
65
66
|
app.add_typer(instances.app, name="instances")
|
|
66
67
|
app.add_typer(keys.app, name="keys")
|
|
@@ -17,10 +17,10 @@ DEV_API_BASE = "https://api.dev.hypercli.com"
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def _get_api_key(key: str | None) -> str:
|
|
20
|
-
"""Resolve API key: --key flag > env
|
|
20
|
+
"""Resolve API key: --key flag > env HYPER_API_KEY > agent-key.json."""
|
|
21
21
|
if key:
|
|
22
22
|
return key
|
|
23
|
-
env_key = os.environ.get("
|
|
23
|
+
env_key = os.environ.get("HYPER_API_KEY", "").strip()
|
|
24
24
|
if env_key:
|
|
25
25
|
return env_key
|
|
26
26
|
if AGENT_KEY_PATH.exists():
|
|
@@ -29,7 +29,7 @@ def _get_api_key(key: str | None) -> str:
|
|
|
29
29
|
if k:
|
|
30
30
|
return k
|
|
31
31
|
console.print("[red]❌ No API key found.[/red]")
|
|
32
|
-
console.print("Pass [bold]--key sk-...[/bold], set [bold]
|
|
32
|
+
console.print("Pass [bold]--key sk-...[/bold], set [bold]HYPER_API_KEY[/bold], or run [bold]hyper agent subscribe[/bold]")
|
|
33
33
|
raise typer.Exit(1)
|
|
34
34
|
|
|
35
35
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""File management CLI commands"""
|
|
2
|
+
import os
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Upload, inspect, and delete files on the platform")
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_client():
|
|
12
|
+
from hypercli import HyperCLI
|
|
13
|
+
return HyperCLI()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fmt_size(size: int) -> str:
|
|
17
|
+
"""Format file size for display."""
|
|
18
|
+
if size < 1024:
|
|
19
|
+
return f"{size} B"
|
|
20
|
+
if size < 1024 * 1024:
|
|
21
|
+
return f"{size / 1024:.1f} KB"
|
|
22
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("upload")
|
|
26
|
+
def upload_file(
|
|
27
|
+
file_path: str = typer.Argument(..., help="Path to local file to upload"),
|
|
28
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for upload to complete"),
|
|
29
|
+
):
|
|
30
|
+
"""Upload a file for use in flows and renders."""
|
|
31
|
+
if not os.path.isfile(file_path):
|
|
32
|
+
console.print(f"[red]File not found: {file_path}[/red]")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
client = _get_client()
|
|
36
|
+
console.print(f"Uploading [cyan]{os.path.basename(file_path)}[/cyan] ({_fmt_size(os.path.getsize(file_path))})...")
|
|
37
|
+
f = client.files.upload(file_path)
|
|
38
|
+
console.print(f"[green]Uploaded[/green] ID: [bold]{f.id}[/bold]")
|
|
39
|
+
|
|
40
|
+
if wait and f.state == "processing":
|
|
41
|
+
console.print("Waiting for processing...", end="")
|
|
42
|
+
f = client.files.wait_ready(f.id)
|
|
43
|
+
console.print(f" [green]done[/green]")
|
|
44
|
+
|
|
45
|
+
console.print(f" Filename: {f.filename}")
|
|
46
|
+
console.print(f" Type: {f.content_type}")
|
|
47
|
+
console.print(f" Size: {_fmt_size(f.file_size)}")
|
|
48
|
+
console.print(f" State: {f.state or 'ready'}")
|
|
49
|
+
console.print(f" URL: [dim]{f.url}[/dim]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("upload-url")
|
|
53
|
+
def upload_url(
|
|
54
|
+
url: str = typer.Argument(..., help="URL to download and upload"),
|
|
55
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for upload to complete"),
|
|
56
|
+
):
|
|
57
|
+
"""Upload a file from a URL."""
|
|
58
|
+
client = _get_client()
|
|
59
|
+
console.print(f"Uploading from URL...")
|
|
60
|
+
f = client.files.upload_url(url)
|
|
61
|
+
console.print(f"[green]Queued[/green] ID: [bold]{f.id}[/bold]")
|
|
62
|
+
|
|
63
|
+
if wait and f.state == "processing":
|
|
64
|
+
console.print("Waiting for processing...", end="")
|
|
65
|
+
f = client.files.wait_ready(f.id)
|
|
66
|
+
console.print(f" [green]done[/green]")
|
|
67
|
+
|
|
68
|
+
console.print(f" Filename: {f.filename}")
|
|
69
|
+
console.print(f" State: {f.state or 'ready'}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("get")
|
|
73
|
+
def get_file(
|
|
74
|
+
file_id: str = typer.Argument(..., help="File ID"),
|
|
75
|
+
):
|
|
76
|
+
"""Get file info and status."""
|
|
77
|
+
client = _get_client()
|
|
78
|
+
f = client.files.get(file_id)
|
|
79
|
+
|
|
80
|
+
table = Table(title=f"File {f.id[:12]}...")
|
|
81
|
+
table.add_column("Field", style="dim")
|
|
82
|
+
table.add_column("Value")
|
|
83
|
+
|
|
84
|
+
table.add_row("ID", f.id)
|
|
85
|
+
table.add_row("Filename", f.filename)
|
|
86
|
+
table.add_row("Type", f.content_type)
|
|
87
|
+
table.add_row("Size", _fmt_size(f.file_size))
|
|
88
|
+
table.add_row("State", f.state or "ready")
|
|
89
|
+
table.add_row("URL", f.url)
|
|
90
|
+
if f.error:
|
|
91
|
+
table.add_row("Error", f"[red]{f.error}[/red]")
|
|
92
|
+
if f.created_at:
|
|
93
|
+
table.add_row("Created", str(f.created_at))
|
|
94
|
+
|
|
95
|
+
console.print(table)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("delete")
|
|
99
|
+
def delete_file(
|
|
100
|
+
file_id: str = typer.Argument(..., help="File ID to delete"),
|
|
101
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
102
|
+
):
|
|
103
|
+
"""Delete an uploaded file."""
|
|
104
|
+
if not yes:
|
|
105
|
+
confirm = typer.confirm(f"Delete file {file_id[:12]}...?")
|
|
106
|
+
if not confirm:
|
|
107
|
+
raise typer.Abort()
|
|
108
|
+
|
|
109
|
+
client = _get_client()
|
|
110
|
+
client.files.delete(file_id)
|
|
111
|
+
console.print(f"[green]File {file_id[:12]}... deleted[/green]")
|
|
@@ -11,6 +11,21 @@ def get_client() -> HyperCLI:
|
|
|
11
11
|
return HyperCLI()
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _parse_constraints(
|
|
15
|
+
constraints: Optional[list[str]],
|
|
16
|
+
cpu_vendor: Optional[str],
|
|
17
|
+
) -> dict[str, str] | None:
|
|
18
|
+
parsed: dict[str, str] = {}
|
|
19
|
+
for item in constraints or []:
|
|
20
|
+
key, sep, value = item.partition("=")
|
|
21
|
+
if not sep or not key or not value:
|
|
22
|
+
raise typer.BadParameter(f"Invalid constraint '{item}', expected KEY=VALUE")
|
|
23
|
+
parsed[key.strip()] = value.strip()
|
|
24
|
+
if cpu_vendor:
|
|
25
|
+
parsed.setdefault("cpu_vendor", cpu_vendor.strip())
|
|
26
|
+
return parsed or None
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
@app.command("list")
|
|
15
30
|
def list_instances(
|
|
16
31
|
gpu: Optional[str] = typer.Option(None, "--gpu", "-g", help="Filter by GPU type"),
|
|
@@ -64,7 +79,13 @@ def list_gpus(
|
|
|
64
79
|
|
|
65
80
|
if fmt == "json":
|
|
66
81
|
output({k: {"name": v.name, "description": v.description, "configs": [
|
|
67
|
-
{
|
|
82
|
+
{
|
|
83
|
+
"gpu_count": c.gpu_count,
|
|
84
|
+
"cpu_cores": c.cpu_cores,
|
|
85
|
+
"memory_gb": c.memory_gb,
|
|
86
|
+
"regions": c.regions,
|
|
87
|
+
"constraints": c.constraints,
|
|
88
|
+
}
|
|
68
89
|
for c in v.configs
|
|
69
90
|
]} for k, v in types.items()}, "json")
|
|
70
91
|
else:
|
|
@@ -187,6 +208,8 @@ def launch(
|
|
|
187
208
|
gpu: str = typer.Option("l40s", "--gpu", "-g", help="GPU type"),
|
|
188
209
|
count: int = typer.Option(1, "--count", "-n", help="Number of GPUs"),
|
|
189
210
|
region: Optional[str] = typer.Option(None, "--region", "-r", help="Region code"),
|
|
211
|
+
constraint: Optional[list[str]] = typer.Option(None, "--constraint", help="Placement constraint KEY=VALUE (repeatable)"),
|
|
212
|
+
cpu_vendor: Optional[str] = typer.Option(None, "--cpu-vendor", help="CPU vendor constraint (e.g. intel, amd)"),
|
|
190
213
|
runtime: Optional[int] = typer.Option(None, "--runtime", "-t", help="Runtime in seconds"),
|
|
191
214
|
interruptible: bool = typer.Option(True, "--interruptible/--on-demand", help="Use interruptible instances"),
|
|
192
215
|
env: Optional[list[str]] = typer.Option(None, "--env", "-e", help="Env vars (KEY=VALUE)"),
|
|
@@ -256,6 +279,8 @@ def launch(
|
|
|
256
279
|
if command and any(op in command for op in ["&&", "||", "|", ";", ">", "<", "$"]):
|
|
257
280
|
command = f'sh -c "{command}"'
|
|
258
281
|
|
|
282
|
+
constraints = _parse_constraints(constraint, cpu_vendor)
|
|
283
|
+
|
|
259
284
|
follow_api_key = None
|
|
260
285
|
|
|
261
286
|
if x402:
|
|
@@ -281,6 +306,7 @@ def launch(
|
|
|
281
306
|
gpu_type=gpu,
|
|
282
307
|
gpu_count=count,
|
|
283
308
|
region=region,
|
|
309
|
+
constraints=constraints,
|
|
284
310
|
interruptible=interruptible,
|
|
285
311
|
env=env_dict,
|
|
286
312
|
ports=ports_dict,
|
|
@@ -330,6 +356,7 @@ def launch(
|
|
|
330
356
|
gpu_type=gpu,
|
|
331
357
|
gpu_count=count,
|
|
332
358
|
region=region,
|
|
359
|
+
constraints=constraints,
|
|
333
360
|
runtime=runtime,
|
|
334
361
|
interruptible=interruptible,
|
|
335
362
|
env=env_dict,
|
|
@@ -44,17 +44,28 @@ def _resolve_job_id(client: HyperCLI, job_id: str) -> str:
|
|
|
44
44
|
def list_jobs(
|
|
45
45
|
state: Optional[str] = typer.Option(None, "--state", "-s", help="Filter by state"),
|
|
46
46
|
tag: list[str] = typer.Option([], "--tag", help="Filter by tag as KEY=VALUE", metavar="KEY=VALUE"),
|
|
47
|
+
page: int = typer.Option(1, "--page", min=1, help="Page number (1-indexed)"),
|
|
48
|
+
page_size: int = typer.Option(50, "--page-size", min=1, max=100, help="Items per page (max 100)"),
|
|
47
49
|
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
48
50
|
):
|
|
49
51
|
"""List all jobs"""
|
|
50
52
|
client = get_client()
|
|
51
53
|
tags = _parse_tags(tag) if tag else None
|
|
52
54
|
with spinner("Fetching jobs..."):
|
|
53
|
-
|
|
55
|
+
result = client.jobs.list_page(state=state, tags=tags, page=page, page_size=page_size)
|
|
54
56
|
|
|
55
57
|
if fmt == "json":
|
|
56
|
-
output(
|
|
58
|
+
output(
|
|
59
|
+
{
|
|
60
|
+
"jobs": [job.__dict__ for job in result.jobs],
|
|
61
|
+
"total_count": result.total_count,
|
|
62
|
+
"page": result.page,
|
|
63
|
+
"page_size": result.page_size,
|
|
64
|
+
},
|
|
65
|
+
"json",
|
|
66
|
+
)
|
|
57
67
|
else:
|
|
68
|
+
jobs = result.jobs
|
|
58
69
|
if not jobs:
|
|
59
70
|
console.print("[dim]No jobs found[/dim]")
|
|
60
71
|
return
|
|
@@ -63,6 +74,11 @@ def list_jobs(
|
|
|
63
74
|
"table",
|
|
64
75
|
["job_id", "state", "gpu_type", "gpu_count", "region", "time_left", "runtime", "hostname"],
|
|
65
76
|
)
|
|
77
|
+
total_pages = max((result.total_count + result.page_size - 1) // result.page_size, 1)
|
|
78
|
+
console.print(
|
|
79
|
+
f"[dim]Page {result.page}/{total_pages} | "
|
|
80
|
+
f"{len(result.jobs)} shown | {result.total_count} total[/dim]"
|
|
81
|
+
)
|
|
66
82
|
|
|
67
83
|
|
|
68
84
|
@app.command("get")
|
|
@@ -18,10 +18,13 @@ DEFAULT_API_BASE = "https://api.hypercli.com"
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _get_api_key(key: str | None) -> str:
|
|
21
|
-
"""Resolve API key: --key
|
|
21
|
+
"""Resolve API key: --key > HYPER_API_KEY > HYPER_AGENTS_API_KEY > agent-key.json."""
|
|
22
22
|
if key:
|
|
23
23
|
return key
|
|
24
|
-
env_key = os.environ.get("
|
|
24
|
+
env_key = os.environ.get("HYPER_API_KEY", "").strip()
|
|
25
|
+
if env_key:
|
|
26
|
+
return env_key
|
|
27
|
+
env_key = os.environ.get("HYPER_AGENTS_API_KEY", "").strip()
|
|
25
28
|
if env_key:
|
|
26
29
|
return env_key
|
|
27
30
|
if AGENT_KEY_PATH.exists():
|
|
@@ -30,15 +33,18 @@ def _get_api_key(key: str | None) -> str:
|
|
|
30
33
|
if k:
|
|
31
34
|
return k
|
|
32
35
|
console.print("[red]❌ No API key found.[/red]")
|
|
33
|
-
console.print("Pass [bold]--key sk-...[/bold], set [bold]
|
|
36
|
+
console.print("Pass [bold]--key sk-...[/bold], set [bold]HYPER_API_KEY[/bold] or [bold]HYPER_AGENTS_API_KEY[/bold], or run [bold]hyper agent subscribe[/bold]")
|
|
34
37
|
raise typer.Exit(1)
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def _resolve_api_base(base_url: str | None) -> str:
|
|
38
|
-
"""Resolve API base: --base-url >
|
|
41
|
+
"""Resolve API base: --base-url > HYPER_API_BASE > HYPERCLI_API_URL > default."""
|
|
39
42
|
if base_url:
|
|
40
43
|
return base_url.rstrip("/")
|
|
41
|
-
env_base = os.environ.get("
|
|
44
|
+
env_base = os.environ.get("HYPER_API_BASE", "").strip()
|
|
45
|
+
if env_base:
|
|
46
|
+
return env_base.rstrip("/")
|
|
47
|
+
env_base = os.environ.get("HYPERCLI_API_URL", "").strip()
|
|
42
48
|
if env_base:
|
|
43
49
|
return env_base.rstrip("/")
|
|
44
50
|
return DEFAULT_API_BASE
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hypercli-cli"
|
|
7
|
-
version = "2026.3.
|
|
7
|
+
version = "2026.3.25"
|
|
8
8
|
description = "CLI for HyperCLI - GPU orchestration and LLM API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -13,7 +13,7 @@ authors = [
|
|
|
13
13
|
{ name = "HyperCLI", email = "support@hypercli.com" }
|
|
14
14
|
]
|
|
15
15
|
dependencies = [
|
|
16
|
-
"hypercli-sdk>=2026.3.
|
|
16
|
+
"hypercli-sdk>=2026.3.25",
|
|
17
17
|
"typer>=0.20.0",
|
|
18
18
|
"rich>=14.2.0",
|
|
19
19
|
"websocket-client>=1.6.0",
|
|
@@ -24,7 +24,7 @@ dependencies = [
|
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
comfyui = [
|
|
27
|
-
"hypercli-sdk[comfyui]>=2026.3.
|
|
27
|
+
"hypercli-sdk[comfyui]>=2026.3.25",
|
|
28
28
|
]
|
|
29
29
|
wallet = [
|
|
30
30
|
"x402[httpx,evm]>=2.0.0",
|
|
@@ -37,7 +37,7 @@ stt = [
|
|
|
37
37
|
"faster-whisper>=1.1.0",
|
|
38
38
|
]
|
|
39
39
|
all = [
|
|
40
|
-
"hypercli-sdk[comfyui]>=2026.3.
|
|
40
|
+
"hypercli-sdk[comfyui]>=2026.3.25",
|
|
41
41
|
"x402[httpx,evm]>=2.0.0",
|
|
42
42
|
"eth-account>=0.13.0",
|
|
43
43
|
"web3>=7.0.0",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_agents_cli_prefers_agent_key_env(monkeypatch):
|
|
5
|
+
monkeypatch.setenv("HYPER_API_KEY", "sk-product")
|
|
6
|
+
monkeypatch.setenv("HYPER_AGENTS_API_KEY", "sk-agent")
|
|
7
|
+
|
|
8
|
+
import hypercli_cli.agents as agents
|
|
9
|
+
|
|
10
|
+
importlib.reload(agents)
|
|
11
|
+
|
|
12
|
+
assert agents._get_agent_api_key() == "sk-agent"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_agents_cli_prefers_agent_base_env(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("AGENTS_API_BASE_URL", "https://api.agents.dev.hypercli.com")
|
|
17
|
+
monkeypatch.setenv("AGENTS_WS_URL", "wss://api.agents.dev.hypercli.com/ws")
|
|
18
|
+
monkeypatch.setenv("HYPER_AGENTS_API_KEY", "sk-agent")
|
|
19
|
+
|
|
20
|
+
import hypercli_cli.agents as agents
|
|
21
|
+
|
|
22
|
+
importlib.reload(agents)
|
|
23
|
+
|
|
24
|
+
client = agents._get_deployments_client()
|
|
25
|
+
assert client._api_base == "https://api.dev.hypercli.com/agents"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_voice_cli_prefers_product_envs(monkeypatch):
|
|
29
|
+
monkeypatch.setenv("HYPER_API_KEY", "sk-product")
|
|
30
|
+
monkeypatch.setenv("HYPER_AGENTS_API_KEY", "sk-agent")
|
|
31
|
+
monkeypatch.setenv("HYPER_API_BASE", "https://api.hypercli.com")
|
|
32
|
+
monkeypatch.setenv("HYPERCLI_API_URL", "https://api.dev.hypercli.com")
|
|
33
|
+
|
|
34
|
+
import hypercli_cli.voice as voice
|
|
35
|
+
|
|
36
|
+
importlib.reload(voice)
|
|
37
|
+
|
|
38
|
+
assert voice._get_api_key(None) == "sk-product"
|
|
39
|
+
assert voice._resolve_api_base(None) == "https://api.hypercli.com"
|
|
@@ -98,6 +98,52 @@ def test_instances_launch_dry_run_mock(monkeypatch):
|
|
|
98
98
|
assert "job-dryrun" in result.stdout
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def test_instances_launch_dry_run_includes_constraints(monkeypatch):
|
|
102
|
+
captured = {}
|
|
103
|
+
|
|
104
|
+
class FakeJobs:
|
|
105
|
+
def create(self, **kwargs):
|
|
106
|
+
captured.update(kwargs)
|
|
107
|
+
return SimpleNamespace(
|
|
108
|
+
job_id="job-dryrun",
|
|
109
|
+
state="validated",
|
|
110
|
+
gpu_type="h200",
|
|
111
|
+
gpu_count=8,
|
|
112
|
+
region="br",
|
|
113
|
+
constraints={"cpu_vendor": "amd"},
|
|
114
|
+
price_per_hour=12.34,
|
|
115
|
+
runtime=300,
|
|
116
|
+
cold_boot=False,
|
|
117
|
+
hostname=None,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
fake_client = SimpleNamespace(jobs=FakeJobs())
|
|
121
|
+
monkeypatch.setattr("hypercli_cli.instances.get_client", lambda: fake_client)
|
|
122
|
+
|
|
123
|
+
result = runner.invoke(
|
|
124
|
+
app,
|
|
125
|
+
[
|
|
126
|
+
"instances",
|
|
127
|
+
"launch",
|
|
128
|
+
"nvidia/cuda:12.0-base-ubuntu22.04",
|
|
129
|
+
"--dry-run",
|
|
130
|
+
"--gpu",
|
|
131
|
+
"h200",
|
|
132
|
+
"--count",
|
|
133
|
+
"8",
|
|
134
|
+
"--cpu-vendor",
|
|
135
|
+
"amd",
|
|
136
|
+
"--constraint",
|
|
137
|
+
"stack=prod",
|
|
138
|
+
"--output",
|
|
139
|
+
"json",
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert result.exit_code == 0
|
|
144
|
+
assert captured["constraints"] == {"cpu_vendor": "amd", "stack": "prod"}
|
|
145
|
+
|
|
146
|
+
|
|
101
147
|
def test_agent_exec_command(monkeypatch):
|
|
102
148
|
called = {}
|
|
103
149
|
|
|
@@ -126,3 +172,20 @@ def test_agent_shell_command(monkeypatch):
|
|
|
126
172
|
|
|
127
173
|
assert result.exit_code == 0
|
|
128
174
|
assert called["agent_id"] == "agent-xyz"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_agents_cp_reports_directory_path_error(monkeypatch, tmp_path):
|
|
178
|
+
class FakeDeployments:
|
|
179
|
+
def cp_from(self, _pod, src_path, dst_path):
|
|
180
|
+
assert src_path == ".openclaw"
|
|
181
|
+
assert str(dst_path).endswith("download")
|
|
182
|
+
raise ValueError("Path is a directory: .openclaw. Use files_list(path) instead.")
|
|
183
|
+
|
|
184
|
+
monkeypatch.setattr("hypercli_cli.agents._get_deployments_client", lambda: FakeDeployments())
|
|
185
|
+
monkeypatch.setattr("hypercli_cli.agents._get_pod_with_token", lambda agent_id: SimpleNamespace(id=agent_id))
|
|
186
|
+
|
|
187
|
+
result = runner.invoke(app, ["agents", "cp", "agent-xyz:.openclaw", str(tmp_path / "download")])
|
|
188
|
+
|
|
189
|
+
assert result.exit_code == 1
|
|
190
|
+
assert "Path is a directory: .openclaw." in result.stdout
|
|
191
|
+
assert "Copy expects a file path, not a directory." in result.stdout
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from hypercli_cli.cli import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
runner = CliRunner()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_jobs_list_passes_tags(monkeypatch):
|
|
12
|
+
captured = {}
|
|
13
|
+
|
|
14
|
+
class FakeJobs:
|
|
15
|
+
def list_page(self, state=None, tags=None, page=None, page_size=None):
|
|
16
|
+
captured["state"] = state
|
|
17
|
+
captured["tags"] = tags
|
|
18
|
+
captured["page"] = page
|
|
19
|
+
captured["page_size"] = page_size
|
|
20
|
+
return SimpleNamespace(jobs=[], total_count=0, page=page, page_size=page_size)
|
|
21
|
+
|
|
22
|
+
fake_client = SimpleNamespace(jobs=FakeJobs())
|
|
23
|
+
monkeypatch.setattr("hypercli_cli.jobs.get_client", lambda: fake_client)
|
|
24
|
+
|
|
25
|
+
result = runner.invoke(
|
|
26
|
+
app,
|
|
27
|
+
["jobs", "list", "--state", "running", "--tag", "team=ml", "--tag", "env=prod"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert result.exit_code == 0
|
|
31
|
+
assert captured == {
|
|
32
|
+
"state": "running",
|
|
33
|
+
"tags": {"team": "ml", "env": "prod"},
|
|
34
|
+
"page": 1,
|
|
35
|
+
"page_size": 50,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_jobs_list_passes_backend_pagination(monkeypatch):
|
|
40
|
+
captured = {}
|
|
41
|
+
|
|
42
|
+
class FakeJobs:
|
|
43
|
+
def list_page(self, state=None, tags=None, page=None, page_size=None):
|
|
44
|
+
captured["page"] = page
|
|
45
|
+
captured["page_size"] = page_size
|
|
46
|
+
return SimpleNamespace(jobs=[], total_count=0, page=page, page_size=page_size)
|
|
47
|
+
|
|
48
|
+
fake_client = SimpleNamespace(jobs=FakeJobs())
|
|
49
|
+
monkeypatch.setattr("hypercli_cli.jobs.get_client", lambda: fake_client)
|
|
50
|
+
|
|
51
|
+
result = runner.invoke(app, ["jobs", "list", "--page", "3", "--page-size", "25"])
|
|
52
|
+
|
|
53
|
+
assert result.exit_code == 0
|
|
54
|
+
assert captured == {"page": 3, "page_size": 25}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_jobs_list_rejects_invalid_tag(monkeypatch):
|
|
58
|
+
fake_client = SimpleNamespace(
|
|
59
|
+
jobs=SimpleNamespace(
|
|
60
|
+
list_page=lambda state=None, tags=None, page=None, page_size=None: SimpleNamespace(
|
|
61
|
+
jobs=[], total_count=0, page=page, page_size=page_size
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
monkeypatch.setattr("hypercli_cli.jobs.get_client", lambda: fake_client)
|
|
66
|
+
|
|
67
|
+
result = runner.invoke(app, ["jobs", "list", "--tag", "broken"])
|
|
68
|
+
|
|
69
|
+
assert result.exit_code == 1
|
|
70
|
+
assert "Expected KEY=VALUE" in result.stdout
|
|
@@ -54,10 +54,10 @@ def test_config_openclaw_supports_placeholder_api_key_env():
|
|
|
54
54
|
{"id": "qwen3-embedding-4b", "name": "Qwen3 Embedding 4B", "mode": "embedding"},
|
|
55
55
|
],
|
|
56
56
|
"https://api.agents.hypercli.com",
|
|
57
|
-
placeholder_env="
|
|
57
|
+
placeholder_env="HYPER_API_KEY",
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
providers = config["models"]["providers"]
|
|
61
|
-
assert providers["hyperclaw"]["apiKey"] == "${
|
|
62
|
-
assert providers["kimi-coding"]["apiKey"] == "${
|
|
63
|
-
assert config["agents"]["defaults"]["memorySearch"]["remote"]["apiKey"] == "${
|
|
61
|
+
assert providers["hyperclaw"]["apiKey"] == "${HYPER_API_KEY}"
|
|
62
|
+
assert providers["kimi-coding"]["apiKey"] == "${HYPER_API_KEY}"
|
|
63
|
+
assert config["agents"]["defaults"]["memorySearch"]["remote"]["apiKey"] == "${HYPER_API_KEY}"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2026.3.18"
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
from types import SimpleNamespace
|
|
2
|
-
|
|
3
|
-
from typer.testing import CliRunner
|
|
4
|
-
|
|
5
|
-
from hypercli_cli.cli import app
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
runner = CliRunner()
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_jobs_list_passes_tags(monkeypatch):
|
|
12
|
-
captured = {}
|
|
13
|
-
|
|
14
|
-
class FakeJobs:
|
|
15
|
-
def list(self, state=None, tags=None):
|
|
16
|
-
captured["state"] = state
|
|
17
|
-
captured["tags"] = tags
|
|
18
|
-
return []
|
|
19
|
-
|
|
20
|
-
fake_client = SimpleNamespace(jobs=FakeJobs())
|
|
21
|
-
monkeypatch.setattr("hypercli_cli.jobs.get_client", lambda: fake_client)
|
|
22
|
-
|
|
23
|
-
result = runner.invoke(
|
|
24
|
-
app,
|
|
25
|
-
["jobs", "list", "--state", "running", "--tag", "team=ml", "--tag", "env=prod"],
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
assert result.exit_code == 0
|
|
29
|
-
assert captured == {"state": "running", "tags": {"team": "ml", "env": "prod"}}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_jobs_list_rejects_invalid_tag(monkeypatch):
|
|
33
|
-
fake_client = SimpleNamespace(jobs=SimpleNamespace(list=lambda state=None, tags=None: []))
|
|
34
|
-
monkeypatch.setattr("hypercli_cli.jobs.get_client", lambda: fake_client)
|
|
35
|
-
|
|
36
|
-
result = runner.invoke(app, ["jobs", "list", "--tag", "broken"])
|
|
37
|
-
|
|
38
|
-
assert result.exit_code == 1
|
|
39
|
-
assert "Expected KEY=VALUE" in result.stdout
|
|
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
|