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
132 changes: 10 additions & 122 deletions src/specify_cli/artifacts/_commands.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
"""Typer sub-app for the `specify artifact` command group.
"""Shared infrastructure and registration for ``specify artifact`` commands.

Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``.
This module is only responsible for CLI wiring — argument parsing, JSON
serialization, exit-code selection, and error-envelope emission on stderr.

Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and
``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a
``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``.

The user-facing contract for both subcommands — the ``list``/``info`` JSON
shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and
the JSON error envelope — is documented in ``docs/reference/artifacts.md``.
Command handlers live in ``command_*.py`` modules. Domain behavior remains in
Typer-free modules in this package, following ``design/cli.md``.
"""

from __future__ import annotations
Expand All @@ -23,12 +14,8 @@

import typer

from ..presets import PresetError
from . import (
ArtifactCatalog,
ArtifactError,
ArtifactKind,
ArtifactResolutionError,
NotASpecKitProjectError,
)

Expand Down Expand Up @@ -74,7 +61,7 @@ def _emit_error_and_exit(exc: ArtifactError) -> None:


def _require_json_flag(json_flag: bool) -> None:
"""Enforce the opt-in ``--json`` contract shared by both subcommands.
"""Enforce the opt-in ``--json`` contract shared by artifact commands.

A text-mode formatter is intentionally deferred so the initial release
can commit to exactly one output shape. Callers that omit ``--json``
Expand All @@ -91,111 +78,12 @@ def _require_json_flag(json_flag: bool) -> None:
raise typer.Exit(code=2)


@artifact_app.command("list")
def artifact_list(
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the inventory as a JSON array on stdout.",
),
) -> None:
"""List every command, template, script, and hook Spec Kit exposes."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
catalog = ArtifactCatalog(root)
rows = catalog.list_artifacts_with_stack()
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover — _emit_error_and_exit raises
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover — _emit_error_and_exit raises

sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False))
sys.stdout.write("\n")


@artifact_app.command("info")
def artifact_info(
name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."),
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the composition stack as a JSON object on stdout.",
),
kind: str | None = typer.Option(
None,
"--kind",
help="Narrow the lookup to one artifact family (command/template/script/hook).",
),
) -> None:
"""Show one artifact and its full composition stack."""
_require_json_flag(json_flag)

resolved_kind: ArtifactKind | None = None
if kind is not None:
if kind not in ("command", "template", "script", "hook"):
print(
f"invalid --kind {kind!r}: expected one of command, template, script, hook",
file=sys.stderr,
)
raise typer.Exit(code=2)
resolved_kind = kind # type: ignore[assignment]

try:
root = _resolve_project_root()
catalog = ArtifactCatalog(root)
payload = catalog.get_artifact_info(name, kind=resolved_kind)
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
sys.stdout.write("\n")


@artifact_app.command("lookup")
def artifact_lookup(
lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."),
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the validated manifest contribution used by Spec Kit as JSON.",
),
) -> None:
"""Resolve a stack lookupId to its effective preset or extension contribution."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
payload = ArtifactCatalog(root).get_contribution_info(lookup_id)
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

try:
rendered = json.dumps(
payload,
indent=2,
sort_keys=True,
ensure_ascii=False,
allow_nan=False,
)
rendered.encode("utf-8")
except (TypeError, ValueError, UnicodeEncodeError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

sys.stdout.write(rendered)
sys.stdout.write("\n")


def register(app: typer.Typer) -> None:
"""Attach the artifact command group to the root Typer app."""
# isort: off
from . import command_list # noqa: F401 — registers handler via decorator
from . import command_info # noqa: F401 — registers handler via decorator
from . import command_lookup # noqa: F401 — registers handler via decorator
# isort: on

app.add_typer(artifact_app, name="artifact")
4 changes: 2 additions & 2 deletions src/specify_cli/artifacts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description).
* :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack.

Everything else in this module is internal machinery. Callers outside
:mod:`specify_cli.artifacts._commands` should not import the private helpers.
Everything else in this module is internal machinery. Callers outside the
artifact command modules should not import the private helpers.
"""

from __future__ import annotations
Expand Down
64 changes: 64 additions & 0 deletions src/specify_cli/artifacts/command_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Implementation of ``specify artifact info``."""

from __future__ import annotations

import json
import sys

import typer

from ..presets import PresetError
from . import (
ArtifactCatalog,
ArtifactError,
ArtifactKind,
ArtifactResolutionError,
)
from ._commands import (
_emit_error_and_exit,
_require_json_flag,
_resolve_project_root,
artifact_app,
)


@artifact_app.command("info")
def artifact_info(
name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."),
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the composition stack as a JSON object on stdout.",
),
kind: str | None = typer.Option(
None,
"--kind",
help="Narrow the lookup to one artifact family (command/template/script/hook).",
),
) -> None:
"""Show one artifact and its full composition stack."""
_require_json_flag(json_flag)

resolved_kind: ArtifactKind | None = None
if kind is not None:
if kind not in ("command", "template", "script", "hook"):
print(
f"invalid --kind {kind!r}: expected one of command, template, script, hook",
file=sys.stderr,
)
raise typer.Exit(code=2)
resolved_kind = kind # type: ignore[assignment]

try:
root = _resolve_project_root()
catalog = ArtifactCatalog(root)
payload = catalog.get_artifact_info(name, kind=resolved_kind)
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
sys.stdout.write("\n")
42 changes: 42 additions & 0 deletions src/specify_cli/artifacts/command_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Implementation of ``specify artifact list``."""

from __future__ import annotations

import json
import sys

import typer

from ..presets import PresetError
from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError
from ._commands import (
_emit_error_and_exit,
_require_json_flag,
_resolve_project_root,
artifact_app,
)


@artifact_app.command("list")
def artifact_list(
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the inventory as a JSON array on stdout.",
),
) -> None:
"""List every command, template, script, and hook Spec Kit exposes."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
catalog = ArtifactCatalog(root)
rows = catalog.list_artifacts_with_stack()
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover — _emit_error_and_exit raises
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover — _emit_error_and_exit raises

sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False))
sys.stdout.write("\n")
55 changes: 55 additions & 0 deletions src/specify_cli/artifacts/command_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Implementation of ``specify artifact lookup``."""

from __future__ import annotations

import json
import sys

import typer

from ..presets import PresetError
from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError
from ._commands import (
_emit_error_and_exit,
_require_json_flag,
_resolve_project_root,
artifact_app,
)


@artifact_app.command("lookup")
def artifact_lookup(
lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."),
json_flag: bool = typer.Option(
False,
"--json",
help="Emit the validated manifest contribution used by Spec Kit as JSON.",
),
) -> None:
"""Resolve a stack lookupId to its effective preset or extension contribution."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
payload = ArtifactCatalog(root).get_contribution_info(lookup_id)
except ArtifactError as exc:
_emit_error_and_exit(exc)
return # pragma: no cover
except (OSError, PresetError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

try:
rendered = json.dumps(
payload,
indent=2,
sort_keys=True,
ensure_ascii=False,
allow_nan=False,
)
rendered.encode("utf-8")
except (TypeError, ValueError, UnicodeEncodeError):
_emit_error_and_exit(ArtifactResolutionError())
return # pragma: no cover

sys.stdout.write(rendered)
sys.stdout.write("\n")
25 changes: 25 additions & 0 deletions tests/specify_cli/artifacts/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from pathlib import Path

import pytest


@pytest.fixture
def spec_kit_project(tmp_path: Path) -> Path:
"""Create a minimal but valid Spec Kit project layout."""
root = tmp_path / "proj"
root.mkdir()
(root / ".specify").mkdir()
(root / ".specify" / "presets").mkdir()
(root / ".specify" / "extensions").mkdir()
(root / ".specify" / "templates").mkdir()
return root


@pytest.fixture
def non_project(tmp_path: Path) -> Path:
"""Create a directory that intentionally lacks ``.specify/``."""
root = tmp_path / "not-proj"
root.mkdir()
return root
Loading
Loading