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.
Files changed (42) hide show
  1. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/PKG-INFO +4 -4
  2. hypercli_cli-2026.4.17/hypercli_cli/__init__.py +1 -0
  3. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/agent.py +56 -7
  4. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/keys.py +5 -1
  5. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/voice.py +6 -0
  6. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/wallet.py +26 -9
  7. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/pyproject.toml +4 -4
  8. hypercli_cli-2026.4.17/tests/test_agent_subscribe_command.py +110 -0
  9. hypercli_cli-2026.4.17/tests/test_keys_command.py +42 -0
  10. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_voice_command.py +36 -0
  11. hypercli_cli-2026.4.17/tests/test_wallet_command.py +343 -0
  12. hypercli_cli-2026.4.13/hypercli_cli/__init__.py +0 -1
  13. hypercli_cli-2026.4.13/tests/test_wallet_command.py +0 -144
  14. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/.gitignore +0 -0
  15. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/README.md +0 -0
  16. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/agents.py +0 -0
  17. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/billing.py +0 -0
  18. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/cli.py +0 -0
  19. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/comfyui.py +0 -0
  20. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/embed.py +0 -0
  21. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/files.py +0 -0
  22. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/flow.py +0 -0
  23. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/instances.py +0 -0
  24. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/jobs.py +0 -0
  25. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/llm.py +0 -0
  26. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/onboard.py +0 -0
  27. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/output.py +0 -0
  28. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/renders.py +0 -0
  29. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/stt.py +0 -0
  30. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/tui/__init__.py +0 -0
  31. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/tui/job_monitor.py +0 -0
  32. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/hypercli_cli/user.py +0 -0
  33. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_agent_env_resolution.py +0 -0
  34. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_config_command.py +0 -0
  35. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_exec_shell_dryrun.py +0 -0
  36. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_flow_command.py +0 -0
  37. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_flow_visibility.py +0 -0
  38. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_jobs_list_tags.py +0 -0
  39. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_llm_command.py +0 -0
  40. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_me_command.py +0 -0
  41. {hypercli_cli-2026.4.13 → hypercli_cli-2026.4.17}/tests/test_openclaw_config.py +0 -0
  42. {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.13
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.13
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.13; extra == 'all'
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.13; extra == 'comfyui'
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
- dev: bool = typer.Option(False, "--dev", help="Use dev API (api.dev.hypercli.com)")
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 = DEV_API_BASE if dev else PROD_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.13"
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.13",
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.13",
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.13",
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:]}"