hypercli-cli 2026.3.13__tar.gz → 2026.3.22__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 (29) hide show
  1. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/.gitignore +3 -0
  2. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/PKG-INFO +4 -4
  3. hypercli_cli-2026.3.22/hypercli_cli/__init__.py +1 -0
  4. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/agent.py +161 -91
  5. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/agents.py +69 -40
  6. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/cli.py +3 -1
  7. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/embed.py +3 -3
  8. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/instances.py +28 -1
  9. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/voice.py +11 -5
  10. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/pyproject.toml +4 -4
  11. hypercli_cli-2026.3.22/tests/test_agent_env_resolution.py +39 -0
  12. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/tests/test_exec_shell_dryrun.py +46 -0
  13. hypercli_cli-2026.3.22/tests/test_openclaw_config.py +63 -0
  14. hypercli_cli-2026.3.13/hypercli_cli/__init__.py +0 -1
  15. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/README.md +0 -0
  16. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/billing.py +0 -0
  17. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/comfyui.py +0 -0
  18. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/flow.py +0 -0
  19. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/jobs.py +0 -0
  20. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/keys.py +0 -0
  21. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/onboard.py +0 -0
  22. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/output.py +0 -0
  23. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/renders.py +0 -0
  24. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/stt.py +0 -0
  25. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/tui/__init__.py +0 -0
  26. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/tui/job_monitor.py +0 -0
  27. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/user.py +0 -0
  28. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/hypercli_cli/wallet.py +0 -0
  29. {hypercli_cli-2026.3.13 → hypercli_cli-2026.3.22}/tests/test_jobs_list_tags.py +0 -0
@@ -28,6 +28,9 @@ coverage
28
28
  .DS_Store
29
29
  *.pem
30
30
 
31
+ # IDE
32
+ .idea/
33
+
31
34
  # Debug
32
35
  npm-debug.log*
33
36
  yarn-debug.log*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-cli
3
- Version: 2026.3.13
3
+ Version: 2026.3.22
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.13
12
+ Requires-Dist: hypercli-sdk>=2026.3.22
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.13; extra == 'all'
22
+ Requires-Dist: hypercli-sdk[comfyui]>=2026.3.22; 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.13; extra == 'comfyui'
26
+ Requires-Dist: hypercli-sdk[comfyui]>=2026.3.22; 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"
@@ -55,6 +55,8 @@ HYPERCLI_DIR = Path.home() / ".hypercli"
55
55
  AGENT_KEY_PATH = HYPERCLI_DIR / "agent-key.json"
56
56
  DEV_API_BASE = "https://api.dev.hypercli.com"
57
57
  PROD_API_BASE = "https://api.hypercli.com"
58
+ DEV_INFERENCE_API_BASE = "https://api.agents.dev.hypercli.com"
59
+ PROD_INFERENCE_API_BASE = "https://api.agents.hypercli.com"
58
60
 
59
61
 
60
62
  def require_x402_deps():
@@ -351,7 +353,7 @@ def models(
351
353
  import httpx
352
354
 
353
355
  api_base = DEV_API_BASE if dev else PROD_API_BASE
354
- key = os.getenv("HYPERCLAW_API_KEY")
356
+ key = os.getenv("HYPER_API_KEY")
355
357
  headers = {"Authorization": f"Bearer {key}"} if key else {}
356
358
 
357
359
  # Prefer OpenAI-compatible endpoint, then fallback to legacy.
@@ -372,7 +374,7 @@ def models(
372
374
  if payload is None:
373
375
  console.print(
374
376
  f"[red]❌ Failed to fetch models from {urls[0]} or {urls[1]} "
375
- "(set HYPERCLAW_API_KEY if endpoint requires auth)[/red]"
377
+ "(set HYPER_API_KEY if endpoint requires auth)[/red]"
376
378
  )
377
379
  raise typer.Exit(1)
378
380
 
@@ -544,12 +546,43 @@ OPENCLAW_CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
544
546
 
545
547
  def _resolve_api_base(base_url: str | None = None, dev: bool = False) -> str:
546
548
  """Resolve API base from flag/env, then fall back to dev/prod defaults."""
547
- return (base_url or os.environ.get("HYPERCLAW_API_BASE") or (DEV_API_BASE if dev else PROD_API_BASE)).rstrip("/")
549
+ return (
550
+ base_url
551
+ or os.environ.get("HYPER_API_BASE")
552
+ or (DEV_INFERENCE_API_BASE if dev else PROD_INFERENCE_API_BASE)
553
+ ).rstrip("/")
548
554
 
549
555
 
550
- def fetch_models(api_key: str, api_base: str = PROD_API_BASE) -> list[dict]:
556
+ def fetch_models(api_key: str, api_base: str = PROD_INFERENCE_API_BASE) -> list[dict]:
551
557
  """Fetch available models from LiteLLM /v1/models (served by HyperClaw)."""
552
558
  import httpx
559
+
560
+ def _infer_mode(model_id: str) -> str | None:
561
+ normalized = (model_id or "").strip().lower()
562
+ if "embedding" in normalized:
563
+ return "embedding"
564
+ return None
565
+
566
+ def _meta_for_model(model_id: str) -> dict:
567
+ normalized = (model_id or "").strip().lower()
568
+ aliases = {
569
+ "kimi-k2.5": {"name": "Kimi K2.5", "reasoning": True, "contextWindow": 262144},
570
+ "moonshotai/kimi-k2.5": {"name": "Kimi K2.5", "reasoning": True, "contextWindow": 262144},
571
+ "glm-5": {"name": "GLM-5", "reasoning": True, "contextWindow": 202752},
572
+ "zai-org/glm-5": {"name": "GLM-5", "reasoning": True, "contextWindow": 202752},
573
+ "qwen3-embedding-4b": {
574
+ "name": "Qwen3 Embedding 4B",
575
+ "reasoning": False,
576
+ "contextWindow": 32768,
577
+ "mode": "embedding",
578
+ "input": ["text"],
579
+ },
580
+ }
581
+ if normalized in aliases:
582
+ return aliases[normalized]
583
+ suffix = normalized.rsplit("/", 1)[-1]
584
+ return aliases.get(suffix, {})
585
+
553
586
  try:
554
587
  resp = httpx.get(
555
588
  f"{api_base}/v1/models",
@@ -558,19 +591,14 @@ def fetch_models(api_key: str, api_base: str = PROD_API_BASE) -> list[dict]:
558
591
  )
559
592
  resp.raise_for_status()
560
593
  data = resp.json().get("data", [])
561
- # Known model metadata (context windows, reasoning, etc.)
562
- MODEL_META = {
563
- "kimi-k2.5": {"name": "Kimi K2.5", "reasoning": True, "contextWindow": 262144},
564
- "glm-5": {"name": "GLM-5", "reasoning": True, "contextWindow": 202752},
565
- }
566
594
  return [
567
595
  {
568
596
  "id": m["id"],
569
- "name": MODEL_META.get(m["id"], {}).get("name", m["id"].replace("-", " ").title()),
570
- "reasoning": MODEL_META.get(m["id"], {}).get("reasoning", False),
571
- "input": ["text"],
572
- "contextWindow": MODEL_META.get(m["id"], {}).get("contextWindow", 200000),
573
- **({"mode": m["mode"]} if m.get("mode") else {}),
597
+ "name": _meta_for_model(m["id"]).get("name", m["id"].replace("-", " ").title()),
598
+ "reasoning": _meta_for_model(m["id"]).get("reasoning", False),
599
+ "input": _meta_for_model(m["id"]).get("input", ["text", "image"]),
600
+ "contextWindow": _meta_for_model(m["id"]).get("contextWindow", 200000),
601
+ **({"mode": m.get("mode") or _meta_for_model(m["id"]).get("mode") or _infer_mode(m["id"])} if (m.get("mode") or _meta_for_model(m["id"]).get("mode") or _infer_mode(m["id"])) else {}),
574
602
  }
575
603
  for m in data
576
604
  if m.get("id")
@@ -583,16 +611,24 @@ def fetch_models(api_key: str, api_base: str = PROD_API_BASE) -> list[dict]:
583
611
  "id": "kimi-k2.5",
584
612
  "name": "Kimi K2.5",
585
613
  "reasoning": True,
586
- "input": ["text"],
614
+ "input": ["text", "image"],
587
615
  "contextWindow": 262144,
588
616
  },
589
617
  {
590
618
  "id": "glm-5",
591
619
  "name": "GLM-5",
592
620
  "reasoning": True,
593
- "input": ["text"],
621
+ "input": ["text", "image"],
594
622
  "contextWindow": 202752,
595
623
  },
624
+ {
625
+ "id": "qwen3-embedding-4b",
626
+ "name": "Qwen3 Embedding 4B",
627
+ "reasoning": False,
628
+ "input": ["text"],
629
+ "contextWindow": 32768,
630
+ "mode": "embedding",
631
+ },
596
632
  ]
597
633
 
598
634
 
@@ -620,49 +656,21 @@ def openclaw_setup(
620
656
  console.print("[red]❌ Invalid key file — missing 'key' field[/red]")
621
657
  raise typer.Exit(1)
622
658
 
623
- # Read existing config (or start empty)
659
+ config = {}
624
660
  if OPENCLAW_CONFIG_PATH.exists():
625
661
  with open(OPENCLAW_CONFIG_PATH) as f:
626
662
  config = json.load(f)
627
- else:
628
- config = {}
629
-
630
- # Fetch current model list from LiteLLM via API
631
- models = fetch_models(api_key)
632
-
633
- # Patch models.providers.hyperclaw + embedding config
634
- config.setdefault("models", {}).setdefault("providers", {})
635
- chat_models = [m for m in models if m.get("mode") != "embedding"]
636
- embedding_models = [m for m in models if m.get("mode") == "embedding"]
637
- config["models"]["providers"]["hyperclaw"] = {
638
- "baseUrl": "https://api.hypercli.com",
639
- "apiKey": api_key,
640
- "api": "anthropic-messages",
641
- "models": chat_models,
642
- }
643
- config["models"]["providers"]["hyperclaw-embed"] = {
644
- "baseUrl": "https://api.hypercli.com/v1",
645
- "apiKey": api_key,
646
- "api": "openai-completions",
647
- "models": embedding_models,
648
- }
649
663
 
650
- # Always set embedding provider (reuses same API key)
651
- config.setdefault("agents", {}).setdefault("defaults", {})
652
- config["agents"]["defaults"]["memorySearch"] = {
653
- "provider": "openai",
654
- "model": "qwen3-embedding-4b",
655
- "remote": {
656
- "baseUrl": "https://api.hypercli.com/v1/",
657
- "apiKey": api_key,
658
- }
659
- }
664
+ models = fetch_models(api_key, PROD_INFERENCE_API_BASE)
665
+ snippet = _config_openclaw(api_key, models, PROD_INFERENCE_API_BASE)
666
+ if not default:
667
+ defaults = (((snippet.get("agents") or {}).get("defaults") or {}))
668
+ model_cfg = defaults.get("model") or {}
669
+ model_cfg.pop("primary", None)
670
+ if not model_cfg and "model" in defaults:
671
+ defaults.pop("model", None)
660
672
 
661
- # Optionally set default model
662
- if default:
663
- config["agents"]["defaults"].setdefault("model", {})
664
- if chat_models:
665
- config["agents"]["defaults"]["model"]["primary"] = f"hyperclaw/{chat_models[0]['id']}"
673
+ _deep_merge(config, snippet)
666
674
 
667
675
  # Write back
668
676
  OPENCLAW_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -671,15 +679,14 @@ def openclaw_setup(
671
679
  f.write("\n")
672
680
 
673
681
  console.print(f"[green]✅ Patched {OPENCLAW_CONFIG_PATH}[/green]")
674
- console.print(f" provider: hyperclaw key: {api_key[:16]}...")
675
- if embedding_models:
676
- console.print(" embedding provider: hyperclaw-embed")
677
- for m in chat_models:
678
- console.print(f" model: hyperclaw/{m['id']}")
679
- for m in embedding_models:
680
- console.print(f" model: hyperclaw-embed/{m['id']}")
681
- if default and chat_models:
682
- console.print(f" default model: hyperclaw/{chat_models[0]['id']}")
682
+ providers = ((snippet.get("models") or {}).get("providers") or {})
683
+ for provider_id, provider_cfg in providers.items():
684
+ console.print(f" provider: {provider_id} key: {api_key[:16]}...")
685
+ for m in provider_cfg.get("models") or []:
686
+ console.print(f" model: {provider_id}/{m['id']}")
687
+ primary = ((((snippet.get("agents") or {}).get("defaults") or {}).get("model") or {}).get("primary"))
688
+ if primary:
689
+ console.print(f" default model: {primary}")
683
690
  console.print("\nOpenClaw will use the Anthropic-compatible /v1/messages endpoint.")
684
691
  console.print("Run: [bold]openclaw gateway restart[/bold]")
685
692
 
@@ -703,11 +710,43 @@ def _resolve_api_key(key: str | None) -> str:
703
710
  raise typer.Exit(1)
704
711
 
705
712
 
706
- def _config_openclaw(api_key: str, models: list[dict], api_base: str = PROD_API_BASE) -> dict:
707
- """OpenClaw openclaw.json provider snippet (LLM + embeddings)."""
713
+ def _config_openclaw(
714
+ api_key: str,
715
+ models: list[dict],
716
+ api_base: str = PROD_INFERENCE_API_BASE,
717
+ placeholder_env: str | None = None,
718
+ ) -> dict:
719
+ """OpenClaw openclaw.json provider snippet (LLM only)."""
720
+ def _model_suffix(model_id: str) -> str:
721
+ return str(model_id or "").strip().lower().rsplit("/", 1)[-1]
722
+
723
+ def _is_supported_openclaw_model(model: dict) -> bool:
724
+ suffix = _model_suffix(model.get("id", ""))
725
+ return (
726
+ suffix == "glm-5"
727
+ or "kimi" in suffix
728
+ or "embedding" in suffix
729
+ )
730
+
708
731
  api_base = api_base.rstrip("/")
709
- chat_models = [m for m in models if m.get("mode") != "embedding"]
710
- embedding_models = [m for m in models if m.get("mode") == "embedding"]
732
+ supported_models = [m for m in models if _is_supported_openclaw_model(m)]
733
+ chat_models = [m for m in supported_models if m.get("mode") != "embedding"]
734
+ embedding_models = [m for m in supported_models if m.get("mode") == "embedding"]
735
+ kimi_models = [m for m in chat_models if "kimi" in _model_suffix(m.get("id", ""))]
736
+ glm_models = [m for m in chat_models if _model_suffix(m.get("id", "")) == "glm-5"]
737
+ openai_chat_models = [
738
+ m for m in chat_models
739
+ if m not in kimi_models and m not in glm_models
740
+ ]
741
+ embedding_model_id = embedding_models[0]["id"] if embedding_models else None
742
+ primary_model = (
743
+ f"kimi-coding/{kimi_models[0]['id']}" if kimi_models else (
744
+ f"hyperclaw/{glm_models[0]['id']}" if glm_models else (
745
+ f"hyperclaw-openai/{openai_chat_models[0]['id']}" if openai_chat_models else None
746
+ )
747
+ )
748
+ )
749
+ config_api_key = f"${{{placeholder_env}}}" if placeholder_env else api_key
711
750
  return {
712
751
  "models": {
713
752
  "mode": "merge",
@@ -715,39 +754,69 @@ def _config_openclaw(api_key: str, models: list[dict], api_base: str = PROD_API_
715
754
  "hyperclaw": {
716
755
  # OpenClaw/pi-ai appends /v1/messages for anthropic-messages.
717
756
  "baseUrl": api_base,
718
- "apiKey": api_key,
757
+ "apiKey": config_api_key,
719
758
  "api": "anthropic-messages",
720
- "models": chat_models,
721
- },
722
- "hyperclaw-embed": {
723
- # Embeddings go through the OpenAI-compatible /v1 endpoints.
724
- "baseUrl": f"{api_base}/v1",
725
- "apiKey": api_key,
726
- "api": "openai-completions",
727
- "models": embedding_models,
759
+ "models": glm_models,
728
760
  },
761
+ **(
762
+ {
763
+ "kimi-coding": {
764
+ # Use the upstream Kimi provider semantics while still
765
+ # routing requests through the HyperClaw Anthropic proxy.
766
+ "baseUrl": api_base,
767
+ "apiKey": config_api_key,
768
+ "api": "anthropic-messages",
769
+ "headers": {
770
+ "User-Agent": "claude-code/0.1.0",
771
+ },
772
+ "models": kimi_models,
773
+ },
774
+ }
775
+ if kimi_models
776
+ else {}
777
+ ),
778
+ **(
779
+ {
780
+ "hyperclaw-openai": {
781
+ "baseUrl": f"{api_base}/v1",
782
+ "apiKey": config_api_key,
783
+ "api": "openai-completions",
784
+ "models": openai_chat_models,
785
+ },
786
+ }
787
+ if openai_chat_models
788
+ else {}
789
+ ),
729
790
  }
730
791
  },
731
792
  "agents": {
732
793
  "defaults": {
794
+ **({"model": {"primary": primary_model}} if primary_model else {}),
733
795
  "models": {
734
- **{f"hyperclaw/{m['id']}": {"alias": m['id'].split('-')[0]} for m in chat_models},
735
- **{f"hyperclaw-embed/{m['id']}": {"alias": m['id'].split('-')[0]} for m in embedding_models},
796
+ **{f"hyperclaw/{m['id']}": {"alias": "glm"} for m in glm_models},
797
+ **{f"kimi-coding/{m['id']}": {"alias": "kimi"} for m in kimi_models},
798
+ **{f"hyperclaw-openai/{m['id']}": {"alias": m['id'].split('-')[0]} for m in openai_chat_models},
736
799
  },
737
- "memorySearch": {
738
- "provider": "openai",
739
- "model": "qwen3-embedding-4b",
740
- "remote": {
741
- "baseUrl": f"{api_base}/v1/",
742
- "apiKey": api_key,
800
+ **(
801
+ {
802
+ "memorySearch": {
803
+ "provider": "openai",
804
+ "model": embedding_model_id,
805
+ "remote": {
806
+ "baseUrl": f"{api_base}/v1",
807
+ "apiKey": config_api_key,
808
+ },
809
+ }
743
810
  }
744
- }
811
+ if embedding_model_id
812
+ else {}
813
+ ),
745
814
  }
746
815
  }
747
816
  }
748
817
 
749
818
 
750
- def _config_opencode(api_key: str, models: list[dict], api_base: str = PROD_API_BASE) -> dict:
819
+ def _config_opencode(api_key: str, models: list[dict], api_base: str = PROD_INFERENCE_API_BASE) -> dict:
751
820
  """OpenCode opencode.json provider snippet."""
752
821
  api_base = api_base.rstrip("/")
753
822
  model_entries = {}
@@ -769,7 +838,7 @@ def _config_opencode(api_key: str, models: list[dict], api_base: str = PROD_API_
769
838
  }
770
839
 
771
840
 
772
- def _config_env(api_key: str, models: list[dict], api_base: str = PROD_API_BASE) -> str:
841
+ def _config_env(api_key: str, models: list[dict], api_base: str = PROD_INFERENCE_API_BASE) -> str:
773
842
  """Shell env vars for generic OpenAI-compatible tools."""
774
843
  api_base = api_base.rstrip("/")
775
844
  lines = [
@@ -786,7 +855,7 @@ def exec_cmd(
786
855
  command: str = typer.Argument(..., help="Command to execute"),
787
856
  timeout: int = typer.Option(30, "--timeout", "-t", help="Command timeout (seconds)"),
788
857
  ):
789
- """Execute a command on a HyperClaw agent container."""
858
+ """Execute a command on a `hypercli-openclaw` agent container."""
790
859
  from . import agents
791
860
 
792
861
  agents.exec_cmd(agent_id=agent_id, command=command, timeout=timeout)
@@ -796,7 +865,7 @@ def exec_cmd(
796
865
  def shell_cmd(
797
866
  agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
798
867
  ):
799
- """Open an interactive shell on a HyperClaw agent container."""
868
+ """Open an interactive shell on a `hypercli-openclaw` agent container."""
800
869
  from . import agents
801
870
 
802
871
  agents.shell(agent_id=agent_id)
@@ -812,7 +881,8 @@ def config_cmd(
812
881
  help=f"Output format: {', '.join(FORMAT_CHOICES)}. Omit to show all.",
813
882
  ),
814
883
  key: str = typer.Option(None, "--key", "-k", help="API key (sk-...). Falls back to ~/.hypercli/agent-key.json"),
815
- base_url: str = typer.Option(None, "--base-url", help="HyperClaw API base URL. Falls back to HYPERCLAW_API_BASE, then --dev/prod defaults"),
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
+ placeholder_env: str = typer.Option(None, "--placeholder-env", help="Write ${ENV_VAR} placeholders into generated config instead of literal API keys"),
816
886
  apply: bool = typer.Option(False, "--apply", help="Write config to the appropriate file (openclaw/opencode only)"),
817
887
  dev: bool = typer.Option(False, "--dev", help="Use dev API"),
818
888
  ):
@@ -844,7 +914,7 @@ def config_cmd(
844
914
 
845
915
  for fmt in formats:
846
916
  if fmt == "openclaw":
847
- snippet = _config_openclaw(api_key, models, api_base)
917
+ snippet = _config_openclaw(api_key, models, api_base, placeholder_env=placeholder_env)
848
918
  _show_snippet("OpenClaw", "~/.openclaw/openclaw.json", snippet, apply, OPENCLAW_CONFIG_PATH)
849
919
  elif fmt == "opencode":
850
920
  snippet = _config_opencode(api_key, models, api_base)
@@ -12,14 +12,12 @@ import typer
12
12
  from rich.console import Console
13
13
  from rich.table import Table
14
14
 
15
- from hypercli.agents import Agent, Deployments, OpenClawAgent
15
+ from hypercli.agents import Agent, Deployments, OpenClawAgent, DEFAULT_OPENCLAW_IMAGE
16
16
 
17
- app = typer.Typer(help="Manage OpenClaw agent pods (reef containers)")
17
+ 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
 
@@ -29,6 +27,13 @@ STATE_DIR = Path.home() / ".hypercli"
29
27
  AGENTS_STATE = STATE_DIR / "agents.json"
30
28
 
31
29
 
30
+ def _default_openclaw_image(image: str | None, config: dict | None = None) -> str:
31
+ if image:
32
+ return image
33
+ configured = str((config or {}).get("image") or "").strip()
34
+ return configured or DEFAULT_OPENCLAW_IMAGE
35
+
36
+
32
37
  @app.callback()
33
38
  def agents_root(
34
39
  dev: bool = typer.Option(False, "--dev", help="Use the dev HyperClaw agents API"),
@@ -42,7 +47,10 @@ def agents_root(
42
47
 
43
48
  def _get_agent_api_key() -> str:
44
49
  """Resolve HyperClaw API key from env or saved key file."""
45
- key = os.environ.get("HYPERCLAW_API_KEY", "")
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()
46
54
  if key:
47
55
  return key
48
56
  if AGENT_KEY_PATH.exists():
@@ -52,7 +60,7 @@ def _get_agent_api_key() -> str:
52
60
  if key:
53
61
  return key
54
62
  console.print("[red]❌ No HyperClaw API key found.[/red]")
55
- console.print("Set HYPERCLAW_API_KEY or subscribe: [bold]hyper agent subscribe 1aiu[/bold]")
63
+ console.print("Set HYPER_AGENTS_API_KEY or HYPER_API_KEY, or subscribe: [bold]hyper agent subscribe 1aiu[/bold]")
56
64
  raise typer.Exit(1)
57
65
 
58
66
 
@@ -60,12 +68,13 @@ def _get_deployments_client(agents_ws_url: str | None = None) -> Deployments:
60
68
  """Create a Deployments client using the HyperClaw API key."""
61
69
  from hypercli.http import HTTPClient
62
70
  api_key = _get_agent_api_key()
63
- api_base = os.environ.get("HYPERCLI_API_URL") or (DEV_API_BASE if _GLOBAL_DEV else PROD_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
+ )
64
77
  resolved_agents_ws_url = agents_ws_url or _GLOBAL_AGENTS_WS_URL or os.environ.get("AGENTS_WS_URL")
65
- if _GLOBAL_DEV and not resolved_agents_ws_url:
66
- resolved_agents_ws_url = DEV_AGENTS_WS_URL
67
- if not _GLOBAL_DEV and not resolved_agents_ws_url and os.environ.get("HYPERCLI_API_URL", "").strip() == "":
68
- resolved_agents_ws_url = PROD_AGENTS_WS_URL
69
78
  http = HTTPClient(api_base, api_key)
70
79
  return Deployments(http, api_key=api_key, api_base=api_base, agents_ws_url=resolved_agents_ws_url)
71
80
 
@@ -125,10 +134,11 @@ def _resolve_agent(agent_id: str) -> str:
125
134
 
126
135
  def _get_pod_with_token(agent_id: str) -> Agent:
127
136
  """Get an agent, filling JWT from local state if needed."""
137
+ resolved_agent_id = _resolve_agent(agent_id)
128
138
  agents = _get_deployments_client()
129
- pod = agents.get(agent_id)
139
+ pod = agents.get(resolved_agent_id)
130
140
  state = _load_state()
131
- local = state.get(agent_id, {})
141
+ local = state.get(resolved_agent_id, {})
132
142
  if not pod.jwt_token and local.get("jwt_token"):
133
143
  pod.jwt_token = local["jwt_token"]
134
144
  if isinstance(pod, OpenClawAgent) and not pod.gateway_token and local.get("gateway_token"):
@@ -274,7 +284,7 @@ def create(
274
284
  port: list[str] = typer.Option(None, "--port", help="Expose port as PORT or PORT:noauth. Repeatable."),
275
285
  command: str = typer.Option(None, "--command", help="Container args as a shell-style string"),
276
286
  entrypoint: str = typer.Option(None, "--entrypoint", help="Container entrypoint as a shell-style string"),
277
- image: str = typer.Option(None, "--image", help="Override the default reef image"),
287
+ image: str = typer.Option(None, "--image", help="Override the default OpenClaw image"),
278
288
  registry_url: str = typer.Option(None, "--registry-url", help="Container registry URL for private image pulls"),
279
289
  registry_username: str = typer.Option(None, "--registry-username", help="Registry username"),
280
290
  registry_password: str = typer.Option(None, "--registry-password", help="Registry password"),
@@ -303,7 +313,7 @@ def create(
303
313
  ports=ports_list,
304
314
  command=command_argv,
305
315
  entrypoint=entrypoint_argv,
306
- image=image,
316
+ image=_default_openclaw_image(image),
307
317
  registry_url=registry_url,
308
318
  registry_auth=registry_auth,
309
319
  gateway_token=gateway_token,
@@ -322,7 +332,7 @@ def create(
322
332
  console.print(f" Size: {pod.cpu} CPU, {pod.memory} GB")
323
333
  console.print(f" State: {pod.state}")
324
334
  console.print(f" Desktop: {pod.vnc_url}")
325
- console.print(f" Shell: {pod.shell_url}")
335
+ console.print(f" Shell: {'via hyper agents shell' if not pod.shell_url else pod.shell_url}")
326
336
  display_ports = pod.ports or ports_list or []
327
337
  for p in display_ports:
328
338
  auth_text = "auth" if p.get("auth", True) else "noauth"
@@ -330,26 +340,14 @@ def create(
330
340
 
331
341
  if wait and not pod.dry_run:
332
342
  console.print("\n[dim]Waiting for pod to start...[/dim]")
333
- for i in range(60):
334
- time.sleep(5)
335
- try:
336
- pod = agents.get(pod.id)
337
- _save_pod_state(pod)
338
- if pod.is_running:
339
- console.print(f"[green]✅ Agent is running![/green]")
340
- break
341
- elif pod.state in ("failed", "stopped"):
342
- console.print(f"[red]❌ Agent failed: {pod.state}[/red]")
343
- if pod.last_error:
344
- console.print(f" Error: {pod.last_error}")
345
- raise typer.Exit(1)
346
- else:
347
- console.print(f" [{i*5}s] State: {pod.state}")
348
- except typer.Exit:
349
- raise
350
- except Exception as e:
351
- console.print(f" [{i*5}s] Checking... ({e})")
352
- else:
343
+ try:
344
+ pod = agents.wait_running(pod.id, timeout=300, poll_interval=5)
345
+ _save_pod_state(pod)
346
+ console.print(f"[green]✅ Agent is running![/green]")
347
+ except RuntimeError as e:
348
+ console.print(f"[red]❌ Agent failed: {e}[/red]")
349
+ raise typer.Exit(1)
350
+ except TimeoutError:
353
351
  console.print("[yellow]⚠ Timed out (5 min). Pod may still be starting.[/yellow]")
354
352
 
355
353
  if pod.dry_run:
@@ -360,6 +358,33 @@ def create(
360
358
  console.print(f"Desktop: {pod.vnc_url}")
361
359
 
362
360
 
361
+ @app.command("wait")
362
+ def wait_agent(
363
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
364
+ timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for RUNNING"),
365
+ poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls"),
366
+ ):
367
+ """Wait for an agent to reach RUNNING."""
368
+ agents = _get_deployments_client()
369
+ pod = _get_pod_with_token(agent_id)
370
+
371
+ try:
372
+ pod = agents.wait_running(pod.id, timeout=timeout, poll_interval=poll_interval)
373
+ except RuntimeError as e:
374
+ console.print(f"[red]❌ Agent failed: {e}[/red]")
375
+ raise typer.Exit(1)
376
+ except TimeoutError as e:
377
+ console.print(f"[yellow]⚠ {e}[/yellow]")
378
+ raise typer.Exit(1)
379
+
380
+ _save_pod_state(pod)
381
+ console.print(f"[green]✅ Agent is running:[/green] [bold]{pod.id[:12]}[/bold]")
382
+ console.print(f" Name: {pod.name or pod.pod_name}")
383
+ console.print(f" State: {pod.state}")
384
+ console.print(f" Desktop: {pod.vnc_url}")
385
+ console.print(f" Shell: {'via hyper agents shell' if not pod.shell_url else pod.shell_url}")
386
+
387
+
363
388
  @app.command("list")
364
389
  def list_agents(
365
390
  json_output: bool = typer.Option(False, "--json", help="JSON output"),
@@ -442,7 +467,7 @@ def status(
442
467
  console.print(f" Size: {pod.cpu} CPU, {pod.memory} GB")
443
468
  console.print(f" State: {pod.state}")
444
469
  console.print(f" Desktop: {pod.vnc_url}")
445
- console.print(f" Shell: {pod.shell_url}")
470
+ console.print(f" Shell: {'via hyper agents shell' if not pod.shell_url else pod.shell_url}")
446
471
  console.print(f" Created: {pod.created_at}")
447
472
  if pod.started_at:
448
473
  console.print(f" Started: {pod.started_at}")
@@ -470,7 +495,7 @@ def start(
470
495
  port: list[str] = typer.Option(None, "--port", help="Expose port as PORT or PORT:noauth. Repeatable."),
471
496
  command: str = typer.Option(None, "--command", help="Container args as a shell-style string"),
472
497
  entrypoint: str = typer.Option(None, "--entrypoint", help="Container entrypoint as a shell-style string"),
473
- image: str = typer.Option(None, "--image", help="Override the default reef image"),
498
+ image: str = typer.Option(None, "--image", help="Override the default OpenClaw image"),
474
499
  registry_url: str = typer.Option(None, "--registry-url", help="Container registry URL for private image pulls"),
475
500
  registry_username: str = typer.Option(None, "--registry-username", help="Registry username"),
476
501
  registry_password: str = typer.Option(None, "--registry-password", help="Registry password"),
@@ -489,6 +514,7 @@ def start(
489
514
  registry_auth = _build_registry_auth(registry_username, registry_password)
490
515
  launch_config = dict(local.get("launch_config") or {})
491
516
  effective_gateway_token = gateway_token or local.get("gateway_token")
517
+ effective_image = _default_openclaw_image(image, launch_config)
492
518
 
493
519
  try:
494
520
  pod = agents.start(
@@ -498,7 +524,7 @@ def start(
498
524
  ports=ports_list,
499
525
  command=command_argv,
500
526
  entrypoint=entrypoint_argv,
501
- image=image,
527
+ image=effective_image,
502
528
  registry_url=registry_url,
503
529
  registry_auth=registry_auth,
504
530
  gateway_token=effective_gateway_token,
@@ -978,18 +1004,21 @@ def gateway_cron(
978
1004
  def gateway_chat(
979
1005
  agent_id: str = typer.Argument(None, help="Agent ID or name"),
980
1006
  message: str = typer.Argument(..., help="Message to send"),
1007
+ session_key: str = typer.Option("main", "--session-key", help="Gateway chat session key"),
981
1008
  ):
982
1009
  """Send a chat message to an agent via the Gateway and stream the response."""
983
1010
  pod = _require_openclaw_agent(_get_pod_with_token(agent_id))
984
1011
 
985
1012
  async def _run():
986
- async for event in pod.chat_send(message):
1013
+ async for event in pod.chat_send(message, session_key=session_key):
987
1014
  if event.type == "content":
988
1015
  print(event.text, end="", flush=True)
989
1016
  elif event.type == "thinking":
990
1017
  console.print(f"[dim]{event.text}[/dim]", end="")
991
1018
  elif event.type == "tool_call":
992
1019
  console.print(f"\n[yellow]🔧 {event.data}[/yellow]")
1020
+ elif event.type == "tool_result":
1021
+ console.print(f"\n[cyan]📤 {event.data}[/cyan]")
993
1022
  elif event.type == "error":
994
1023
  console.print(f"\n[red]❌ {event.text}[/red]")
995
1024
  elif event.type == "done":
@@ -1,5 +1,6 @@
1
1
  """HyperCLI - Main entry point"""
2
2
  import sys
3
+ import json
3
4
  import typer
4
5
  from rich.console import Console
5
6
  from rich.prompt import Prompt
@@ -143,7 +144,8 @@ def cli():
143
144
  try:
144
145
  app()
145
146
  except APIError as e:
146
- detail = e.detail or str(e)
147
+ raw_detail = e.detail or str(e)
148
+ detail = raw_detail if isinstance(raw_detail, str) else json.dumps(raw_detail)
147
149
 
148
150
  # Check for GPU type errors and suggest corrections
149
151
  if "GPU type" in detail and "not found" in detail and "Available:" in detail:
@@ -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 HYPERCLAW_API_KEY > agent-key.json."""
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("HYPERCLAW_API_KEY", "").strip()
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]HYPERCLAW_API_KEY[/bold], or run [bold]hyper agent subscribe[/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
 
@@ -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
- {"gpu_count": c.gpu_count, "cpu_cores": c.cpu_cores, "memory_gb": c.memory_gb, "regions": c.regions}
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,
@@ -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 flag > env HYPERCLAW_API_KEY > agent-key.json."""
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("HYPERCLAW_API_KEY", "").strip()
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]HYPERCLAW_API_KEY[/bold], or run [bold]hyper agent subscribe[/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 > HYPERCLAW_API_BASE env > default."""
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("HYPERCLAW_API_BASE", "").strip()
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.13"
7
+ version = "2026.3.22"
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.13",
16
+ "hypercli-sdk>=2026.3.22",
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.13",
27
+ "hypercli-sdk[comfyui]>=2026.3.22",
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.13",
40
+ "hypercli-sdk[comfyui]>=2026.3.22",
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
 
@@ -0,0 +1,63 @@
1
+ from hypercli_cli.agent import _config_openclaw
2
+
3
+
4
+ def test_config_openclaw_limits_runtime_models_to_supported_set():
5
+ api_key = "sk-test"
6
+ api_base = "https://api.agents.hypercli.com"
7
+ models = [
8
+ {"id": "kimi-k2.5", "name": "Kimi K2.5", "reasoning": True},
9
+ {"id": "glm-5", "name": "GLM-5", "reasoning": True},
10
+ {
11
+ "id": "qwen3-embedding-4b",
12
+ "name": "Qwen3 Embedding 4B",
13
+ "reasoning": False,
14
+ "mode": "embedding",
15
+ },
16
+ {"id": "claude-sonnet-4", "name": "Claude Sonnet 4", "reasoning": False},
17
+ {"id": "minimax-m2.5", "name": "MiniMax M2.5", "reasoning": False},
18
+ ]
19
+
20
+ config = _config_openclaw(api_key, models, api_base)
21
+ providers = config["models"]["providers"]
22
+
23
+ assert set(providers) == {"hyperclaw", "kimi-coding"}
24
+ assert [m["id"] for m in providers["hyperclaw"]["models"]] == ["glm-5"]
25
+ assert [m["id"] for m in providers["kimi-coding"]["models"]] == ["kimi-k2.5"]
26
+
27
+ defaults = config["agents"]["defaults"]
28
+ assert defaults["model"]["primary"] == "kimi-coding/kimi-k2.5"
29
+ assert defaults["memorySearch"]["provider"] == "openai"
30
+ assert defaults["memorySearch"]["model"] == "qwen3-embedding-4b"
31
+ assert defaults["memorySearch"]["remote"]["baseUrl"] == "https://api.agents.hypercli.com/v1"
32
+
33
+
34
+ def test_config_openclaw_uses_first_embedding_model_for_memory_search():
35
+ config = _config_openclaw(
36
+ "sk-test",
37
+ [
38
+ {"id": "kimi-k2.5", "name": "Kimi K2.5", "reasoning": True},
39
+ {"id": "text-embedding-3-large", "name": "Text Embedding 3 Large", "mode": "embedding"},
40
+ ],
41
+ "https://api.agents.hypercli.com",
42
+ )
43
+
44
+ defaults = config["agents"]["defaults"]
45
+ assert defaults["memorySearch"]["model"] == "text-embedding-3-large"
46
+
47
+
48
+ def test_config_openclaw_supports_placeholder_api_key_env():
49
+ config = _config_openclaw(
50
+ "sk-real",
51
+ [
52
+ {"id": "kimi-k2.5", "name": "Kimi K2.5", "reasoning": True},
53
+ {"id": "glm-5", "name": "GLM-5", "reasoning": True},
54
+ {"id": "qwen3-embedding-4b", "name": "Qwen3 Embedding 4B", "mode": "embedding"},
55
+ ],
56
+ "https://api.agents.hypercli.com",
57
+ placeholder_env="HYPER_API_KEY",
58
+ )
59
+
60
+ providers = config["models"]["providers"]
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.13"