diff --git a/src/modeldock/cli/commands/install_category.py b/src/modeldock/cli/commands/install_category.py index 5b6bf18..fa045a5 100644 --- a/src/modeldock/cli/commands/install_category.py +++ b/src/modeldock/cli/commands/install_category.py @@ -6,10 +6,15 @@ from modeldock.cli.console import print_error from modeldock.cli.factory import manager_for +from modeldock.domain.model import Category + +_CATEGORY_HELP: str = "Category name. Available: " + "; ".join( + f"{item.value} ({item.description})" for item in Category +) def install_category_cmd( - category: str = typer.Argument(..., help="Category name (e.g. coding)"), + category: str = typer.Argument(..., help=_CATEGORY_HELP), backend: str = typer.Option(None, "--backend", help="Runtime backend"), debug: bool = typer.Option(False, "--debug", help="Show traceback"), ) -> None: diff --git a/src/modeldock/domain/model.py b/src/modeldock/domain/model.py index cec63b5..c526c27 100644 --- a/src/modeldock/domain/model.py +++ b/src/modeldock/domain/model.py @@ -38,12 +38,25 @@ def from_value(cls, value: str) -> Capability: class Category(str, Enum): """High-level model categories used for discovery and bulk install.""" - CHAT = "chat" - CODING = "coding" - EMBEDDING = "embedding" - VISION = "vision" - REASONING = "reasoning" - INSTRUCT = "instruct" + _description: str + + def __new__(cls, value: str, description: str) -> Category: + obj = str.__new__(cls, value) + obj._value_ = value + obj._description = description + return obj + + CHAT = ("chat", "General-purpose conversational models") + CODING = ("coding", "Models optimized for code generation and completion") + EMBEDDING = ("embedding", "Models that convert text into vector representations") + VISION = ("vision", "Models that understand images and text") + REASONING = ("reasoning", "Models optimized for multi-step reasoning") + INSTRUCT = ("instruct", "Models tuned to follow instructions") + + @property + def description(self) -> str: + """Return a human-readable description for CLI help and documentation.""" + return self._description @classmethod def from_value(cls, value: str) -> Category: diff --git a/tests/unit/test_cli_wiring.py b/tests/unit/test_cli_wiring.py index 18d14e5..b577a75 100644 --- a/tests/unit/test_cli_wiring.py +++ b/tests/unit/test_cli_wiring.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any, Optional @@ -12,7 +13,7 @@ import modeldock.cli.factory as factory from modeldock.cli.app import app from modeldock.common.errors import ConfigError -from modeldock.domain.model import ModelRef, RuntimeBackend +from modeldock.domain.model import Category, ModelRef, RuntimeBackend runner = CliRunner() @@ -85,6 +86,17 @@ def test_unknown_backend_exits_nonzero(recording_manager: type[_RecordingManager assert "Unknown backend" in result.output +def test_install_category_help_includes_descriptions() -> None: + result = runner.invoke(app, ["install-category", "--help"]) + stripped = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", result.output) + output = " ".join(stripped.replace("│", " ").split()) + + assert result.exit_code == 0 + for category in list(Category): + assert category.value in output + assert category.description in output + + def test_global_backend_reaches_subcommands(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("MODELDOCK_DEFAULT_BACKEND", raising=False) result = runner.invoke(app, ["--backend", "lmstudio", "config", "show"]) diff --git a/tests/unit/test_domain.py b/tests/unit/test_domain.py index 18be1e8..3b2505c 100644 --- a/tests/unit/test_domain.py +++ b/tests/unit/test_domain.py @@ -32,6 +32,20 @@ def test_category_from_value() -> None: Category.from_value("nonsense") +def test_category_descriptions() -> None: + # Pin exact wording so accidental rewording is caught in CI. + expected = { + Category.CHAT: "General-purpose conversational models", + Category.CODING: "Models optimized for code generation and completion", + Category.EMBEDDING: "Models that convert text into vector representations", + Category.VISION: "Models that understand images and text", + Category.REASONING: "Models optimized for multi-step reasoning", + Category.INSTRUCT: "Models tuned to follow instructions", + } + + assert {category: category.description for category in Category} == expected + + def test_backend_from_value() -> None: assert RuntimeBackend.from_value("OLLAMA") == RuntimeBackend.OLLAMA assert RuntimeBackend.from_value("vllm") == RuntimeBackend.VLLM