Skip to content

Commit 95ffe74

Browse files
mnriemCopilot
andcommitted
refactor: organize artifact CLI commands
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 175adbc commit 95ffe74

13 files changed

Lines changed: 826 additions & 707 deletions
Lines changed: 10 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
"""Typer sub-app for the `specify artifact` command group.
1+
"""Shared infrastructure and registration for ``specify artifact`` commands.
22
3-
Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``.
4-
This module is only responsible for CLI wiring — argument parsing, JSON
5-
serialization, exit-code selection, and error-envelope emission on stderr.
6-
7-
Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and
8-
``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a
9-
``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``.
10-
11-
The user-facing contract for both subcommands — the ``list``/``info`` JSON
12-
shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and
13-
the JSON error envelope — is documented in ``docs/reference/artifacts.md``.
3+
Command handlers live in ``command_*.py`` modules. Domain behavior remains in
4+
Typer-free modules in this package, following ``design/cli.md``.
145
"""
156

167
from __future__ import annotations
@@ -23,12 +14,8 @@
2314

2415
import typer
2516

26-
from ..presets import PresetError
2717
from . import (
28-
ArtifactCatalog,
2918
ArtifactError,
30-
ArtifactKind,
31-
ArtifactResolutionError,
3219
NotASpecKitProjectError,
3320
)
3421

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

7562

7663
def _require_json_flag(json_flag: bool) -> None:
77-
"""Enforce the opt-in ``--json`` contract shared by both subcommands.
64+
"""Enforce the opt-in ``--json`` contract shared by artifact commands.
7865
7966
A text-mode formatter is intentionally deferred so the initial release
8067
can commit to exactly one output shape. Callers that omit ``--json``
@@ -91,111 +78,12 @@ def _require_json_flag(json_flag: bool) -> None:
9178
raise typer.Exit(code=2)
9279

9380

94-
@artifact_app.command("list")
95-
def artifact_list(
96-
json_flag: bool = typer.Option(
97-
False,
98-
"--json",
99-
help="Emit the inventory as a JSON array on stdout.",
100-
),
101-
) -> None:
102-
"""List every command, template, script, and hook Spec Kit exposes."""
103-
_require_json_flag(json_flag)
104-
try:
105-
root = _resolve_project_root()
106-
catalog = ArtifactCatalog(root)
107-
rows = catalog.list_artifacts_with_stack()
108-
except ArtifactError as exc:
109-
_emit_error_and_exit(exc)
110-
return # pragma: no cover — _emit_error_and_exit raises
111-
except (OSError, PresetError):
112-
_emit_error_and_exit(ArtifactResolutionError())
113-
return # pragma: no cover — _emit_error_and_exit raises
114-
115-
sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False))
116-
sys.stdout.write("\n")
117-
118-
119-
@artifact_app.command("info")
120-
def artifact_info(
121-
name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."),
122-
json_flag: bool = typer.Option(
123-
False,
124-
"--json",
125-
help="Emit the composition stack as a JSON object on stdout.",
126-
),
127-
kind: str | None = typer.Option(
128-
None,
129-
"--kind",
130-
help="Narrow the lookup to one artifact family (command/template/script/hook).",
131-
),
132-
) -> None:
133-
"""Show one artifact and its full composition stack."""
134-
_require_json_flag(json_flag)
135-
136-
resolved_kind: ArtifactKind | None = None
137-
if kind is not None:
138-
if kind not in ("command", "template", "script", "hook"):
139-
print(
140-
f"invalid --kind {kind!r}: expected one of command, template, script, hook",
141-
file=sys.stderr,
142-
)
143-
raise typer.Exit(code=2)
144-
resolved_kind = kind # type: ignore[assignment]
145-
146-
try:
147-
root = _resolve_project_root()
148-
catalog = ArtifactCatalog(root)
149-
payload = catalog.get_artifact_info(name, kind=resolved_kind)
150-
except ArtifactError as exc:
151-
_emit_error_and_exit(exc)
152-
return # pragma: no cover
153-
except (OSError, PresetError):
154-
_emit_error_and_exit(ArtifactResolutionError())
155-
return # pragma: no cover
156-
157-
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
158-
sys.stdout.write("\n")
159-
160-
161-
@artifact_app.command("lookup")
162-
def artifact_lookup(
163-
lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."),
164-
json_flag: bool = typer.Option(
165-
False,
166-
"--json",
167-
help="Emit the validated manifest contribution used by Spec Kit as JSON.",
168-
),
169-
) -> None:
170-
"""Resolve a stack lookupId to its effective preset or extension contribution."""
171-
_require_json_flag(json_flag)
172-
try:
173-
root = _resolve_project_root()
174-
payload = ArtifactCatalog(root).get_contribution_info(lookup_id)
175-
except ArtifactError as exc:
176-
_emit_error_and_exit(exc)
177-
return # pragma: no cover
178-
except (OSError, PresetError):
179-
_emit_error_and_exit(ArtifactResolutionError())
180-
return # pragma: no cover
181-
182-
try:
183-
rendered = json.dumps(
184-
payload,
185-
indent=2,
186-
sort_keys=True,
187-
ensure_ascii=False,
188-
allow_nan=False,
189-
)
190-
rendered.encode("utf-8")
191-
except (TypeError, ValueError, UnicodeEncodeError):
192-
_emit_error_and_exit(ArtifactResolutionError())
193-
return # pragma: no cover
194-
195-
sys.stdout.write(rendered)
196-
sys.stdout.write("\n")
197-
198-
19981
def register(app: typer.Typer) -> None:
20082
"""Attach the artifact command group to the root Typer app."""
83+
# isort: off
84+
from . import command_list # noqa: F401 — registers handler via decorator
85+
from . import command_info # noqa: F401 — registers handler via decorator
86+
from . import command_lookup # noqa: F401 — registers handler via decorator
87+
# isort: on
88+
20189
app.add_typer(artifact_app, name="artifact")

src/specify_cli/artifacts/catalog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description).
66
* :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack.
77
8-
Everything else in this module is internal machinery. Callers outside
9-
:mod:`specify_cli.artifacts._commands` should not import the private helpers.
8+
Everything else in this module is internal machinery. Callers outside the
9+
artifact command modules should not import the private helpers.
1010
"""
1111

1212
from __future__ import annotations
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Implementation of ``specify artifact info``."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
8+
import typer
9+
10+
from ..presets import PresetError
11+
from . import (
12+
ArtifactCatalog,
13+
ArtifactError,
14+
ArtifactKind,
15+
ArtifactResolutionError,
16+
)
17+
from ._commands import (
18+
_emit_error_and_exit,
19+
_require_json_flag,
20+
_resolve_project_root,
21+
artifact_app,
22+
)
23+
24+
25+
@artifact_app.command("info")
26+
def artifact_info(
27+
name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."),
28+
json_flag: bool = typer.Option(
29+
False,
30+
"--json",
31+
help="Emit the composition stack as a JSON object on stdout.",
32+
),
33+
kind: str | None = typer.Option(
34+
None,
35+
"--kind",
36+
help="Narrow the lookup to one artifact family (command/template/script/hook).",
37+
),
38+
) -> None:
39+
"""Show one artifact and its full composition stack."""
40+
_require_json_flag(json_flag)
41+
42+
resolved_kind: ArtifactKind | None = None
43+
if kind is not None:
44+
if kind not in ("command", "template", "script", "hook"):
45+
print(
46+
f"invalid --kind {kind!r}: expected one of command, template, script, hook",
47+
file=sys.stderr,
48+
)
49+
raise typer.Exit(code=2)
50+
resolved_kind = kind # type: ignore[assignment]
51+
52+
try:
53+
root = _resolve_project_root()
54+
catalog = ArtifactCatalog(root)
55+
payload = catalog.get_artifact_info(name, kind=resolved_kind)
56+
except ArtifactError as exc:
57+
_emit_error_and_exit(exc)
58+
return # pragma: no cover
59+
except (OSError, PresetError):
60+
_emit_error_and_exit(ArtifactResolutionError())
61+
return # pragma: no cover
62+
63+
sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False))
64+
sys.stdout.write("\n")
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Implementation of ``specify artifact list``."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
8+
import typer
9+
10+
from ..presets import PresetError
11+
from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError
12+
from ._commands import (
13+
_emit_error_and_exit,
14+
_require_json_flag,
15+
_resolve_project_root,
16+
artifact_app,
17+
)
18+
19+
20+
@artifact_app.command("list")
21+
def artifact_list(
22+
json_flag: bool = typer.Option(
23+
False,
24+
"--json",
25+
help="Emit the inventory as a JSON array on stdout.",
26+
),
27+
) -> None:
28+
"""List every command, template, script, and hook Spec Kit exposes."""
29+
_require_json_flag(json_flag)
30+
try:
31+
root = _resolve_project_root()
32+
catalog = ArtifactCatalog(root)
33+
rows = catalog.list_artifacts_with_stack()
34+
except ArtifactError as exc:
35+
_emit_error_and_exit(exc)
36+
return # pragma: no cover — _emit_error_and_exit raises
37+
except (OSError, PresetError):
38+
_emit_error_and_exit(ArtifactResolutionError())
39+
return # pragma: no cover — _emit_error_and_exit raises
40+
41+
sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False))
42+
sys.stdout.write("\n")
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Implementation of ``specify artifact lookup``."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
8+
import typer
9+
10+
from ..presets import PresetError
11+
from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError
12+
from ._commands import (
13+
_emit_error_and_exit,
14+
_require_json_flag,
15+
_resolve_project_root,
16+
artifact_app,
17+
)
18+
19+
20+
@artifact_app.command("lookup")
21+
def artifact_lookup(
22+
lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."),
23+
json_flag: bool = typer.Option(
24+
False,
25+
"--json",
26+
help="Emit the validated manifest contribution used by Spec Kit as JSON.",
27+
),
28+
) -> None:
29+
"""Resolve a stack lookupId to its effective preset or extension contribution."""
30+
_require_json_flag(json_flag)
31+
try:
32+
root = _resolve_project_root()
33+
payload = ArtifactCatalog(root).get_contribution_info(lookup_id)
34+
except ArtifactError as exc:
35+
_emit_error_and_exit(exc)
36+
return # pragma: no cover
37+
except (OSError, PresetError):
38+
_emit_error_and_exit(ArtifactResolutionError())
39+
return # pragma: no cover
40+
41+
try:
42+
rendered = json.dumps(
43+
payload,
44+
indent=2,
45+
sort_keys=True,
46+
ensure_ascii=False,
47+
allow_nan=False,
48+
)
49+
rendered.encode("utf-8")
50+
except (TypeError, ValueError, UnicodeEncodeError):
51+
_emit_error_and_exit(ArtifactResolutionError())
52+
return # pragma: no cover
53+
54+
sys.stdout.write(rendered)
55+
sys.stdout.write("\n")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def spec_kit_project(tmp_path: Path) -> Path:
10+
"""Create a minimal but valid Spec Kit project layout."""
11+
root = tmp_path / "proj"
12+
root.mkdir()
13+
(root / ".specify").mkdir()
14+
(root / ".specify" / "presets").mkdir()
15+
(root / ".specify" / "extensions").mkdir()
16+
(root / ".specify" / "templates").mkdir()
17+
return root
18+
19+
20+
@pytest.fixture
21+
def non_project(tmp_path: Path) -> Path:
22+
"""Create a directory that intentionally lacks ``.specify/``."""
23+
root = tmp_path / "not-proj"
24+
root.mkdir()
25+
return root

0 commit comments

Comments
 (0)