Skip to content
Closed
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
32 changes: 29 additions & 3 deletions design/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ extensions/
The nested package's `__init__.py` owns its Typer application and registration.
Shared helpers for that nested surface can live in `_helpers.py`.

Creating a nested CLI package does not transfer same-named domain behavior into
that package. If an existing domain module collides with a new nested command
namespace, keep the implementation in the parent domain package (or a focused
domain module there). Preserve an established import path through thin
compatibility exports from the nested package when required.

Do not add a nested `_commands.py` merely for symmetry. Create one only when
the nested group develops substantial shared command infrastructure that no
longer fits cleanly in `__init__.py` and `_helpers.py`.
Expand Down Expand Up @@ -208,10 +214,30 @@ The primary `test_command_<name>.py` suite verifies the public command surface.
Phase-specific suites verify detailed invariants without obscuring the primary
command behavior.

Not every test belongs in the mirrored command tree:
Domain source remains in the parent package's `__init__.py` or a focused
domain module without the `command_` prefix. Its mirrored tests use the domain
subject name, for example:

```text
src/specify_cli/integrations/__init__.py # catalog domain API
tests/specify_cli/integrations/test_catalog.py

src/specify_cli/integrations/command_search.py
tests/specify_cli/integrations/test_command_search.py
```

Do not put `test_<domain>.py` under a nested command directory merely because
the domain has the same name as that CLI namespace. The nested directory is
reserved for `test_command_<name>.py` suites that exercise its actual
subcommands.

Not every test is a command test, even when it belongs in the mirrored package
tree:

- Domain model, registry, manager, and catalog behavior remains in domain test
suites such as `tests/test_extensions.py`.
- Domain model, registry, manager, and catalog behavior belongs at the parent
package level, not under a nested command namespace and not in
`test_command_*.py`. Existing consolidated domain suites such as
`tests/test_extensions.py` may remain in place until separately reorganized.
- Cross-domain CLI contracts remain with the broader integration tests.
- Shared fixtures belong in the narrowest `conftest.py` that serves all of
their consumers.
Expand Down
4 changes: 2 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,8 @@ def _require_specify_project() -> Path:

# ===== Bundle Commands =====

# Bundler subcommand group (specify bundle ...) — see commands/bundle/.
from .commands.bundle import register as _register_bundle_cmds # noqa: E402
# Bundle subcommand group (specify bundle ...) — see bundles/_commands.py.
from .bundles._commands import register as _register_bundle_cmds # noqa: E402
_register_bundle_cmds(app)


Expand Down
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")
5 changes: 5 additions & 0 deletions src/specify_cli/bundler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Compatibility import for the renamed :mod:`specify_cli.bundles` package."""

from .bundles import BundlerError

__all__ = ["BundlerError"]
Loading
Loading