hivekit 0.7.0__tar.gz → 0.8.0__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 (54) hide show
  1. {hivekit-0.7.0 → hivekit-0.8.0}/PKG-INFO +1 -1
  2. {hivekit-0.7.0 → hivekit-0.8.0}/pyproject.toml +2 -2
  3. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/config.py +225 -5
  4. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/experiment.py +16 -0
  5. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/http_client.py +25 -2
  6. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/main.py +136 -9
  7. hivekit-0.8.0/src/cli/skills/hive-setup/SKILL.md +400 -0
  8. hivekit-0.8.0/src/cli/skills/hive-setup/references/configuration.md +200 -0
  9. hivekit-0.8.0/src/cli/skills/hive-setup/references/gpu-hardware.md +61 -0
  10. hivekit-0.8.0/src/cli/skills/hive-setup/references/multi-evaluator.md +119 -0
  11. hivekit-0.8.0/src/cli/skills_install.py +127 -0
  12. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/config_paths.py +8 -0
  13. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/url_utils.py +18 -1
  14. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/PKG-INFO +1 -1
  15. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/SOURCES.txt +5 -0
  16. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_config.py +323 -0
  17. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_experiment.py +46 -0
  18. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_http_client.py +67 -0
  19. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_main.py +192 -5
  20. {hivekit-0.7.0 → hivekit-0.8.0}/LICENSE +0 -0
  21. {hivekit-0.7.0 → hivekit-0.8.0}/README.md +0 -0
  22. {hivekit-0.7.0 → hivekit-0.8.0}/setup.cfg +0 -0
  23. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/__init__.py +0 -0
  24. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/__init__.py +0 -0
  25. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/auth_utils.py +0 -0
  26. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/credential_store.py +0 -0
  27. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/login_page.py +0 -0
  28. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/logo.svg +0 -0
  29. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/oidc_flow.py +0 -0
  30. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/session_manager.py +0 -0
  31. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/auth/token_revoker.py +0 -0
  32. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/completers.py +0 -0
  33. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/__init__.py +0 -0
  34. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/archive.py +0 -0
  35. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/config_sync.py +0 -0
  36. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/docker.py +0 -0
  37. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/logger.py +0 -0
  38. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/terminal.py +0 -0
  39. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/time.py +0 -0
  40. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/utils/upload.py +0 -0
  41. {hivekit-0.7.0 → hivekit-0.8.0}/src/cli/version.py +0 -0
  42. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/dependency_links.txt +0 -0
  43. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/entry_points.txt +0 -0
  44. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/requires.txt +0 -0
  45. {hivekit-0.7.0 → hivekit-0.8.0}/src/hivekit.egg-info/top_level.txt +0 -0
  46. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_completers.py +0 -0
  47. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_config_sync.py +0 -0
  48. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_login.py +0 -0
  49. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_logout.py +0 -0
  50. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_overrides.py +0 -0
  51. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_push_image.py +0 -0
  52. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_time_utils.py +0 -0
  53. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_upload.py +0 -0
  54. {hivekit-0.7.0 → hivekit-0.8.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivekit
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Toolkits for using hive agents, including CLI and SDK.
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hivekit"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Toolkits for using hive agents, including CLI and SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -63,7 +63,7 @@ quote-style = "double"
63
63
  include-package-data = true
64
64
 
65
65
  [tool.setuptools.package-data]
66
- "cli" = ["libs/**/*", "auth/*.svg"]
66
+ "cli" = ["libs/**/*", "auth/*.svg", "skills/**/*"]
67
67
 
68
68
  [tool.pytest.ini_options]
69
69
  pythonpath = ["src"]
@@ -19,6 +19,7 @@ from pydantic import (
19
19
  model_validator,
20
20
  )
21
21
 
22
+ from cli.utils.config_paths import get_coordinator_overlay_path
22
23
  from cli.utils.logger import set_log_level
23
24
  from cli.utils.time import unique_suffix
24
25
 
@@ -66,6 +67,29 @@ KNOWN_ACCELERATOR_TYPES = frozenset(
66
67
  # the CLI before they reach the backend.
67
68
  SUPPORTED_ARCHITECTURES = frozenset({"amd64", "arm64"})
68
69
 
70
+ # Allowed agent presets and known models for users to select from in the CLI
71
+ # when choosing custom agent ensemble configurations.
72
+ AGENT_PRESETS = frozenset({"low-effort", "medium-effort", "high-effort"})
73
+ KNOWN_MODELS = frozenset(
74
+ {
75
+ "anthropic.claude-haiku-4-5",
76
+ "anthropic.claude-opus-4-6",
77
+ "anthropic.claude-opus-4-7",
78
+ "anthropic.claude-opus-4-8",
79
+ "anthropic.claude-opus-5",
80
+ "anthropic.claude-sonnet-4-6",
81
+ "anthropic.claude-sonnet-5",
82
+ "moonshotai.kimi-k2.5",
83
+ "nvidia.nemotron-super-3-120b",
84
+ "openai.gpt-6-astra",
85
+ "openai.gpt-5.6-luna",
86
+ "openai.gpt-5.6-sol",
87
+ "openai.gpt-5.6-terra",
88
+ "qwen.qwen3-coder-480b-a35b",
89
+ "zai.glm-4.7-flash",
90
+ }
91
+ )
92
+
69
93
 
70
94
  class ResourceConfig(BaseModel):
71
95
  cpu: str = Field(
@@ -435,6 +459,71 @@ class ProviderConfig(BaseModel):
435
459
  )
436
460
 
437
461
 
462
+ class AgentModelConfig(BaseModel):
463
+ model: str = Field(
464
+ description="The model this agent runs, named from the registry the Hive "
465
+ "ships. Run `hive list models` to see the names.",
466
+ )
467
+ weight: Optional[float] = Field(
468
+ default=None,
469
+ allow_inf_nan=False,
470
+ description="How heavily this model is drawn on relative to the rest of "
471
+ "the ensemble. Unset (the default) counts as 1.",
472
+ )
473
+
474
+ @field_validator("model")
475
+ @classmethod
476
+ def validate_model(cls, v):
477
+ if v not in KNOWN_MODELS:
478
+ raise ValueError(
479
+ f"Unknown model '{v}'. Known models: {', '.join(sorted(KNOWN_MODELS))}. "
480
+ "Run `hive list models` to see them."
481
+ )
482
+ return v
483
+
484
+ @field_validator("weight")
485
+ @classmethod
486
+ def validate_weight(cls, v):
487
+ if v is not None and v <= 0:
488
+ raise ValueError(f"weight must be greater than 0, got {v}.")
489
+ return v
490
+
491
+
492
+ class AgentsConfig(BaseModel):
493
+ preset: Optional[str] = Field(
494
+ default=None,
495
+ description="A preset agent configuration to run, by name.",
496
+ )
497
+ models: Optional[list[AgentModelConfig]] = Field(
498
+ default=None,
499
+ description="A custom ensemble of agent models to use in the Hive experiment.",
500
+ )
501
+
502
+ @field_validator("preset")
503
+ @classmethod
504
+ def validate_preset(cls, v):
505
+ if v is not None and v not in AGENT_PRESETS:
506
+ raise ValueError(
507
+ f"Unknown preset '{v}'. Valid presets: {', '.join(sorted(AGENT_PRESETS))}."
508
+ )
509
+ return v
510
+
511
+ @model_validator(mode="after")
512
+ def validate_exactly_one_way_of_choosing(self):
513
+ """Fail closed unless exactly one of the two ways is used."""
514
+ if self.preset and self.models:
515
+ raise ValueError(
516
+ "agents names a preset tier or lists models, not both: "
517
+ "a preset already says which models to run."
518
+ )
519
+ if not self.preset and not self.models:
520
+ raise ValueError(
521
+ "agents must name a preset or list models. Omit the agents "
522
+ "section entirely to run the default tier."
523
+ )
524
+ return self
525
+
526
+
438
527
  class RuntimeConfig(BaseModel):
439
528
  num_sandboxes: int = Field(
440
529
  default=1,
@@ -541,7 +630,10 @@ class HiveConfig(BaseModel):
541
630
  coordinator_image_tag: Optional[str] = Field(
542
631
  default=None,
543
632
  description="The image tag to run the coordinator at, e.g. 'v0.6.0'. Optional — when "
544
- "omitted, the backend's default coordinator image is used. This field is ignored in production environments.",
633
+ "omitted, in a dev environment the tag recorded by `hivectl generate config` in "
634
+ "~/.hive/coordinator.yaml is used if present. If that file or tag is absent, or in a "
635
+ "production environment, the backend's default coordinator image is used. This field is "
636
+ "ignored in production environments.",
545
637
  )
546
638
  runtime: RuntimeConfig = Field(
547
639
  default_factory=RuntimeConfig, description="Runtime configuration for the experiment."
@@ -561,6 +653,12 @@ class HiveConfig(BaseModel):
561
653
  "omitted, the default provider is used with no capacity-type constraint.",
562
654
  )
563
655
  prompt: Optional[PromptConfig] = None
656
+ agents: Optional[AgentsConfig] = Field(
657
+ default=None,
658
+ description="Which agents generate new versions of the code: a preset "
659
+ "configuration, or a custom ensemble of models you pick. Optional — "
660
+ "when omitted, the Hive runs its own default tier.",
661
+ )
564
662
  log_level: str = Field(
565
663
  default="WARNING",
566
664
  json_schema_extra={"enumerated": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]},
@@ -579,6 +677,49 @@ class HiveConfig(BaseModel):
579
677
  def raw_yaml(self, value: Optional[str]) -> None:
580
678
  self._raw_yaml = value
581
679
 
680
+ @model_validator(mode="before")
681
+ @classmethod
682
+ def migrate_coordinator_config_name(cls, values):
683
+ # The effort-tier coordinator configs are the deprecated spelling of the
684
+ # matching agents.preset. Accept them, warn, and fold them into the
685
+ # canonical field. Setting agents too is a fatal error -- the legacy
686
+ # field would be redundant and we refuse to guess which one wins. Names
687
+ # outside that set have no agents equivalent, so they pass through under
688
+ # a plain warning.
689
+ if not isinstance(values, dict) or "coordinator_config_name" not in values:
690
+ return values
691
+ # Copy first: model_validate passes the caller's own dict through, and we
692
+ # must not mutate it -- not on success, and not on the conflict-raise path.
693
+ values = dict(values)
694
+ legacy = values.pop("coordinator_config_name")
695
+ match legacy:
696
+ case "high-effort-config" | "medium-effort-config" | "low-effort-config":
697
+ preset = legacy.removesuffix("-config")
698
+ if values.get("agents") is not None:
699
+ raise ValueError(
700
+ f"coordinator_config_name={legacy} and agents are both set. "
701
+ "coordinator_config_name is deprecated and now just selects "
702
+ f"agents.preset={preset}; set only agents."
703
+ )
704
+ # agents is absent (conflicts raised above), so this never
705
+ # discards an explicit value.
706
+ values["agents"] = {"preset": preset}
707
+ logger.warning(
708
+ "coordinator_config_name=%s is deprecated. Running agents.preset=%s "
709
+ "instead; set agents.preset (or agents.models) in your config to "
710
+ "silence this warning.",
711
+ legacy,
712
+ preset,
713
+ )
714
+ case _:
715
+ values["coordinator_config_name"] = legacy
716
+ logger.warning(
717
+ "coordinator_config_name=%s is deprecated. Specify the agent/models "
718
+ "used under the agents.preset or agents.models fields instead.",
719
+ legacy,
720
+ )
721
+ return values
722
+
582
723
 
583
724
  def _lookup_description(schema: dict, loc: tuple) -> str | None:
584
725
  """Walk the JSON schema to find the description for a field at the given location."""
@@ -654,7 +795,7 @@ def validate_repo_files_exist(base_dir: str, repo: RepoConfig, allow_missing: bo
654
795
  )
655
796
 
656
797
 
657
- def apply_overrides(cfg: dict, overrides: list[str]) -> dict:
798
+ def apply_overrides(cfg: dict, overrides: list[str]) -> set[tuple[str, ...]]:
658
799
  """Apply dot-notation overrides to a raw config dict before Pydantic validation.
659
800
 
660
801
  Supports two forms:
@@ -664,6 +805,9 @@ def apply_overrides(cfg: dict, overrides: list[str]) -> dict:
664
805
  Values are coerced via yaml.safe_load, so 4 -> int, true -> bool, etc.
665
806
  Type correctness and field existence within typed models are enforced by
666
807
  Pydantic at construction time.
808
+
809
+ ``cfg`` is edited in place; the key paths the overrides set are returned, so a
810
+ caller can tell a value the command line asked for from one the file carried.
667
811
  """
668
812
  overridden_keys: set[tuple[str, ...]] = set()
669
813
  for raw in overrides:
@@ -682,7 +826,7 @@ def apply_overrides(cfg: dict, overrides: list[str]) -> dict:
682
826
  d[parts[-1]] = value
683
827
  overridden_keys.add(tuple(parts))
684
828
  _reconcile_num_sandboxes_override(cfg, overridden_keys)
685
- return cfg
829
+ return overridden_keys
686
830
 
687
831
 
688
832
  def _reconcile_num_sandboxes_override(cfg: dict, overridden_keys: set[tuple[str, ...]]) -> None:
@@ -718,17 +862,93 @@ def _reconcile_num_sandboxes_override(cfg: dict, overridden_keys: set[tuple[str,
718
862
  del runtime[loser]
719
863
 
720
864
 
865
+ def _recorded_coordinator_image_tag(overlay_path: str) -> Optional[str]:
866
+ """Return the coordinator image tag recorded in the overlay file, if any.
867
+
868
+ A broken overlay must not stop an experiment the user asked for, so anything
869
+ unusable is warned about and ignored. An absent, empty or non-scalar tag
870
+ records nothing; a bool is not a scalar here, being an int subclass.
871
+ """
872
+ if not os.path.exists(overlay_path):
873
+ return None
874
+ try:
875
+ with open(overlay_path, "r") as file:
876
+ overlay = yaml.safe_load(file)
877
+ except (OSError, yaml.YAMLError) as e:
878
+ logger.warning("Ignoring %s, which could not be read: %s", overlay_path, e)
879
+ return None
880
+ tag = overlay.get("coordinator_image_tag") if isinstance(overlay, dict) else None
881
+ if tag is None or tag == "":
882
+ return None
883
+ if isinstance(tag, bool) or not isinstance(tag, (str, int, float)):
884
+ logger.warning(
885
+ "Ignoring coordinator_image_tag: %r in %s, which must be a string or number.",
886
+ tag,
887
+ overlay_path,
888
+ )
889
+ return None
890
+ return str(tag)
891
+
892
+
893
+ def _resolve_coordinator_image_tag(config_data: dict, overridden: bool) -> None:
894
+ """Fall back to the coordinator image tag `hivectl generate config` recorded.
895
+
896
+ A tag the experiment asked for wins, even an empty one asking for the backend
897
+ default. Whichever tag is used is logged with where it came from, as is any
898
+ recorded tag it discards, so which coordinator image runs is never a surprise.
899
+ """
900
+ overlay_path = get_coordinator_overlay_path()
901
+ recorded = _recorded_coordinator_image_tag(overlay_path)
902
+ if "coordinator_image_tag" not in config_data:
903
+ if recorded is None:
904
+ return
905
+ config_data["coordinator_image_tag"] = recorded
906
+ logger.warning(
907
+ "Using coordinator_image_tag=%s recorded in %s by `hivectl generate config`; "
908
+ "set coordinator_image_tag in the experiment config to override it.",
909
+ recorded,
910
+ overlay_path,
911
+ )
912
+ return
913
+ asked_for = config_data["coordinator_image_tag"]
914
+ source = "a command-line override" if overridden else "the experiment config"
915
+ # str(): an unquoted digit-only tag loads as an int. None is not compared, or
916
+ # an explicit null would match the tag "None" rather than discard it.
917
+ if recorded is not None and (asked_for is None or str(asked_for) != recorded):
918
+ logger.warning(
919
+ "Using coordinator_image_tag=%s from %s, discarding coordinator_image_tag=%s "
920
+ "recorded in %s.",
921
+ asked_for,
922
+ source,
923
+ recorded,
924
+ overlay_path,
925
+ )
926
+ elif asked_for is not None and asked_for != "":
927
+ logger.warning("Using coordinator_image_tag=%s from %s.", asked_for, source)
928
+ # An empty tag with nothing to discard asks for the backend's default image,
929
+ # exactly as setting no tag at all does, so there is nothing to report.
930
+
931
+
721
932
  def load_config(
722
933
  file_path: str,
723
934
  overrides: list[str] | None = None,
935
+ coordinator: bool = True,
724
936
  ) -> HiveConfig:
725
- """Load configuration from a YAML file."""
937
+ """Load configuration from a YAML file.
938
+
939
+ Pass ``coordinator=False`` when no coordinator will run, so the recorded
940
+ coordinator image tag is neither filled in nor reported as being used.
941
+ """
726
942
  with open(file_path, "r") as file:
727
943
  raw_text = file.read()
728
944
  config_data = yaml.safe_load(raw_text)
729
945
 
946
+ overridden_keys: set[tuple[str, ...]] = set()
730
947
  if overrides:
731
- apply_overrides(config_data, overrides)
948
+ overridden_keys = apply_overrides(config_data, overrides)
949
+
950
+ if coordinator and isinstance(config_data, dict):
951
+ _resolve_coordinator_image_tag(config_data, ("coordinator_image_tag",) in overridden_keys)
732
952
 
733
953
  try:
734
954
  config = HiveConfig(**config_data)
@@ -129,6 +129,22 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
129
129
  if config.prompt.enable_evolution:
130
130
  experiment["spec"]["prompt"]["enableEvolution"] = config.prompt.enable_evolution
131
131
 
132
+ # Add optional agent selection
133
+ if config.agents:
134
+ agents = {}
135
+ if config.agents.preset:
136
+ agents["preset"] = config.agents.preset
137
+ if config.agents.models:
138
+ agents["models"] = [
139
+ {
140
+ "model": entry.model,
141
+ **({"weight": entry.weight} if entry.weight is not None else {}),
142
+ }
143
+ for entry in config.agents.models
144
+ ]
145
+ if agents:
146
+ experiment["spec"]["agents"] = agents
147
+
132
148
  # Add coordinator config
133
149
  if config.coordinator_config_name:
134
150
  experiment["spec"]["coordinatorConfigName"] = config.coordinator_config_name
@@ -369,16 +369,39 @@ class HttpClient:
369
369
  response = self._send("get", "/experiments", "list experiments")
370
370
  return response.json()
371
371
 
372
+ def start_repo_download(self, name: str, content_uid: str) -> Dict[str, Any]:
373
+ """
374
+ Initiate assembly of the repository at a content snapshot.
375
+
376
+ Args:
377
+ name: Experiment name.
378
+ content_uid: Content snapshot identifier (a point on the evolution chart).
379
+
380
+ Returns:
381
+ Object with ``status`` ("ready" when already assembled, "started" when
382
+ assembly was kicked off). ``_send`` treats both 200 and 202 as success.
383
+ """
384
+ response = self._send(
385
+ "post",
386
+ f"/experiments/{name}/repo-download",
387
+ "start repo download",
388
+ params={"contentUid": content_uid},
389
+ )
390
+ return response.json()
391
+
372
392
  def get_repo_download_url(self, name: str, content_uid: str) -> Dict[str, Any]:
373
393
  """
374
- Get a presigned URL to download the repository assembled at a content snapshot.
394
+ Poll for a presigned URL to download the repository assembled at a content snapshot.
375
395
 
376
396
  Args:
377
397
  name: Experiment name.
378
398
  content_uid: Content snapshot identifier (a point on the evolution chart).
379
399
 
380
400
  Returns:
381
- Object with ``url`` (presigned S3 URL) and ``expiresIn`` (seconds).
401
+ Object with ``url`` (presigned S3 URL) and ``expiresIn`` (seconds) when
402
+ ready, or ``{"status": "pending"}`` while assembly is still in progress
403
+ (the server answers a not-yet-ready poll with 202, which ``_send`` does
404
+ not treat as an error).
382
405
  """
383
406
  response = self._send(
384
407
  "get",
@@ -29,13 +29,15 @@ from cli.auth.credential_store import create_credential_store
29
29
  from cli.auth.session_manager import create_session_manager
30
30
  from cli.auth.token_revoker import TokenRevocationError, TokenRevoker
31
31
  from cli.completers import config_file_completer, experiment_completer
32
- from cli.config import load_config, validate_repo_files_exist
32
+ from cli.config import KNOWN_MODELS, load_config, validate_repo_files_exist
33
33
  from cli.http_client import (
34
34
  HttpClient,
35
35
  ShellNotFoundError,
36
36
  StreamNotReadyError,
37
37
  create_http_client,
38
38
  )
39
+ from cli.skills_install import TARGETS as SKILL_TARGETS
40
+ from cli.skills_install import skills_install
39
41
  from cli.utils import docker
40
42
  from cli.utils.archive import resolve_file_list
41
43
  from cli.utils.config_paths import get_config_dir, get_config_path, load_organization_id
@@ -47,6 +49,7 @@ from cli.utils.url_utils import (
47
49
  derive_identity_base_url,
48
50
  get_api_endpoint,
49
51
  get_dashboard_url,
52
+ get_experiment_url,
50
53
  )
51
54
  from cli.version import print_version
52
55
 
@@ -327,7 +330,7 @@ def create_experiment(args, overrides) -> None:
327
330
  experiment_crd["metadata"]["annotations"]["hiverge.ai/config-tmp-id"] = config_tmp_id
328
331
 
329
332
  try:
330
- client.create_experiment(experiment_crd)
333
+ created = client.create_experiment(experiment_crd)
331
334
  logger.info("Created experiment. experiment_name=%s", config.experiment_name)
332
335
  console.print(f'experiment "[cyan]{config.experiment_name}[/cyan]" created')
333
336
  except Exception as e:
@@ -335,6 +338,30 @@ def create_experiment(args, overrides) -> None:
335
338
  console.print(f"[bold red]Error:[/bold red] {e}", highlight=False)
336
339
  sys.exit(1)
337
340
 
341
+ url = _experiment_url(created)
342
+ if url:
343
+ console.print(f" • [bold]Dashboard:[/bold] {url}", highlight=False, soft_wrap=True)
344
+
345
+
346
+ def _experiment_url(created: dict[str, object]) -> str | None:
347
+ """
348
+ Dashboard URL for a just-created experiment, or None if it cannot be built.
349
+
350
+ ``created`` is the CRD the server echoes back; the Alphatrion experiment ID
351
+ the dashboard routes on is its ``hiverge.ai/experiment-id`` label.
352
+ """
353
+ try:
354
+ labels = created["metadata"]["labels"] # type: ignore[index]
355
+ experiment_id = labels["hiverge.ai/experiment-id"]
356
+ if not experiment_id:
357
+ return None
358
+ return get_experiment_url(
359
+ experiment_id=experiment_id, organization_id=load_organization_id()
360
+ )
361
+ except Exception:
362
+ logger.debug("Could not build the dashboard URL for the new experiment", exc_info=True)
363
+ return None
364
+
338
365
 
339
366
  def shell_command(args, overrides) -> None:
340
367
  """Create an interactive shell and attach to it.
@@ -408,7 +435,8 @@ def shell_command(args, overrides) -> None:
408
435
  # A shell has no experiment; force a throwaway name to satisfy validation.
409
436
  overrides = [*overrides, "experiment_name=shell"]
410
437
  try:
411
- config = load_config(args.config, overrides=overrides)
438
+ # A shell runs no coordinator, so its image tag does not apply.
439
+ config = load_config(args.config, overrides=overrides, coordinator=False)
412
440
  except Exception as e:
413
441
  console.print(
414
442
  f"[bold red]Error:[/bold red] invalid config '{args.config}'.\n{e}", highlight=False
@@ -930,6 +958,14 @@ def list_coordinators(args) -> None:
930
958
  return
931
959
 
932
960
 
961
+ def list_models(args) -> None:
962
+ """List the models an agent ensemble can name in a config's `agents.models`."""
963
+ console = Console()
964
+
965
+ for model in sorted(KNOWN_MODELS):
966
+ console.print(model, highlight=False)
967
+
968
+
933
969
  def get_experiment(args) -> None:
934
970
  """Get details of a specific experiment."""
935
971
  console = Console()
@@ -1015,6 +1051,19 @@ def get_experiment(args) -> None:
1015
1051
  console.print(f" name: {provider.get('name')}", highlight=False)
1016
1052
  if provider.get("spot") is not None:
1017
1053
  console.print(f" spot: {provider.get('spot')}")
1054
+ # Agents subsection. Only shown when the experiment selected agents.
1055
+ agents = spec.get("agents", {})
1056
+ if agents:
1057
+ console.print(" [bold]agents[/bold]")
1058
+ if agents.get("preset"):
1059
+ console.print(f" preset: {agents.get('preset')}", highlight=False)
1060
+ for entry in agents.get("models", []):
1061
+ # One line per model, weight only when the experiment asked for
1062
+ # it, matching what was submitted.
1063
+ details = [f"model: {entry.get('model', 'N/A')}"]
1064
+ if entry.get("weight") is not None:
1065
+ details.append(f"weight: {entry['weight']}")
1066
+ console.print(f" - {', '.join(details)}", highlight=False)
1018
1067
  # Status section
1019
1068
  created = metadata.get("creationTimestamp", "N/A")
1020
1069
  age = humanize_time(created)
@@ -1065,6 +1114,32 @@ def get_config(args) -> None:
1065
1114
  console.print(hive_yaml.rstrip("\n"), highlight=False, markup=False)
1066
1115
 
1067
1116
 
1117
+ def _poll_repo_download_url(
1118
+ client: HttpClient, name: str, content_uid: str, console: Console
1119
+ ) -> str:
1120
+ """Poll until the assembled repository is ready, then return its presigned URL.
1121
+
1122
+ Exits the process on timeout. A permanent server failure propagates as an
1123
+ exception raised by ``get_repo_download_url`` (handled by the caller).
1124
+ """
1125
+ timeout = 3600
1126
+ backoff = 1.0
1127
+ backoff_cap = 5.0
1128
+ deadline = time.monotonic() + timeout
1129
+
1130
+ with console.status("Assembling repository..."):
1131
+ while time.monotonic() < deadline:
1132
+ result = client.get_repo_download_url(name, content_uid)
1133
+ url = result.get("url")
1134
+ if url:
1135
+ return url
1136
+ time.sleep(backoff)
1137
+ backoff = min(backoff * 2, backoff_cap)
1138
+
1139
+ console.print("[red]Error: timed out waiting for repository assembly[/red]")
1140
+ sys.exit(1)
1141
+
1142
+
1068
1143
  def download_repo(args) -> None:
1069
1144
  """Download the repository assembled at a content snapshot of an experiment."""
1070
1145
  console = Console()
@@ -1080,21 +1155,46 @@ def download_repo(args) -> None:
1080
1155
 
1081
1156
  try:
1082
1157
  client = _get_http_client(args)
1083
- result = client.get_repo_download_url(experiment_name, content_uid)
1084
- url = result.get("url")
1085
- if not url:
1086
- console.print("[red]Error: server did not return a download URL[/red]")
1087
- sys.exit(1)
1158
+
1159
+ # Kick off assembly. A permanent failure (bad args, unauthorized, not
1160
+ # found, internal) raises here and is caught below.
1161
+ client.start_repo_download(experiment_name, content_uid)
1162
+
1163
+ url = _poll_repo_download_url(client, experiment_name, content_uid, console)
1164
+
1165
+ from rich.progress import (
1166
+ BarColumn,
1167
+ DownloadColumn,
1168
+ Progress,
1169
+ TextColumn,
1170
+ TimeRemainingColumn,
1171
+ TransferSpeedColumn,
1172
+ )
1088
1173
 
1089
1174
  # The presigned URL is a plain S3 GET; do not send Hive auth headers to it.
1090
1175
  with requests.get(
1091
1176
  url, stream=True, timeout=300, verify=not getattr(args, "insecure", False)
1092
1177
  ) as resp:
1093
1178
  resp.raise_for_status()
1094
- with open(output, "wb") as f:
1179
+ # S3 returns Content-Length for the object, so the bar is determinate;
1180
+ # if it were ever absent, total=None degrades to a bytes counter.
1181
+ total = int(resp.headers.get("Content-Length") or 0) or None
1182
+ with (
1183
+ Progress(
1184
+ TextColumn("[progress.description]{task.description}"),
1185
+ BarColumn(),
1186
+ DownloadColumn(),
1187
+ TransferSpeedColumn(),
1188
+ TimeRemainingColumn(),
1189
+ console=console,
1190
+ ) as progress,
1191
+ open(output, "wb") as f,
1192
+ ):
1193
+ task = progress.add_task("Downloading", total=total)
1095
1194
  for chunk in resp.iter_content(chunk_size=1 << 16):
1096
1195
  if chunk: # skip empty keep-alive chunks
1097
1196
  f.write(chunk)
1197
+ progress.update(task, advance=len(chunk))
1098
1198
  except Exception as e:
1099
1199
  console.print(f"[red]Error: Failed to download repo: {e}[/red]")
1100
1200
  sys.exit(1)
@@ -1407,6 +1507,14 @@ def main():
1407
1507
  )
1408
1508
  parser_list_image.set_defaults(func=list_images)
1409
1509
 
1510
+ # list models
1511
+ parser_list_models = list_subparsers.add_parser(
1512
+ "models",
1513
+ aliases=["model"],
1514
+ help="List the models an agent ensemble can name",
1515
+ )
1516
+ parser_list_models.set_defaults(func=list_models)
1517
+
1410
1518
  # delete command
1411
1519
  parser_delete = subparsers.add_parser("delete", help="Delete resources")
1412
1520
  parser_delete.set_defaults(func=lambda _: parser_delete.print_help())
@@ -1497,6 +1605,25 @@ def main():
1497
1605
  )
1498
1606
  parser_logs.set_defaults(func=stream_logs)
1499
1607
 
1608
+ parser_skills = subparsers.add_parser("skills", help="Manage bundled coding-agent skills")
1609
+ parser_skills.set_defaults(func=lambda _: parser_skills.print_help())
1610
+ skills_subparsers = parser_skills.add_subparsers(dest="skills_target")
1611
+ parser_skills_install = skills_subparsers.add_parser(
1612
+ "install",
1613
+ help="Install bundled skills into a coding agent's skills directory",
1614
+ )
1615
+ parser_skills_install.add_argument(
1616
+ "who",
1617
+ nargs="*",
1618
+ choices=list(SKILL_TARGETS),
1619
+ metavar="AGENT",
1620
+ help="agents to install for: " + ", ".join(SKILL_TARGETS),
1621
+ )
1622
+ parser_skills_install.add_argument(
1623
+ "--all", action="store_true", help="install for every supported agent"
1624
+ )
1625
+ parser_skills_install.set_defaults(func=skills_install)
1626
+
1500
1627
  argcomplete.autocomplete(parser)
1501
1628
  args, overrides = parser.parse_known_args()
1502
1629
  if hasattr(args, "func"):