hypercli-cli 2026.4.13__tar.gz → 2026.4.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.
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/PKG-INFO +4 -4
- hypercli_cli-2026.4.17/hypercli_cli/__init__.py +1 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/agent.py +56 -7
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/keys.py +5 -1
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/voice.py +6 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/wallet.py +26 -9
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/pyproject.toml +4 -4
- hypercli_cli-2026.4.17/tests/test_agent_subscribe_command.py +110 -0
- hypercli_cli-2026.4.17/tests/test_keys_command.py +42 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_voice_command.py +36 -0
- hypercli_cli-2026.4.17/tests/test_wallet_command.py +343 -0
- hypercli_cli-2026.4.13/hypercli_cli/__init__.py +0 -1
- hypercli_cli-2026.4.13/tests/test_wallet_command.py +0 -144
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/.gitignore +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/README.md +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/agents.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/billing.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/cli.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/comfyui.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/embed.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/files.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/flow.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/instances.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/jobs.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/llm.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/onboard.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/output.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/renders.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/stt.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/tui/__init__.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/tui/job_monitor.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/user.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_agent_env_resolution.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_config_command.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_exec_shell_dryrun.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_flow_command.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_flow_visibility.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_jobs_list_tags.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_llm_command.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_me_command.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_openclaw_config.py +0 -0
- {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_wallet_migration_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hypercli-cli
|
|
3
|
-
Version: 2026.4.
|
|
3
|
+
Version: 2026.4.17
|
|
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.4.
|
|
12
|
+
Requires-Dist: hypercli-sdk>=2026.4.17
|
|
13
13
|
Requires-Dist: mutagen>=1.47.0
|
|
14
14
|
Requires-Dist: openai>=2.8.1
|
|
15
15
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -20,11 +20,11 @@ Provides-Extra: all
|
|
|
20
20
|
Requires-Dist: argon2-cffi>=25.0.0; extra == 'all'
|
|
21
21
|
Requires-Dist: eth-account>=0.13.0; extra == 'all'
|
|
22
22
|
Requires-Dist: faster-whisper>=1.1.0; extra == 'all'
|
|
23
|
-
Requires-Dist: hypercli-sdk[comfyui]>=2026.4.
|
|
23
|
+
Requires-Dist: hypercli-sdk[comfyui]>=2026.4.17; extra == 'all'
|
|
24
24
|
Requires-Dist: web3>=7.0.0; extra == 'all'
|
|
25
25
|
Requires-Dist: x402[evm,httpx]>=2.0.0; extra == 'all'
|
|
26
26
|
Provides-Extra: comfyui
|
|
27
|
-
Requires-Dist: hypercli-sdk[comfyui]>=2026.4.
|
|
27
|
+
Requires-Dist: hypercli-sdk[comfyui]>=2026.4.17; extra == 'comfyui'
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
30
30
|
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2026.4.17"
|
|
@@ -6,11 +6,13 @@ import shutil
|
|
|
6
6
|
import subprocess
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
import typer
|
|
10
11
|
from rich.console import Console
|
|
11
12
|
from rich.table import Table
|
|
12
13
|
|
|
13
14
|
from hypercli import HyperCLI
|
|
15
|
+
from hypercli.config import get_agents_api_base_url_from_product_base
|
|
14
16
|
|
|
15
17
|
from .onboard import onboard as _onboard_fn
|
|
16
18
|
from .voice import app as voice_app
|
|
@@ -79,7 +81,11 @@ def _get_agent_query_client(dev: bool) -> HyperCLI:
|
|
|
79
81
|
def subscribe(
|
|
80
82
|
plan_id: str = typer.Argument("1aiu", help="Plan ID: 1aiu, 2aiu, 5aiu, 10aiu (default: 1aiu)"),
|
|
81
83
|
amount: str = typer.Argument(None, help="USDC amount to pay (e.g., '25' for $25). Duration scales proportionally."),
|
|
82
|
-
|
|
84
|
+
passphrase: str = typer.Option(
|
|
85
|
+
None,
|
|
86
|
+
"--passphrase",
|
|
87
|
+
help="Current keystore passphrase. Skips interactive prompt.",
|
|
88
|
+
),
|
|
83
89
|
):
|
|
84
90
|
"""Subscribe to a HyperClaw plan via x402 payment.
|
|
85
91
|
|
|
@@ -97,8 +103,9 @@ def subscribe(
|
|
|
97
103
|
|
|
98
104
|
# Import wallet helper
|
|
99
105
|
from .wallet import load_wallet
|
|
106
|
+
from hypercli.config import get_api_url
|
|
100
107
|
|
|
101
|
-
api_base =
|
|
108
|
+
api_base = get_api_url().rstrip("/")
|
|
102
109
|
|
|
103
110
|
console.print(f"\n[bold]Subscribing to HyperClaw plan: {plan_id}[/bold]\n")
|
|
104
111
|
console.print(f"API: {api_base}")
|
|
@@ -106,7 +113,7 @@ def subscribe(
|
|
|
106
113
|
console.print(f"Custom amount: [bold]${amount} USDC[/bold]")
|
|
107
114
|
|
|
108
115
|
# Load wallet
|
|
109
|
-
account = load_wallet()
|
|
116
|
+
account = load_wallet(passphrase=passphrase)
|
|
110
117
|
console.print(f"[green]✓[/green] Loaded wallet: {account.address}\n")
|
|
111
118
|
|
|
112
119
|
# Run async subscribe
|
|
@@ -180,11 +187,11 @@ async def _subscribe_async(account, plan_id: str, api_base: str, amount: str = N
|
|
|
180
187
|
register_exact_evm_client(client, EthAccountSigner(account))
|
|
181
188
|
http_client = x402HTTPClient(client)
|
|
182
189
|
|
|
183
|
-
url = f"{api_base}/api/x402/{plan_id}"
|
|
184
|
-
console.print(f"\n[bold]→ Requesting:[/bold] POST {url}\n")
|
|
185
|
-
|
|
186
190
|
async with httpx.AsyncClient() as http:
|
|
187
191
|
try:
|
|
192
|
+
url = await _resolve_plan_purchase_url(http, api_base, plan_id)
|
|
193
|
+
console.print(f"\n[bold]→ Requesting:[/bold] POST {url}\n")
|
|
194
|
+
|
|
188
195
|
# Step 1: Make initial request to get 402 response with payment requirements
|
|
189
196
|
response = await http.post(url)
|
|
190
197
|
|
|
@@ -254,7 +261,7 @@ async def _subscribe_async(account, plan_id: str, api_base: str, amount: str = N
|
|
|
254
261
|
console.print(f"\n[red]❌ Payment failed: {retry_response.status_code}[/red]")
|
|
255
262
|
console.print(retry_response.text)
|
|
256
263
|
raise typer.Exit(1)
|
|
257
|
-
|
|
264
|
+
|
|
258
265
|
except typer.Exit:
|
|
259
266
|
raise
|
|
260
267
|
except Exception as e:
|
|
@@ -264,6 +271,48 @@ async def _subscribe_async(account, plan_id: str, api_base: str, amount: str = N
|
|
|
264
271
|
raise typer.Exit(1)
|
|
265
272
|
|
|
266
273
|
|
|
274
|
+
def _extract_plan_purchase_url_from_discovery(discovery: object, plan_id: str) -> str | None:
|
|
275
|
+
if not isinstance(discovery, dict):
|
|
276
|
+
return None
|
|
277
|
+
resources = discovery.get("resources")
|
|
278
|
+
if not isinstance(resources, list):
|
|
279
|
+
return None
|
|
280
|
+
suffix = f"/x402/{plan_id}"
|
|
281
|
+
for resource in resources:
|
|
282
|
+
if not isinstance(resource, str):
|
|
283
|
+
continue
|
|
284
|
+
parsed = urlsplit(resource)
|
|
285
|
+
if parsed.path.endswith(suffix):
|
|
286
|
+
return resource
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _resolve_plan_purchase_url(http: "httpx.AsyncClient", api_base: str, plan_id: str) -> str:
|
|
291
|
+
normalized_api_base = api_base.rstrip("/")
|
|
292
|
+
agents_base = get_agents_api_base_url_from_product_base(normalized_api_base).rstrip("/")
|
|
293
|
+
discovery_candidates = [
|
|
294
|
+
f"{agents_base}/.well-known/x402",
|
|
295
|
+
f"{normalized_api_base}/api/.well-known/x402",
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
for discovery_url in discovery_candidates:
|
|
299
|
+
try:
|
|
300
|
+
response = await http.get(discovery_url)
|
|
301
|
+
except Exception:
|
|
302
|
+
continue
|
|
303
|
+
if response.status_code >= 400:
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
payload = response.json()
|
|
307
|
+
except Exception:
|
|
308
|
+
continue
|
|
309
|
+
resource_url = _extract_plan_purchase_url_from_discovery(payload, plan_id)
|
|
310
|
+
if resource_url:
|
|
311
|
+
return resource_url
|
|
312
|
+
|
|
313
|
+
return f"{agents_base}/x402/{plan_id}"
|
|
314
|
+
|
|
315
|
+
|
|
267
316
|
@app.command("status")
|
|
268
317
|
def status():
|
|
269
318
|
"""Show current HyperClaw key status"""
|
|
@@ -26,10 +26,14 @@ def _get_client():
|
|
|
26
26
|
def create_key(
|
|
27
27
|
name: str = typer.Option("default", help="Key name"),
|
|
28
28
|
tag: list[str] = typer.Option(None, "--tag", help="Repeat as --tag team=dev"),
|
|
29
|
+
all_access: bool = typer.Option(False, "--all", help="Grant full access with *:*"),
|
|
29
30
|
):
|
|
30
31
|
"""Create a new API key"""
|
|
32
|
+
if all_access and tag:
|
|
33
|
+
raise typer.BadParameter("Use either --all or --tag, not both")
|
|
34
|
+
|
|
31
35
|
client = _get_client()
|
|
32
|
-
key = client.keys.create(name=name, tags=tag or None)
|
|
36
|
+
key = client.keys.create(name=name, tags=(["*:*"] if all_access else (tag or None)))
|
|
33
37
|
console.print(f"\n[bold green]API key created![/bold green]\n")
|
|
34
38
|
console.print(f" Key ID: {key.key_id}")
|
|
35
39
|
console.print(f" Name: {key.name}")
|
|
@@ -112,6 +112,7 @@ def tts(
|
|
|
112
112
|
language: str = typer.Option("auto", "--language", "-l", help="Language: auto, english, chinese, etc."),
|
|
113
113
|
format: str = typer.Option("mp3", "--format", "-f", help="Output format: wav, mp3, opus, ogg, flac"),
|
|
114
114
|
output: Path = typer.Option(None, "--output", "-o", help="Output audio file (default: output.<format>)"),
|
|
115
|
+
timeout: float | None = typer.Option(None, "--timeout", help="Voice request timeout in seconds"),
|
|
115
116
|
key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
|
|
116
117
|
base_url: str = typer.Option(None, "--base-url", "-b", help="API base URL (default: api.hypercli.com)"),
|
|
117
118
|
):
|
|
@@ -133,6 +134,7 @@ def tts(
|
|
|
133
134
|
voice=voice,
|
|
134
135
|
language=language,
|
|
135
136
|
response_format=format,
|
|
137
|
+
timeout=timeout,
|
|
136
138
|
)
|
|
137
139
|
|
|
138
140
|
|
|
@@ -144,6 +146,7 @@ def clone(
|
|
|
144
146
|
x_vector_only: bool = typer.Option(True, "--x-vector-only/--full-clone", help="Use x_vector_only mode (recommended)"),
|
|
145
147
|
format: str = typer.Option("mp3", "--format", "-f", help="Output format: wav, mp3, opus, ogg, flac"),
|
|
146
148
|
output: Path = typer.Option(None, "--output", "-o", help="Output audio file (default: output.<format>)"),
|
|
149
|
+
timeout: float | None = typer.Option(None, "--timeout", help="Voice request timeout in seconds"),
|
|
147
150
|
key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
|
|
148
151
|
base_url: str = typer.Option(None, "--base-url", "-b", help="API base URL (default: api.hypercli.com)"),
|
|
149
152
|
):
|
|
@@ -172,6 +175,7 @@ def clone(
|
|
|
172
175
|
language=language,
|
|
173
176
|
x_vector_only=x_vector_only,
|
|
174
177
|
response_format=format,
|
|
178
|
+
timeout=timeout,
|
|
175
179
|
)
|
|
176
180
|
|
|
177
181
|
|
|
@@ -182,6 +186,7 @@ def design(
|
|
|
182
186
|
language: str = typer.Option("auto", "--language", "-l", help="Language: auto, english, chinese, etc."),
|
|
183
187
|
format: str = typer.Option("mp3", "--format", "-f", help="Output format: wav, mp3, opus, ogg, flac"),
|
|
184
188
|
output: Path = typer.Option(None, "--output", "-o", help="Output audio file (default: output.<format>)"),
|
|
189
|
+
timeout: float | None = typer.Option(None, "--timeout", help="Voice request timeout in seconds"),
|
|
185
190
|
key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
|
|
186
191
|
base_url: str = typer.Option(None, "--base-url", "-b", help="API base URL (default: api.hypercli.com)"),
|
|
187
192
|
):
|
|
@@ -203,4 +208,5 @@ def design(
|
|
|
203
208
|
description=description,
|
|
204
209
|
language=language,
|
|
205
210
|
response_format=format,
|
|
211
|
+
timeout=timeout,
|
|
206
212
|
)
|
|
@@ -132,13 +132,13 @@ def _account_from_wallet_data(data: dict, passphrase: str | None = None):
|
|
|
132
132
|
raise typer.Exit(1)
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
def get_wallet_auth_token(api_url: str | None = None) -> str:
|
|
135
|
+
def get_wallet_auth_token(api_url: str | None = None, *, passphrase: str | None = None) -> str:
|
|
136
136
|
"""Authenticate the local wallet and return a short-lived JWT bearer token."""
|
|
137
137
|
from eth_account.messages import encode_defunct
|
|
138
138
|
import httpx
|
|
139
139
|
from hypercli.config import get_api_url
|
|
140
140
|
|
|
141
|
-
account = load_wallet()
|
|
141
|
+
account = load_wallet(passphrase=passphrase)
|
|
142
142
|
base_url = (api_url or get_api_url()).rstrip("/")
|
|
143
143
|
|
|
144
144
|
with httpx.Client(timeout=15) as client:
|
|
@@ -375,11 +375,17 @@ def qr(
|
|
|
375
375
|
|
|
376
376
|
|
|
377
377
|
@app.command("balance")
|
|
378
|
-
def balance(
|
|
378
|
+
def balance(
|
|
379
|
+
passphrase: str = typer.Option(
|
|
380
|
+
None,
|
|
381
|
+
"--passphrase",
|
|
382
|
+
help="Current keystore passphrase. Skips interactive prompt.",
|
|
383
|
+
),
|
|
384
|
+
):
|
|
379
385
|
"""Check USDC balance on Base"""
|
|
380
386
|
require_wallet_deps()
|
|
381
387
|
|
|
382
|
-
account = load_wallet()
|
|
388
|
+
account = load_wallet(passphrase=passphrase)
|
|
383
389
|
|
|
384
390
|
console.print(f"\n[bold]Checking USDC balance on Base...[/bold]\n")
|
|
385
391
|
|
|
@@ -416,6 +422,11 @@ def balance():
|
|
|
416
422
|
def topup(
|
|
417
423
|
amount: str = typer.Argument(help="Amount in USDC to top up (max 6 decimals)"),
|
|
418
424
|
api_url: str = typer.Option(None, help="API URL override"),
|
|
425
|
+
passphrase: str = typer.Option(
|
|
426
|
+
None,
|
|
427
|
+
"--passphrase",
|
|
428
|
+
help="Current keystore passphrase. Skips interactive prompt.",
|
|
429
|
+
),
|
|
419
430
|
):
|
|
420
431
|
"""Top up account balance via Orchestra x402 endpoint.
|
|
421
432
|
|
|
@@ -460,7 +471,7 @@ def topup(
|
|
|
460
471
|
amount_atomic = int(amount_dec * Decimal("1000000"))
|
|
461
472
|
|
|
462
473
|
# Step 1: Load wallet
|
|
463
|
-
account = load_wallet()
|
|
474
|
+
account = load_wallet(passphrase=passphrase)
|
|
464
475
|
console.print(f"[green]✓[/green] Wallet: {account.address}")
|
|
465
476
|
|
|
466
477
|
# Step 2: Check USDC balance
|
|
@@ -585,6 +596,11 @@ def topup(
|
|
|
585
596
|
def wallet_login(
|
|
586
597
|
name: str = typer.Option("cli", help="Name for the generated API key"),
|
|
587
598
|
api_url: str = typer.Option(None, help="API URL override"),
|
|
599
|
+
passphrase: str = typer.Option(
|
|
600
|
+
None,
|
|
601
|
+
"--passphrase",
|
|
602
|
+
help="Current keystore passphrase. Skips interactive prompt.",
|
|
603
|
+
),
|
|
588
604
|
):
|
|
589
605
|
"""Login with wallet signature, create an API key, and save it.
|
|
590
606
|
|
|
@@ -592,16 +608,17 @@ def wallet_login(
|
|
|
592
608
|
1. hyper wallet create
|
|
593
609
|
2. hyper wallet login
|
|
594
610
|
"""
|
|
611
|
+
import httpx
|
|
595
612
|
require_wallet_deps()
|
|
596
613
|
from hypercli.config import get_api_url, configure
|
|
597
614
|
|
|
598
615
|
base_url = (api_url or get_api_url()).rstrip("/")
|
|
599
616
|
|
|
600
617
|
# Step 1: Load wallet
|
|
601
|
-
account = load_wallet()
|
|
618
|
+
account = load_wallet(passphrase=passphrase)
|
|
602
619
|
console.print(f"[green]✓[/green] Wallet: {account.address}\n")
|
|
603
620
|
console.print("[bold]Requesting login challenge...[/bold]")
|
|
604
|
-
jwt_token = get_wallet_auth_token(api_url=base_url)
|
|
621
|
+
jwt_token = get_wallet_auth_token(api_url=base_url, passphrase=passphrase)
|
|
605
622
|
console.print("[green]✓[/green] Authenticated\n")
|
|
606
623
|
|
|
607
624
|
# Step 5: Create API key using JWT
|
|
@@ -629,9 +646,9 @@ def wallet_login(
|
|
|
629
646
|
console.print(f"\n[green]You're all set! Try:[/green] hyper keys list\n")
|
|
630
647
|
|
|
631
648
|
|
|
632
|
-
def load_wallet():
|
|
649
|
+
def load_wallet(*, passphrase: str | None = None):
|
|
633
650
|
"""Load and decrypt wallet (helper function for other commands)"""
|
|
634
651
|
require_wallet_deps()
|
|
635
652
|
|
|
636
653
|
wallet_data = _read_wallet_data()
|
|
637
|
-
return _account_from_wallet_data(wallet_data)
|
|
654
|
+
return _account_from_wallet_data(wallet_data, passphrase=passphrase)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hypercli-cli"
|
|
7
|
-
version = "2026.4.
|
|
7
|
+
version = "2026.4.17"
|
|
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.4.
|
|
16
|
+
"hypercli-sdk>=2026.4.17",
|
|
17
17
|
"openai>=2.8.1",
|
|
18
18
|
"typer>=0.20.0",
|
|
19
19
|
"rich>=14.2.0",
|
|
@@ -25,7 +25,7 @@ dependencies = [
|
|
|
25
25
|
|
|
26
26
|
[project.optional-dependencies]
|
|
27
27
|
comfyui = [
|
|
28
|
-
"hypercli-sdk[comfyui]>=2026.4.
|
|
28
|
+
"hypercli-sdk[comfyui]>=2026.4.17",
|
|
29
29
|
]
|
|
30
30
|
wallet = [
|
|
31
31
|
"x402[httpx,evm]>=2.0.0",
|
|
@@ -38,7 +38,7 @@ stt = [
|
|
|
38
38
|
"faster-whisper>=1.1.0",
|
|
39
39
|
]
|
|
40
40
|
all = [
|
|
41
|
-
"hypercli-sdk[comfyui]>=2026.4.
|
|
41
|
+
"hypercli-sdk[comfyui]>=2026.4.17",
|
|
42
42
|
"x402[httpx,evm]>=2.0.0",
|
|
43
43
|
"eth-account>=0.13.0",
|
|
44
44
|
"web3>=7.0.0",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from hypercli_cli.cli import app
|
|
9
|
+
import hypercli_cli.agent as agent_mod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_agent_subscribe_passes_explicit_passphrase(monkeypatch, tmp_path):
|
|
16
|
+
hypercli_dir = tmp_path / ".hypercli"
|
|
17
|
+
monkeypatch.setattr(agent_mod, "HYPERCLI_DIR", hypercli_dir)
|
|
18
|
+
monkeypatch.setattr(agent_mod, "AGENT_KEY_PATH", hypercli_dir / "agent-key.json")
|
|
19
|
+
monkeypatch.setattr(agent_mod, "X402_AVAILABLE", True)
|
|
20
|
+
|
|
21
|
+
load_calls: list[str | None] = []
|
|
22
|
+
|
|
23
|
+
def _fake_load_wallet(*, passphrase=None):
|
|
24
|
+
load_calls.append(passphrase)
|
|
25
|
+
return SimpleNamespace(address="0xabc")
|
|
26
|
+
|
|
27
|
+
def _fake_subscribe_async(account, plan_id: str, api_base: str, amount: str | None = None):
|
|
28
|
+
assert account.address == "0xabc"
|
|
29
|
+
assert plan_id == "1aiu"
|
|
30
|
+
assert amount == "0.01"
|
|
31
|
+
return {
|
|
32
|
+
"key": "hyper_api_test",
|
|
33
|
+
"plan_id": "1aiu",
|
|
34
|
+
"amount_paid": "0.010000",
|
|
35
|
+
"duration_days": 0.5,
|
|
36
|
+
"expires_at": "2026-04-14T00:00:00Z",
|
|
37
|
+
"tpm_limit": 1000,
|
|
38
|
+
"rpm_limit": 10,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr("hypercli_cli.wallet.load_wallet", _fake_load_wallet)
|
|
42
|
+
monkeypatch.setattr(agent_mod.asyncio, "run", lambda coro: coro)
|
|
43
|
+
monkeypatch.setattr(agent_mod, "_subscribe_async", _fake_subscribe_async)
|
|
44
|
+
|
|
45
|
+
result = runner.invoke(app, ["agent", "subscribe", "1aiu", "0.01", "--passphrase", "secret"])
|
|
46
|
+
|
|
47
|
+
assert result.exit_code == 0
|
|
48
|
+
assert load_calls == ["secret"]
|
|
49
|
+
saved = json.loads((hypercli_dir / "agent-key.json").read_text())
|
|
50
|
+
assert saved["key"] == "hyper_api_test"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_agent_subscribe_uses_product_api_base_env(monkeypatch, tmp_path):
|
|
54
|
+
hypercli_dir = tmp_path / ".hypercli"
|
|
55
|
+
monkeypatch.setattr(agent_mod, "HYPERCLI_DIR", hypercli_dir)
|
|
56
|
+
monkeypatch.setattr(agent_mod, "AGENT_KEY_PATH", hypercli_dir / "agent-key.json")
|
|
57
|
+
monkeypatch.setattr(agent_mod, "X402_AVAILABLE", True)
|
|
58
|
+
monkeypatch.setenv("HYPER_API_BASE", "https://api.dev.hypercli.com")
|
|
59
|
+
|
|
60
|
+
def _fake_load_wallet(*, passphrase=None):
|
|
61
|
+
assert passphrase is None
|
|
62
|
+
return SimpleNamespace(address="0xabc")
|
|
63
|
+
|
|
64
|
+
def _fake_subscribe_async(account, plan_id: str, api_base: str, amount: str | None = None):
|
|
65
|
+
assert account.address == "0xabc"
|
|
66
|
+
assert plan_id == "1aiu"
|
|
67
|
+
assert amount == "0.01"
|
|
68
|
+
assert api_base == "https://api.dev.hypercli.com"
|
|
69
|
+
return {
|
|
70
|
+
"key": "hyper_api_test",
|
|
71
|
+
"plan_id": "1aiu",
|
|
72
|
+
"amount_paid": "0.010000",
|
|
73
|
+
"duration_days": 0.5,
|
|
74
|
+
"expires_at": "2026-04-14T00:00:00Z",
|
|
75
|
+
"tpm_limit": 1000,
|
|
76
|
+
"rpm_limit": 10,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr("hypercli_cli.wallet.load_wallet", _fake_load_wallet)
|
|
80
|
+
monkeypatch.setattr(agent_mod.asyncio, "run", lambda coro: coro)
|
|
81
|
+
monkeypatch.setattr(agent_mod, "_subscribe_async", _fake_subscribe_async)
|
|
82
|
+
|
|
83
|
+
result = runner.invoke(app, ["agent", "subscribe", "1aiu", "0.01"])
|
|
84
|
+
|
|
85
|
+
assert result.exit_code == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_extract_plan_purchase_url_from_agent_discovery():
|
|
89
|
+
discovery = {
|
|
90
|
+
"resources": [
|
|
91
|
+
"https://api.dev.hypercli.com/agents/x402/1aiu",
|
|
92
|
+
"https://api.dev.hypercli.com/agents/x402/2aiu",
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
assert (
|
|
97
|
+
agent_mod._extract_plan_purchase_url_from_discovery(discovery, "1aiu")
|
|
98
|
+
== "https://api.dev.hypercli.com/agents/x402/1aiu"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_extract_plan_purchase_url_from_discovery_ignores_nonmatching_resources():
|
|
103
|
+
discovery = {
|
|
104
|
+
"resources": [
|
|
105
|
+
"https://api.dev.hypercli.com/api/x402/top_up",
|
|
106
|
+
"https://api.dev.hypercli.com/api/x402/job",
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
assert agent_mod._extract_plan_purchase_url_from_discovery(discovery, "1aiu") is None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typer.testing import CliRunner
|
|
2
|
+
|
|
3
|
+
from hypercli_cli.cli import app
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
runner = CliRunner()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _FakeKey:
|
|
10
|
+
key_id = "key-123"
|
|
11
|
+
name = "demo"
|
|
12
|
+
api_key = "hyper_api_test"
|
|
13
|
+
tags = ["*:*"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_keys_create_all_expands_to_global_scope(monkeypatch):
|
|
17
|
+
import hypercli_cli.keys as keys
|
|
18
|
+
|
|
19
|
+
captured = {}
|
|
20
|
+
|
|
21
|
+
class _FakeKeys:
|
|
22
|
+
def create(self, *, name, tags=None):
|
|
23
|
+
captured["name"] = name
|
|
24
|
+
captured["tags"] = tags
|
|
25
|
+
return _FakeKey()
|
|
26
|
+
|
|
27
|
+
class _FakeClient:
|
|
28
|
+
keys = _FakeKeys()
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(keys, "_get_client", lambda: _FakeClient())
|
|
31
|
+
|
|
32
|
+
result = runner.invoke(app, ["keys", "create", "--name", "demo", "--all"])
|
|
33
|
+
|
|
34
|
+
assert result.exit_code == 0, result.stdout
|
|
35
|
+
assert captured == {"name": "demo", "tags": ["*:*"]}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_keys_create_rejects_all_and_tag_together():
|
|
39
|
+
result = runner.invoke(app, ["keys", "create", "--all", "--tag", "team=dev"])
|
|
40
|
+
|
|
41
|
+
assert result.exit_code != 0
|
|
42
|
+
assert "Use either --all or --tag, not both" in result.stdout
|
|
@@ -70,3 +70,39 @@ def test_agent_transcribe_command_is_removed():
|
|
|
70
70
|
|
|
71
71
|
assert result.exit_code != 0
|
|
72
72
|
assert "No such command 'transcribe'" in result.stdout
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_voice_tts_forwards_timeout(monkeypatch, tmp_path):
|
|
76
|
+
import hypercli_cli.voice as voice
|
|
77
|
+
|
|
78
|
+
monkeypatch.setenv("HYPER_API_KEY", "hyper_api_test")
|
|
79
|
+
captured = {}
|
|
80
|
+
|
|
81
|
+
def _fake_post_voice(endpoint, api_key, output, base_url=None, **kwargs):
|
|
82
|
+
captured["endpoint"] = endpoint
|
|
83
|
+
captured["api_key"] = api_key
|
|
84
|
+
captured["output"] = output
|
|
85
|
+
captured["base_url"] = base_url
|
|
86
|
+
captured["kwargs"] = kwargs
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(voice, "_post_voice", _fake_post_voice)
|
|
89
|
+
|
|
90
|
+
output = tmp_path / "tts.wav"
|
|
91
|
+
result = runner.invoke(
|
|
92
|
+
app,
|
|
93
|
+
[
|
|
94
|
+
"voice",
|
|
95
|
+
"tts",
|
|
96
|
+
"hello",
|
|
97
|
+
"--format",
|
|
98
|
+
"wav",
|
|
99
|
+
"--output",
|
|
100
|
+
str(output),
|
|
101
|
+
"--timeout",
|
|
102
|
+
"720",
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert result.exit_code == 0, result.stdout
|
|
107
|
+
assert captured["endpoint"] == "tts"
|
|
108
|
+
assert captured["kwargs"]["timeout"] == 720.0
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from hypercli_cli.cli import app
|
|
8
|
+
import hypercli_cli.wallet as wallet_mod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeLocalAccount:
|
|
15
|
+
def __init__(self, private_key_hex: str):
|
|
16
|
+
raw = private_key_hex.removeprefix("0x").lower()
|
|
17
|
+
self._private_key_hex = raw
|
|
18
|
+
self.key = bytes.fromhex(raw)
|
|
19
|
+
self.address = f"0x{raw[-40:]}"
|
|
20
|
+
|
|
21
|
+
def encrypt(self, passphrase: str):
|
|
22
|
+
return {
|
|
23
|
+
"address": self.address[2:].lower(),
|
|
24
|
+
"crypto": {
|
|
25
|
+
"ciphertext": self._private_key_hex[::-1],
|
|
26
|
+
"passphrase": passphrase,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def sign_message(self, _message):
|
|
31
|
+
return SimpleNamespace(signature=b"\x12\x34")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeAccountAPI:
|
|
35
|
+
CREATED_PRIVATE_KEY = "11" * 32
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def create():
|
|
39
|
+
return FakeLocalAccount(FakeAccountAPI.CREATED_PRIVATE_KEY)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def decrypt(data, passphrase):
|
|
43
|
+
crypto = data.get("crypto") or data.get("Crypto") or {}
|
|
44
|
+
if crypto.get("passphrase") != passphrase:
|
|
45
|
+
raise ValueError("bad passphrase")
|
|
46
|
+
return bytes.fromhex(str(crypto["ciphertext"])[::-1])
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_key(private_key):
|
|
50
|
+
if isinstance(private_key, bytes):
|
|
51
|
+
raw = private_key.hex()
|
|
52
|
+
else:
|
|
53
|
+
raw = str(private_key).removeprefix("0x")
|
|
54
|
+
return FakeLocalAccount(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_temp_wallet_paths(monkeypatch, tmp_path):
|
|
58
|
+
wallet_dir = tmp_path / ".hypercli"
|
|
59
|
+
wallet_path = wallet_dir / "wallet.json"
|
|
60
|
+
passphrase_path = wallet_dir / "wallet.passphrase"
|
|
61
|
+
monkeypatch.setattr(wallet_mod, "WALLET_AVAILABLE", True)
|
|
62
|
+
monkeypatch.setattr(wallet_mod, "Account", FakeAccountAPI)
|
|
63
|
+
monkeypatch.setattr(wallet_mod, "WALLET_DIR", wallet_dir)
|
|
64
|
+
monkeypatch.setattr(wallet_mod, "WALLET_PATH", wallet_path)
|
|
65
|
+
monkeypatch.setattr(wallet_mod, "WALLET_PASSPHRASE_PATH", passphrase_path)
|
|
66
|
+
return wallet_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_wallet_create_unencrypted_wallet(monkeypatch, tmp_path):
|
|
70
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
71
|
+
|
|
72
|
+
result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
|
|
75
|
+
plain_data = json.loads(wallet_path.read_text())
|
|
76
|
+
assert plain_data["type"] == "plaintext_private_key"
|
|
77
|
+
assert plain_data["private_key"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY}"
|
|
78
|
+
assert plain_data["address"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_old_unencrypted_wallet_import(monkeypatch, tmp_path):
|
|
82
|
+
known_private_key = "22" * 32
|
|
83
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
84
|
+
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
wallet_path.write_text(
|
|
86
|
+
json.dumps(
|
|
87
|
+
{
|
|
88
|
+
"type": "plaintext_private_key",
|
|
89
|
+
"address": f"0x{known_private_key[-40:]}",
|
|
90
|
+
"private_key": f"0x{known_private_key}",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
result = runner.invoke(app, ["wallet", "address"])
|
|
96
|
+
assert result.exit_code == 0
|
|
97
|
+
assert f"0x{known_private_key[-40:]}" in result.stdout
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_old_encrypted_wallet_import(monkeypatch, tmp_path):
|
|
101
|
+
known_private_key = "44" * 32
|
|
102
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
103
|
+
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
legacy_account = FakeLocalAccount(known_private_key)
|
|
105
|
+
wallet_path.write_text(json.dumps(legacy_account.encrypt("legacy-pass")))
|
|
106
|
+
|
|
107
|
+
result = runner.invoke(app, ["wallet", "decrypt", "--passphrase", "legacy-pass"])
|
|
108
|
+
|
|
109
|
+
assert result.exit_code == 0
|
|
110
|
+
decrypted_data = json.loads(wallet_path.read_text())
|
|
111
|
+
assert decrypted_data["type"] == "plaintext_private_key"
|
|
112
|
+
assert decrypted_data["private_key"] == f"0x{known_private_key}"
|
|
113
|
+
assert decrypted_data["address"] == legacy_account.address
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_encrypt_created_plaintext_wallet(monkeypatch, tmp_path):
|
|
117
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
118
|
+
|
|
119
|
+
create_result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
120
|
+
assert create_result.exit_code == 0
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(app, ["wallet", "encrypt", "--passphrase", "secret"])
|
|
123
|
+
assert result.exit_code == 0
|
|
124
|
+
|
|
125
|
+
encrypted_data = json.loads(wallet_path.read_text())
|
|
126
|
+
assert "crypto" in encrypted_data
|
|
127
|
+
assert "private_key" not in encrypted_data
|
|
128
|
+
assert encrypted_data["address"] == FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_decrypt_encrypted_wallet(monkeypatch, tmp_path):
|
|
132
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
133
|
+
|
|
134
|
+
create_result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
135
|
+
assert create_result.exit_code == 0
|
|
136
|
+
encrypt_result = runner.invoke(app, ["wallet", "encrypt", "--passphrase", "secret"])
|
|
137
|
+
assert encrypt_result.exit_code == 0
|
|
138
|
+
|
|
139
|
+
result = runner.invoke(app, ["wallet", "decrypt", "--passphrase", "secret"])
|
|
140
|
+
assert result.exit_code == 0
|
|
141
|
+
|
|
142
|
+
decrypted_data = json.loads(wallet_path.read_text())
|
|
143
|
+
assert decrypted_data["type"] == "plaintext_private_key"
|
|
144
|
+
assert decrypted_data["private_key"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY}"
|
|
145
|
+
assert decrypted_data["address"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_load_wallet_uses_explicit_passphrase(monkeypatch, tmp_path):
|
|
149
|
+
known_private_key = "55" * 32
|
|
150
|
+
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
151
|
+
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
wallet_path.write_text(json.dumps(FakeLocalAccount(known_private_key).encrypt("secret")))
|
|
153
|
+
|
|
154
|
+
account = wallet_mod.load_wallet(passphrase="secret")
|
|
155
|
+
|
|
156
|
+
assert account.address == f"0x{known_private_key[-40:]}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_wallet_balance_passes_explicit_passphrase(monkeypatch, tmp_path):
|
|
160
|
+
_set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
161
|
+
seen: list[str | None] = []
|
|
162
|
+
|
|
163
|
+
class _FakeCall:
|
|
164
|
+
def call(self):
|
|
165
|
+
return 1234567
|
|
166
|
+
|
|
167
|
+
class _FakeContractFunctions:
|
|
168
|
+
def balanceOf(self, _address):
|
|
169
|
+
return _FakeCall()
|
|
170
|
+
|
|
171
|
+
class _FakeContract:
|
|
172
|
+
functions = _FakeContractFunctions()
|
|
173
|
+
|
|
174
|
+
class _FakeEth:
|
|
175
|
+
def contract(self, address=None, abi=None):
|
|
176
|
+
return _FakeContract()
|
|
177
|
+
|
|
178
|
+
class _FakeWeb3:
|
|
179
|
+
HTTPProvider = staticmethod(lambda url: url)
|
|
180
|
+
|
|
181
|
+
def __init__(self, _provider):
|
|
182
|
+
self.eth = _FakeEth()
|
|
183
|
+
|
|
184
|
+
def _fake_load_wallet(*, passphrase=None):
|
|
185
|
+
seen.append(passphrase)
|
|
186
|
+
return SimpleNamespace(address="0xabc")
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(wallet_mod, "load_wallet", _fake_load_wallet)
|
|
189
|
+
monkeypatch.setattr(wallet_mod, "Web3", _FakeWeb3)
|
|
190
|
+
|
|
191
|
+
result = runner.invoke(app, ["wallet", "balance", "--passphrase", "secret"])
|
|
192
|
+
|
|
193
|
+
assert result.exit_code == 0
|
|
194
|
+
assert seen == ["secret"]
|
|
195
|
+
assert "1.234567" in result.stdout
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_wallet_login_passes_explicit_passphrase(monkeypatch, tmp_path):
|
|
199
|
+
_set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
200
|
+
load_calls: list[str | None] = []
|
|
201
|
+
auth_calls: list[str | None] = []
|
|
202
|
+
|
|
203
|
+
def _fake_load_wallet(*, passphrase=None):
|
|
204
|
+
load_calls.append(passphrase)
|
|
205
|
+
return SimpleNamespace(address="0xabc")
|
|
206
|
+
|
|
207
|
+
def _fake_get_wallet_auth_token(api_url=None, *, passphrase=None):
|
|
208
|
+
auth_calls.append(passphrase)
|
|
209
|
+
return "jwt-token"
|
|
210
|
+
|
|
211
|
+
class _FakeHttpxClient:
|
|
212
|
+
def __init__(self, timeout=None):
|
|
213
|
+
self.timeout = timeout
|
|
214
|
+
|
|
215
|
+
def __enter__(self):
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __exit__(self, exc_type, exc, tb):
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def post(self, url, json=None, headers=None):
|
|
222
|
+
return SimpleNamespace(
|
|
223
|
+
status_code=200,
|
|
224
|
+
json=lambda: {"api_key": "hyper_api_test", "name": json["name"]},
|
|
225
|
+
text="ok",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
monkeypatch.setattr(wallet_mod, "load_wallet", _fake_load_wallet)
|
|
229
|
+
monkeypatch.setattr(wallet_mod, "get_wallet_auth_token", _fake_get_wallet_auth_token)
|
|
230
|
+
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace(Client=_FakeHttpxClient))
|
|
231
|
+
monkeypatch.setattr(
|
|
232
|
+
sys.modules["hypercli.config"],
|
|
233
|
+
"configure",
|
|
234
|
+
lambda api_key, api_url: None,
|
|
235
|
+
)
|
|
236
|
+
monkeypatch.setattr(
|
|
237
|
+
sys.modules["hypercli.config"],
|
|
238
|
+
"get_api_url",
|
|
239
|
+
lambda: "https://api.example.com",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
result = runner.invoke(app, ["wallet", "login", "--passphrase", "secret"])
|
|
243
|
+
|
|
244
|
+
assert result.exit_code == 0
|
|
245
|
+
assert load_calls == ["secret"]
|
|
246
|
+
assert auth_calls == ["secret"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_wallet_topup_passes_explicit_passphrase(monkeypatch, tmp_path):
|
|
250
|
+
_set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
251
|
+
load_calls: list[str | None] = []
|
|
252
|
+
|
|
253
|
+
class _FakeCall:
|
|
254
|
+
def call(self):
|
|
255
|
+
return 2_000_000
|
|
256
|
+
|
|
257
|
+
class _FakeContractFunctions:
|
|
258
|
+
def balanceOf(self, _address):
|
|
259
|
+
return _FakeCall()
|
|
260
|
+
|
|
261
|
+
class _FakeContract:
|
|
262
|
+
functions = _FakeContractFunctions()
|
|
263
|
+
|
|
264
|
+
class _FakeEth:
|
|
265
|
+
def contract(self, address=None, abi=None):
|
|
266
|
+
return _FakeContract()
|
|
267
|
+
|
|
268
|
+
class _FakeWeb3:
|
|
269
|
+
HTTPProvider = staticmethod(lambda url: url)
|
|
270
|
+
|
|
271
|
+
def __init__(self, _provider):
|
|
272
|
+
self.eth = _FakeEth()
|
|
273
|
+
|
|
274
|
+
class _FakeX402ClientSync:
|
|
275
|
+
def register(self, *_args, **_kwargs):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
class _FakeX402HTTPClientSync:
|
|
279
|
+
def __init__(self, _client):
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
def handle_402_response(self, headers, content):
|
|
283
|
+
return ({"X-Payment": "sig"}, None)
|
|
284
|
+
|
|
285
|
+
def get_payment_settle_response(self, getter):
|
|
286
|
+
return SimpleNamespace(transaction=None, network=None, error_reason=None)
|
|
287
|
+
|
|
288
|
+
class _FakeEthAccountSigner:
|
|
289
|
+
def __init__(self, account):
|
|
290
|
+
self.account = account
|
|
291
|
+
|
|
292
|
+
class _FakeHttpxClient:
|
|
293
|
+
def __init__(self, timeout=None):
|
|
294
|
+
self.timeout = timeout
|
|
295
|
+
self.posts = 0
|
|
296
|
+
|
|
297
|
+
def __enter__(self):
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
def __exit__(self, exc_type, exc, tb):
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
def get(self, url, headers=None):
|
|
304
|
+
return SimpleNamespace(status_code=200, json=lambda: {"user_id": "user-1"}, text="ok")
|
|
305
|
+
|
|
306
|
+
def post(self, url, headers=None, json=None):
|
|
307
|
+
self.posts += 1
|
|
308
|
+
if self.posts == 1:
|
|
309
|
+
return SimpleNamespace(status_code=402, headers={}, content=b"", text="payment required")
|
|
310
|
+
return SimpleNamespace(
|
|
311
|
+
status_code=200,
|
|
312
|
+
json=lambda: {
|
|
313
|
+
"user_id": "user-1",
|
|
314
|
+
"amount": 1.0,
|
|
315
|
+
"wallet": "0xabc",
|
|
316
|
+
"transaction_id": "tx-1",
|
|
317
|
+
"message": "Top-up successful",
|
|
318
|
+
},
|
|
319
|
+
headers={},
|
|
320
|
+
text="ok",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _fake_load_wallet(*, passphrase=None):
|
|
324
|
+
load_calls.append(passphrase)
|
|
325
|
+
return SimpleNamespace(address="0xabc")
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr(wallet_mod, "load_wallet", _fake_load_wallet)
|
|
328
|
+
monkeypatch.setattr(wallet_mod, "Web3", _FakeWeb3)
|
|
329
|
+
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace(Client=_FakeHttpxClient))
|
|
330
|
+
monkeypatch.setattr(sys.modules["hypercli.config"], "get_api_key", lambda: "hyper_api_test")
|
|
331
|
+
monkeypatch.setattr(sys.modules["hypercli.config"], "get_api_url", lambda: "https://api.example.com")
|
|
332
|
+
|
|
333
|
+
sys.modules["x402"] = SimpleNamespace(x402ClientSync=_FakeX402ClientSync)
|
|
334
|
+
sys.modules["x402.http"] = SimpleNamespace(x402HTTPClientSync=_FakeX402HTTPClientSync)
|
|
335
|
+
sys.modules["x402.mechanisms.evm"] = SimpleNamespace(EthAccountSigner=_FakeEthAccountSigner)
|
|
336
|
+
sys.modules["x402.mechanisms.evm.exact.register"] = SimpleNamespace(
|
|
337
|
+
register_exact_evm_client=lambda *_args, **_kwargs: None
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
result = runner.invoke(app, ["wallet", "topup", "1.0", "--passphrase", "secret"])
|
|
341
|
+
|
|
342
|
+
assert result.exit_code == 0
|
|
343
|
+
assert load_calls == ["secret"]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2026.4.13"
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from types import SimpleNamespace
|
|
3
|
-
|
|
4
|
-
from typer.testing import CliRunner
|
|
5
|
-
|
|
6
|
-
from hypercli_cli.cli import app
|
|
7
|
-
import hypercli_cli.wallet as wallet_mod
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
runner = CliRunner()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class FakeLocalAccount:
|
|
14
|
-
def __init__(self, private_key_hex: str):
|
|
15
|
-
raw = private_key_hex.removeprefix("0x").lower()
|
|
16
|
-
self._private_key_hex = raw
|
|
17
|
-
self.key = bytes.fromhex(raw)
|
|
18
|
-
self.address = f"0x{raw[-40:]}"
|
|
19
|
-
|
|
20
|
-
def encrypt(self, passphrase: str):
|
|
21
|
-
return {
|
|
22
|
-
"address": self.address[2:].lower(),
|
|
23
|
-
"crypto": {
|
|
24
|
-
"ciphertext": self._private_key_hex[::-1],
|
|
25
|
-
"passphrase": passphrase,
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
def sign_message(self, _message):
|
|
30
|
-
return SimpleNamespace(signature=b"\x12\x34")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class FakeAccountAPI:
|
|
34
|
-
CREATED_PRIVATE_KEY = "11" * 32
|
|
35
|
-
|
|
36
|
-
@staticmethod
|
|
37
|
-
def create():
|
|
38
|
-
return FakeLocalAccount(FakeAccountAPI.CREATED_PRIVATE_KEY)
|
|
39
|
-
|
|
40
|
-
@staticmethod
|
|
41
|
-
def decrypt(data, passphrase):
|
|
42
|
-
crypto = data.get("crypto") or data.get("Crypto") or {}
|
|
43
|
-
if crypto.get("passphrase") != passphrase:
|
|
44
|
-
raise ValueError("bad passphrase")
|
|
45
|
-
return bytes.fromhex(str(crypto["ciphertext"])[::-1])
|
|
46
|
-
|
|
47
|
-
@staticmethod
|
|
48
|
-
def from_key(private_key):
|
|
49
|
-
if isinstance(private_key, bytes):
|
|
50
|
-
raw = private_key.hex()
|
|
51
|
-
else:
|
|
52
|
-
raw = str(private_key).removeprefix("0x")
|
|
53
|
-
return FakeLocalAccount(raw)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _set_temp_wallet_paths(monkeypatch, tmp_path):
|
|
57
|
-
wallet_dir = tmp_path / ".hypercli"
|
|
58
|
-
wallet_path = wallet_dir / "wallet.json"
|
|
59
|
-
passphrase_path = wallet_dir / "wallet.passphrase"
|
|
60
|
-
monkeypatch.setattr(wallet_mod, "WALLET_AVAILABLE", True)
|
|
61
|
-
monkeypatch.setattr(wallet_mod, "Account", FakeAccountAPI)
|
|
62
|
-
monkeypatch.setattr(wallet_mod, "WALLET_DIR", wallet_dir)
|
|
63
|
-
monkeypatch.setattr(wallet_mod, "WALLET_PATH", wallet_path)
|
|
64
|
-
monkeypatch.setattr(wallet_mod, "WALLET_PASSPHRASE_PATH", passphrase_path)
|
|
65
|
-
return wallet_path
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_wallet_create_unencrypted_wallet(monkeypatch, tmp_path):
|
|
69
|
-
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
70
|
-
|
|
71
|
-
result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
72
|
-
assert result.exit_code == 0
|
|
73
|
-
|
|
74
|
-
plain_data = json.loads(wallet_path.read_text())
|
|
75
|
-
assert plain_data["type"] == "plaintext_private_key"
|
|
76
|
-
assert plain_data["private_key"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY}"
|
|
77
|
-
assert plain_data["address"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]}"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_old_unencrypted_wallet_import(monkeypatch, tmp_path):
|
|
81
|
-
known_private_key = "22" * 32
|
|
82
|
-
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
83
|
-
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
-
wallet_path.write_text(
|
|
85
|
-
json.dumps(
|
|
86
|
-
{
|
|
87
|
-
"type": "plaintext_private_key",
|
|
88
|
-
"address": f"0x{known_private_key[-40:]}",
|
|
89
|
-
"private_key": f"0x{known_private_key}",
|
|
90
|
-
}
|
|
91
|
-
)
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
result = runner.invoke(app, ["wallet", "address"])
|
|
95
|
-
assert result.exit_code == 0
|
|
96
|
-
assert f"0x{known_private_key[-40:]}" in result.stdout
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_old_encrypted_wallet_import(monkeypatch, tmp_path):
|
|
100
|
-
known_private_key = "44" * 32
|
|
101
|
-
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
102
|
-
wallet_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
-
legacy_account = FakeLocalAccount(known_private_key)
|
|
104
|
-
wallet_path.write_text(json.dumps(legacy_account.encrypt("legacy-pass")))
|
|
105
|
-
|
|
106
|
-
result = runner.invoke(app, ["wallet", "decrypt", "--passphrase", "legacy-pass"])
|
|
107
|
-
|
|
108
|
-
assert result.exit_code == 0
|
|
109
|
-
decrypted_data = json.loads(wallet_path.read_text())
|
|
110
|
-
assert decrypted_data["type"] == "plaintext_private_key"
|
|
111
|
-
assert decrypted_data["private_key"] == f"0x{known_private_key}"
|
|
112
|
-
assert decrypted_data["address"] == legacy_account.address
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_encrypt_created_plaintext_wallet(monkeypatch, tmp_path):
|
|
116
|
-
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
117
|
-
|
|
118
|
-
create_result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
119
|
-
assert create_result.exit_code == 0
|
|
120
|
-
|
|
121
|
-
result = runner.invoke(app, ["wallet", "encrypt", "--passphrase", "secret"])
|
|
122
|
-
assert result.exit_code == 0
|
|
123
|
-
|
|
124
|
-
encrypted_data = json.loads(wallet_path.read_text())
|
|
125
|
-
assert "crypto" in encrypted_data
|
|
126
|
-
assert "private_key" not in encrypted_data
|
|
127
|
-
assert encrypted_data["address"] == FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def test_decrypt_encrypted_wallet(monkeypatch, tmp_path):
|
|
131
|
-
wallet_path = _set_temp_wallet_paths(monkeypatch, tmp_path)
|
|
132
|
-
|
|
133
|
-
create_result = runner.invoke(app, ["wallet", "create", "--no-passphrase"])
|
|
134
|
-
assert create_result.exit_code == 0
|
|
135
|
-
encrypt_result = runner.invoke(app, ["wallet", "encrypt", "--passphrase", "secret"])
|
|
136
|
-
assert encrypt_result.exit_code == 0
|
|
137
|
-
|
|
138
|
-
result = runner.invoke(app, ["wallet", "decrypt", "--passphrase", "secret"])
|
|
139
|
-
assert result.exit_code == 0
|
|
140
|
-
|
|
141
|
-
decrypted_data = json.loads(wallet_path.read_text())
|
|
142
|
-
assert decrypted_data["type"] == "plaintext_private_key"
|
|
143
|
-
assert decrypted_data["private_key"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY}"
|
|
144
|
-
assert decrypted_data["address"] == f"0x{FakeAccountAPI.CREATED_PRIVATE_KEY[-40:]}"
|
|
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
|
{hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_wallet_migration_integration.py
RENAMED
|
File without changes
|