Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/hal0/cli/setup_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,30 @@ def setup(
"--answers",
help="Path to a hal0-setup.yaml answer file for a fully non-interactive run.",
),
emit_answers: str | None = typer.Option(
None,
"--emit-answers",
help="Write the resolved choices to a hal0-setup.yaml and exit.",
),
) -> None:
hw = HardwareProbe().probe()
if emit_answers is not None:
from hal0.install.answers import load_answers, write_answers

sel = (
load_answers(answers, hw)
if answers is not None
else build_auto_selections(
hw,
storage_dir=storage_dir,
with_extensions=not no_extensions,
with_slots=not no_slots,
existing_slots=_existing_slot_names(),
)
)
write_answers(sel, emit_answers)
typer.echo(f"Wrote resolved setup answers to {emit_answers}")
return
if answers is not None:
from hal0.install.answers import load_answers

Expand Down
89 changes: 88 additions & 1 deletion src/hal0/install/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import warnings
from pathlib import Path
from typing import Any

import yaml
Expand Down Expand Up @@ -319,4 +320,90 @@ def load_answers(path: str, hw: HardwareInfo) -> Selections:
)


__all__ = ["AnswersError", "load_answers"]
def dump_answers(sel: Selections) -> dict[str, Any]:
"""Serialize a resolved :class:`~hal0.install.orchestrate.Selections` back
into the ``hal0-setup.yaml`` schema (v1, spec §3) that :func:`load_answers`
reads — the ``--emit-answers`` half of the round trip (spec §2, §9.3).

Emits CONCRETE resolved values (no ``auto`` literals): a slot's
``model_id``/``device``/``profile`` are whatever ``sel`` already carries,
including ``None`` for a pick-free empty-scaffold slot (``load_answers``
treats an explicit ``model_id: null`` the same as omission — round-trips
to ``None``, not re-resolved to a suggestion).

Only slot capabilities the loader accepts (``chat``/``coder`` — spec §3/§5)
are written; any other capability in ``sel.slots`` (e.g. the ``embed``/
``rerank``/``stt``/``tts``/``vision`` scaffold slots ``build_auto_selections``
also produces) is not yet part of the answer-file slots schema, so it is
skipped with a warning rather than emitting a file ``load_answers`` would
reject.

SECURITY (spec §8): NEVER inlines a Hugging Face token. Only records which
env var to read at apply time (``huggingface.token_env``) — matching
``load_answers``' env-only handling of ``huggingface.*`` today. No secret
material is ever written to the emitted file.
"""
slots: list[dict[str, Any]] = []
for s in sel.slots:
if s.capability not in _VALID_SLOT_CAPABILITIES:
_warn(
f"dump_answers: slot '{s.slot_name}' has capability {s.capability!r}, "
f"which the answer-file slots schema does not yet support "
f"({sorted(_VALID_SLOT_CAPABILITIES)}); omitted from the emitted file."
)
continue
entry: dict[str, Any] = {
"capability": s.capability,
"name": s.slot_name,
"port": s.port,
"model_id": s.model_id,
}
if s.device is not None:
entry["device"] = s.device
if s.profile is not None:
entry["profile"] = s.profile
slots.append(entry)

comfyui_enabled = bool(sel.extensions.get("comfyui", False))
apps = {
ext_id: {"enabled": enabled}
for ext_id, enabled in sel.extensions.items()
if ext_id != "comfyui" # gen.mode drives the comfyui extension (spec §3 note)
}

return {
"version": 1,
"model_store": {"path": sel.storage_dir},
# Never inline a token (spec §8) — only the env var name to read.
"huggingface": {"token_env": "HF_TOKEN"},
"slots": slots,
"npu": {"opt_in": sel.npu_opt_in},
"gen": {
"mode": "scaffold_only" if comfyui_enabled else "off",
"capabilities": dict(sel.comfyui_defaults),
},
"apps": apps,
}


def write_answers(sel: Selections, path: str) -> None:
"""Write ``dump_answers(sel)`` to *path* as ``hal0-setup.yaml``.

Prefixes a header comment noting the file was resolved against the
hardware detected at write time (values are concrete, not ``auto``).
"""
doc = dump_answers(sel)
header = (
"# hal0-setup.yaml — generated by `hal0 setup --emit-answers`\n"
"# Values below are resolved against the hardware detected at write\n"
"# time (concrete, not `auto`). Replay with:\n"
"# hal0 setup --auto --answers <this file>\n"
)
body = yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)

out_path = Path(path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(header + body, encoding="utf-8")


__all__ = ["AnswersError", "dump_answers", "load_answers", "write_answers"]
242 changes: 242 additions & 0 deletions tests/install/test_emit_answers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
"""Tests for ``hal0 setup --emit-answers`` (issue #1117): the ``dump_answers``/
``write_answers`` serializer half of the answer-file round trip, and the CLI
wiring that resolves + writes + exits before any apply.

Spec: ``handoffs/hal0-setup-answers-spec-2026-07-05.md`` §2 (CLI surface),
§3 (schema), §8 (security — never inline a token).
"""

from __future__ import annotations

import textwrap

import pytest
import yaml
from typer.testing import CliRunner

from hal0.cli import setup_command
from hal0.cli.setup_command import build_auto_selections
from hal0.config.schema import GPUInfo, HardwareInfo, NPUInfo
from hal0.install.answers import dump_answers, load_answers, write_answers
from hal0.install.orchestrate import Selections, SlotSelection

runner = CliRunner()


def _hw(ram_gb=96, npu_present=True):
return HardwareInfo(
platform="strix-halo",
ram_mb=ram_gb * 1024,
ram_available_mb=ram_gb * 1024,
unified_memory_mb=ram_gb * 1024,
gpus=[GPUInfo(vendor="amd", vram_mb=512, compute_capable=True, vulkan_capable=True)],
npu=NPUInfo(present=npu_present),
)


def _manual_selections() -> Selections:
return Selections(
storage_dir="/var/lib/hal0/models",
slots=[
SlotSelection(
"chat", "chat", 8081, "some/chat-model-gguf", device="vulkan", profile="default"
),
SlotSelection("coder", "coder", 8082, None), # pick-free empty scaffold
],
extensions={"openwebui": True, "comfyui": True, "hermes": True, "pi": False},
npu_opt_in=True,
comfyui_defaults=(("txt2img", "qwen-image"), ("img2img", "qwen-image-edit")),
)


# ── dump_answers / write_answers round trip ─────────────────────────────────


def test_dump_answers_has_version_1():
doc = dump_answers(_manual_selections())
assert doc["version"] == 1


def test_dump_answers_never_inlines_a_token():
doc = dump_answers(_manual_selections())
assert doc["huggingface"] == {"token_env": "HF_TOKEN"}
text = yaml.safe_dump(doc)
# only the env-var-name key appears; no bare `token:`/`token_file:` with a value.
assert "token_env: HF_TOKEN" in text
assert "token_file" not in text
assert "\ntoken:" not in text and not text.startswith("token:")


def test_round_trip_manual_selections_is_equivalent(tmp_path):
sel = _manual_selections()
hw = _hw()

doc = dump_answers(sel)
path = tmp_path / "hal0-setup.yaml"
path.write_text(yaml.safe_dump(doc, sort_keys=False))

loaded = load_answers(str(path), hw)

assert loaded.storage_dir == sel.storage_dir
assert loaded.npu_opt_in == sel.npu_opt_in
assert loaded.extensions == sel.extensions
assert loaded.comfyui_defaults == sel.comfyui_defaults
assert {
(s.capability, s.slot_name, s.port, s.model_id, s.device, s.profile) for s in loaded.slots
} == {(s.capability, s.slot_name, s.port, s.model_id, s.device, s.profile) for s in sel.slots}


def test_round_trip_from_build_auto_selections(tmp_path):
hw = _hw()
sel = build_auto_selections(hw, storage_dir="/mnt/hal0-models")

# build_auto_selections also scaffolds embed/rerank/stt/tts/vision slots;
# the answer-file slots schema only supports chat/coder today (spec §3/§5)
# so dump_answers must warn-and-skip those rather than emit an invalid file.
with pytest.warns(UserWarning, match="does not yet support"):
doc = dump_answers(sel)
assert {s["capability"] for s in doc["slots"]} == {"chat", "coder"}

path = tmp_path / "hal0-setup.yaml"
write_answers(sel, str(path))
loaded = load_answers(str(path), hw)

assert loaded.storage_dir == sel.storage_dir
assert loaded.npu_opt_in == sel.npu_opt_in
assert loaded.extensions == sel.extensions
assert loaded.comfyui_defaults == sel.comfyui_defaults
assert {s.capability for s in loaded.slots} == {"chat", "coder"}
orig_by_cap = {s.capability: s for s in sel.slots if s.capability in ("chat", "coder")}
for s in loaded.slots:
assert s.model_id == orig_by_cap[s.capability].model_id
assert s.port == orig_by_cap[s.capability].port


def test_write_answers_creates_parent_dirs_and_header(tmp_path):
sel = _manual_selections()
path = tmp_path / "nested" / "dir" / "hal0-setup.yaml"
write_answers(sel, str(path))

text = path.read_text()
assert text.startswith("#")
assert "hardware detected" in text
doc = yaml.safe_load(text)
assert doc["version"] == 1
assert doc["model_store"]["path"] == sel.storage_dir


def test_apps_omits_comfyui_key():
doc = dump_answers(_manual_selections())
assert set(doc["apps"].keys()) == {"openwebui", "hermes", "pi"}
assert doc["apps"]["openwebui"] == {"enabled": True}
assert doc["apps"]["pi"] == {"enabled": False}


def test_gen_mode_derived_from_comfyui_extension():
sel = _manual_selections()
doc = dump_answers(sel)
assert doc["gen"]["mode"] == "scaffold_only"
assert doc["gen"]["capabilities"] == {"txt2img": "qwen-image", "img2img": "qwen-image-edit"}

off_sel = Selections(
storage_dir="/var/lib/hal0/models",
slots=[],
extensions={"openwebui": True, "comfyui": False, "hermes": False, "pi": False},
npu_opt_in=False,
comfyui_defaults=(),
)
off_doc = dump_answers(off_sel)
assert off_doc["gen"]["mode"] == "off"
assert off_doc["gen"]["capabilities"] == {}


def test_slot_entry_omits_device_and_profile_when_none():
sel = _manual_selections()
doc = dump_answers(sel)
coder_entry = next(s for s in doc["slots"] if s["capability"] == "coder")
assert "device" not in coder_entry
assert "profile" not in coder_entry
assert coder_entry["model_id"] is None

chat_entry = next(s for s in doc["slots"] if s["capability"] == "chat")
assert chat_entry["device"] == "vulkan"
assert chat_entry["profile"] == "default"


# ── CLI wiring: --emit-answers writes and exits before any apply ────────────


@pytest.fixture(autouse=True)
def _no_real_hardware_probe(monkeypatch):
"""Every CLI test below stubs HardwareProbe so it never touches the host."""
monkeypatch.setattr(setup_command, "HardwareProbe", lambda: _StubProbe())


class _StubProbe:
def probe(self):
return _hw()


@pytest.fixture
def _forbid_apply(monkeypatch):
"""Make run_install/apply_setup explode if called — --emit-answers must
return before either is invoked."""

def boom_run_install(*a, **k):
raise AssertionError("run_install must not be called for --emit-answers")

def boom_apply_setup(*a, **k):
raise AssertionError("apply_setup must not be called for --emit-answers")

monkeypatch.setattr("hal0.cli.setup_install.run_install", boom_run_install)
monkeypatch.setattr("hal0.install.orchestrate.apply_setup", boom_apply_setup)


def test_cli_emit_answers_auto_writes_file_and_returns(tmp_path, _forbid_apply):
out = tmp_path / "hal0-setup.yaml"
result = runner.invoke(
setup_command.app,
["--auto", "--emit-answers", str(out), "--storage-dir", "/mnt/hal0-models"],
)
assert result.exit_code == 0, result.output
assert out.exists()
doc = yaml.safe_load(out.read_text())
assert doc["version"] == 1
assert doc["model_store"]["path"] == "/mnt/hal0-models"


def test_cli_emit_answers_default_no_auto_flag_also_writes(tmp_path, _forbid_apply):
"""--emit-answers alone (no --auto, no --answers) resolves via
build_auto_selections defaults rather than dropping into the interactive
TUI — it must never block on a TTY."""
out = tmp_path / "hal0-setup.yaml"
result = runner.invoke(setup_command.app, ["--emit-answers", str(out)])
assert result.exit_code == 0, result.output
assert out.exists()


def test_cli_emit_answers_from_answers_file(tmp_path, _forbid_apply):
answers_path = tmp_path / "in.yaml"
answers_path.write_text(
textwrap.dedent(
"""
version: 1
model_store: { path: /var/lib/hal0/models }
slots:
- { capability: chat, name: chat, port: 8081, model_id: auto }
npu: { opt_in: false }
gen: { mode: "off" }
apps: { openwebui: { enabled: true }, hermes: { enabled: false }, pi: { enabled: false } }
"""
)
)
out = tmp_path / "hal0-setup.yaml"
result = runner.invoke(
setup_command.app,
["--answers", str(answers_path), "--emit-answers", str(out)],
)
assert result.exit_code == 0, result.output
assert out.exists()
doc = yaml.safe_load(out.read_text())
assert doc["npu"]["opt_in"] is False
assert {s["capability"] for s in doc["slots"]} == {"chat"}