Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
3807522
Add deterministic contribution IDs and stack lookup IDs for resolved …
nicolehaugen Aug 21, 2026
9a441fe
feat: add `specify artifact` command exposing composition stacks as JSON
nicolehaugen Aug 21, 2026
c47371e
Potential fix for pull request finding 'Module is imported with 'impo…
nicolehaugen Aug 24, 2026
ea2636f
Potential fix for pull request finding 'Module is imported with 'impo…
nicolehaugen Aug 24, 2026
b1d6a75
Potential fix for pull request finding 'Unused import'
nicolehaugen Aug 24, 2026
424ca0f
Project preset artifacts by entry type
Copilot Aug 24, 2026
ac90181
Represent project override artifact layers
Copilot Aug 24, 2026
ed2f3db
Preserve artifact JSON init-dir errors
Copilot Aug 24, 2026
7eb1a52
Canonicalize core script artifacts
Copilot Aug 24, 2026
70965d7
Potential fix for pull request finding
nicolehaugen Aug 24, 2026
70a937e
Fix artifact inventory resolver filtering
Copilot Aug 24, 2026
df4afef
Add resolver tests for single-runtime core scripts
Copilot Aug 24, 2026
028fcd8
Cache artifact resolver lookups
Copilot Aug 24, 2026
8e7cf83
Handle artifact resolver failures
Copilot Aug 24, 2026
fcb72b1
Document artifact resolution error
Copilot Aug 24, 2026
69baa87
Include convention-based artifacts in inventory
Copilot Aug 24, 2026
e4347b0
Restore legacy flat core script lookup
Copilot Aug 24, 2026
2c36d57
Extend convention discovery to presets in artifact inventory
Copilot Aug 24, 2026
f6eacd2
Fix manifest path portability and export ArtifactResolutionError
Copilot Aug 24, 2026
f601efa
Bound artifact manifest search to project root
Copilot Aug 24, 2026
5410f72
Cover project-root artifact manifests
Copilot Aug 24, 2026
9b68bac
Handle directory artifact manifest lookups
Copilot Aug 24, 2026
f9ee35c
Fall back to top-level preset name in artifact stacks
Copilot Aug 24, 2026
95bbd99
Include project-local core artifacts in inventory
Copilot Aug 24, 2026
aacb49b
Address inline review feedback on artifact resolver helpers
Copilot Aug 24, 2026
fef72da
Reuse manifest/registry APIs in artifact contribution enumeration
Copilot Aug 24, 2026
442cd23
Pass layer explicitly to _iter_pack_contributions instead of inferrin…
Copilot Aug 24, 2026
ddadd5f
Fix core command namespacing and validate names for kind-scoped lookups
Copilot Aug 24, 2026
602f042
Skip manifest contributions without a usable identifier
Copilot Aug 24, 2026
63237cf
Hoist test-local imports to module scope in artifact/assets tests
Copilot Aug 24, 2026
67c107f
fix: resolve artifact inventory and validation review regressions
Copilot Aug 24, 2026
2514302
perf: avoid duplicate read in core command inventory
Copilot Aug 24, 2026
f93f526
fix: classify dotted override-only artifacts as commands
Copilot Aug 24, 2026
5b08932
fix: accept single-segment artifact commands
Copilot Aug 24, 2026
355ed82
fix: fail closed on corrupt artifact registries
Copilot Aug 24, 2026
d0f9f25
fix: trust inventory for artifact info lookups
Copilot Aug 24, 2026
b4de317
fix: validate registry before artifact info
Copilot Aug 24, 2026
4857201
fix: resolve artifact description by layer precedence, not enumeratio…
Copilot Aug 24, 2026
a20502d
fix: validate subdir before wheel bundle lookup in _locate_core_asset…
Copilot Aug 24, 2026
a149714
fix: detect duplicate hooks after command canonicalization
Copilot Aug 24, 2026
9fd43d7
fix: reuse normalized hook entries for duplicate detection
Copilot Aug 24, 2026
4682021
fix: align core command candidate ordering
Copilot Aug 24, 2026
c816a29
test: cover manifest-backed artifact parity
Copilot Aug 24, 2026
60050a8
fix: align artifact IDs with resolver identity
Copilot Aug 24, 2026
1c4842f
fix: skip invalid local artifact name components
Copilot Aug 24, 2026
a3293eb
fix: filter invalid local artifact IDs from inventory
Copilot Aug 24, 2026
0377861
fix: align artifact preset enumeration with resolver
Copilot Aug 24, 2026
1739a09
test: remove tautological artifact tests and strengthen id assertion
Copilot Aug 24, 2026
866b3a5
fix: preserve documented hook duplicate semantics
Copilot Aug 24, 2026
467ddca
fix: dedupe hook contributions last-wins
Copilot Aug 25, 2026
a2fc586
docs: clarify hook identifier deduplication
Copilot Aug 25, 2026
f9efe06
docs: remove hook discriminator references
Copilot Aug 25, 2026
9ae2682
style: space identifier declarations
Copilot Aug 25, 2026
7e0e7dc
fix: address unresolved review feedback on PR #4305
Copilot Aug 25, 2026
e1a63ce
fix: keep on-disk preset/extension identity separate from lookupId
Copilot Aug 25, 2026
fd2b268
fix: remove stale lookupId parsing fallback and tighten malformed loo…
Copilot Aug 25, 2026
1c4319c
fix: route resolver core fallback through shared asset resolver, desc…
Copilot Aug 25, 2026
f7b549f
refactor: drop redundant derive_named_id import-visibility assignment
Copilot Aug 25, 2026
083af4c
fix: align artifact inventory and lookup ID validation
nicolehaugen Aug 25, 2026
bd77448
fix: fail closed on malformed artifact registries
nicolehaugen Aug 25, 2026
46394a9
Merge remote-tracking branch 'origin/nicolehaugen-contribution-ids-an…
nicolehaugen Aug 25, 2026
436f394
fix: preserve convention artifact descriptions
nicolehaugen Aug 25, 2026
7416247
fix: align artifact override resolution
Copilot Aug 25, 2026
4b8b381
Refactor artifact inventory candidates
Copilot Aug 25, 2026
19beb04
Address artifact inventory review
Copilot Aug 25, 2026
dd9ef64
Address artifact inventory review
Copilot Aug 25, 2026
8c29bfe
Address artifact inventory review
Copilot Aug 25, 2026
8678014
source-agnostic artifact IDs; built-in tier recognized by exclusion, …
Copilot Aug 25, 2026
05eaf72
fix: reject malformed artifact layer provenance
Copilot Aug 25, 2026
b6f4481
Tighten artifact provenance handling
Copilot Aug 25, 2026
cea47c7
Refactor shared asset directory lookup
Copilot Aug 25, 2026
2f3ec02
Document shared asset families
Copilot Aug 25, 2026
38ce9f6
Avoid full artifact content scans
Copilot Aug 25, 2026
f881074
Clarify artifact resolution guard
Copilot Aug 25, 2026
9c73550
Restore resolver core provenance
Copilot Aug 25, 2026
ef7fca3
Reuse artifact inventory layers
Copilot Aug 25, 2026
2b8ce69
Simplify preset resolve assertion
Copilot Aug 25, 2026
73832c7
Restore source-agnostic artifact provenance
Copilot Aug 25, 2026
bf8aeb3
artifact catalog: `id` is the source-agnostic round-trip key; `info` …
Copilot Aug 25, 2026
d7babbb
fix: keep layer_kind_from_lookup_id and derive_hook_id in agreement o…
Copilot Aug 25, 2026
6efff92
artifact: reuse shared project resolver, rename handlers, dedupe vali…
Copilot Aug 25, 2026
bd483da
fix: align artifact info existence and resolver naming
Copilot Aug 25, 2026
e67b1cd
artifact: reuse PresetResolver.templates_dir in _project_core_asset_root
Copilot Aug 25, 2026
e7bdc62
fix: guard stale registry entries in artifact convention discovery
Copilot Aug 25, 2026
9977faa
fix: include stack in artifact list json
Copilot Aug 25, 2026
d9d1ef7
docs: document artifact list stack records
Copilot Aug 25, 2026
0ba4800
feat: add artifact layer source paths
Copilot Aug 26, 2026
ec76b9f
docs: clarify artifact sourcePath provenance
Copilot Aug 26, 2026
48605fd
refactor: clarify sourcePath derivation flow
Copilot Aug 26, 2026
f83e95b
refactor: document artifact source path fallback
Copilot Aug 26, 2026
b763712
refactor: expose registrar output path helper
Copilot Aug 26, 2026
c45c4bf
refactor: centralize registrar skill output check
Copilot Aug 26, 2026
63ec272
fix: only use materialized command output for the active stack row
Copilot Aug 26, 2026
3cc0570
Merge origin/main into nicolehaugen-contribution-ids-and-artifacts
nicolehaugen Sep 8, 2026
26f7fb0
docs(artifacts): cross-link contribution identifier grammar
nicolehaugen Sep 8, 2026
64f6a65
fix(artifacts): enforce identifier grammar and gate manifestPath on d…
nicolehaugen Sep 8, 2026
b2dbb7a
chore(tests): remove trailing blank lines
nicolehaugen Sep 8, 2026
842aa87
fix(presets): reuse parsed extension manifest identity
nicolehaugen Sep 8, 2026
778fc06
fix(identifiers): enforce layer source sentinel when parsing
nicolehaugen Sep 9, 2026
3f3206a
docs(identifiers): clarify built-in provenance contract
nicolehaugen Sep 9, 2026
c9b23e8
fix(artifacts): preserve preset registry fallback
nicolehaugen Sep 9, 2026
4eefb4b
test(artifacts): drop preset corruption fallback coverage
nicolehaugen Sep 9, 2026
c903bb5
chore(changelog): remove manual unreleased entry
nicolehaugen Sep 9, 2026
a5b779c
docs(artifacts): clarify layer resolution semantics
nicolehaugen Sep 9, 2026
429579c
docs(artifacts): explain resolver reuse
nicolehaugen Sep 9, 2026
165e9bd
fix(presets): preserve legacy layer resolution
nicolehaugen Sep 9, 2026
2db45ef
fix: preserve README convention resolution
nicolehaugen Sep 11, 2026
1b88a8f
fix: preserve existing script resolution
nicolehaugen Sep 11, 2026
bd50670
refactor: isolate artifact provenance
nicolehaugen Sep 11, 2026
7d23dfb
refactor: split artifact catalog modules
nicolehaugen Sep 11, 2026
68b8278
style: normalize artifact resolution EOF
nicolehaugen Sep 11, 2026
820d542
Merge origin/main into artifact branch
nicolehaugen Sep 11, 2026
d14b5b3
fix: expose both override artifact kinds
nicolehaugen Sep 11, 2026
ff695dd
docs: use Spec Kit product spelling
nicolehaugen Sep 11, 2026
599106f
fix: confine artifact script references
nicolehaugen Sep 11, 2026
6c77ca9
test: cover composing stack visibility
nicolehaugen Sep 11, 2026
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
167 changes: 167 additions & 0 deletions docs/reference/artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Artifacts

An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`.

The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve <name>` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches.

Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change.

## List Artifacts

```bash
specify artifact list --json
```

| Option | Description |
| -------- | -------------------------------------------------------- |
| `--json` | Required. Emit the inventory as a JSON array on stdout. |

Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`) and then by name.

```json
[
{
"id": "command:speckit.specify",
"name": "speckit.specify",
"kind": "command",
"description": "Create or update the feature specification.",
"stack": [
{
"id": "command:speckit.specify",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": null,
"lookupId": null,
"sourcePath": null
}
]
},
{
"id": "script:setup-plan",
"name": "setup-plan",
"kind": "script",
"description": "Setup implementation plan for a feature.",
"stack": [
{
"id": "script:setup-plan",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": null,
"lookupId": null,
"sourcePath": null
}
]
}
]
```

| Field | Description |
| ------------- | ------------------------------------------------------------------------- |
| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument |
| `name` | Logical artifact name (commands use the `speckit.<stem>` namespace) |
| `kind` | One of `command`, `template`, `script` |
| `description` | Description from the highest-precedence layer that declares one, else `""` |
| `stack` | Composition stack for this artifact, using the same row shape as `artifact info` |

Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family.

## Artifact Info

```bash
specify artifact info <name> --json
```

| Option | Description |
| ---------------- | ------------------------------------------------------------------- |
| `--json` | Required. Emit the composition stack as a JSON object on stdout. |
| `--kind <kind>` | Narrow the lookup to `command`, `template`, or `script` |

`<name>` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). When both the shorthand and `--kind` are supplied they must agree.

```json
{
"id": "command:speckit.specify",
"name": "speckit.specify",
"kind": "command",
"description": "Create or update the feature specification.",
"stack": [
{
"id": "command:speckit.specify",
"layer": "preset",
"sourceId": "compliance",
"presetId": "compliance",
"presetName": "Compliance Preset",
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": ".specify/presets/compliance/preset.yml",
"lookupId": "preset:compliance:command:speckit.specify",
"sourcePath": ".github/skills/speckit-specify/SKILL.md"
},
{
"id": "command:speckit.specify",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": false,
"hidden": true,
"manifestPath": null,
"lookupId": null,
"sourcePath": null
}
]
}
```

The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the corresponding row on `artifact list --json`.

### Stack semantics

`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer:

| Field | Description |
| -------------- | -------------------------------------------------------------------------------- |
| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack |
| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers |
| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance |
| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows |
| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` |
| `strategy` | `replace`, `wrap`, `prepend`, or `append` |
| `active` | `true` only for index `0` — the layer whose content is served |
| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition |
| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies |
| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers |
| `sourcePath` | Project-relative POSIX path to the concrete file backing the layer, or `null` for built-in/synthetic layers |

`active` and `hidden` are independent labels, not opposites. `active` identifies the highest-precedence layer selected by the existing Spec Kit layer-resolution order; it does not validate that the layer content can be read or composed. This preserves the diagnostic behavior of `specify preset resolve`, which reports the discovered layer chain even when content composition later produces a warning. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`.

Lookup IDs are derived by the artifact command from the resolved layer and its existing preset or extension manifest. Manifest-declared layers use the manifest's `id`; convention-only layers use the installed preset or extension directory id. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while built-in layers have no `lookupId`. These values are artifact-stack provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`.

## JSON Errors

On failure, nothing is written to stdout. A single-key JSON envelope is written to stderr and the process exits with code `1`:

```json
{ "error": "unknown artifact command:nope" }
```

| Message | Cause |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project |
| `unknown artifact <name>` | No artifact matches the requested name (and kind, when given) |
| `ambiguous artifact <name>: matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` |
| `artifact resolution failed` | The extension registry could not be read, or an error prevented the artifact layer stack from being collected |

Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope.
6 changes: 6 additions & 0 deletions docs/reference/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Presets customize how Spec Kit works — overriding command files, template file

[Presets reference →](presets.md)

## Artifacts

Artifacts are the commands, templates, and scripts a project exposes, whichever layer contributes them. The `specify artifact` command group is the read-only introspection surface over that inventory — a flat list of everything visible, plus the full composition stack behind any single entry, including which layer wins and which layers are hidden.

[Artifacts reference →](artifacts.md)

## Workflows

Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption.
Expand Down
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
href: reference/extensions.md
- name: Presets
href: reference/presets.md
- name: Artifacts
href: reference/artifacts.md
- name: Workflows
href: reference/workflows.md
- name: Bundles
Expand Down
7 changes: 7 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ def _require_specify_project() -> Path:
_register_preset_cmds(app)


# ===== Artifact Commands =====

# Read-only introspection over the composed inventory (commands/templates/scripts).
from .artifacts._commands import register as _register_artifact_cmds # noqa: E402
_register_artifact_cmds(app)


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

# Bundler subcommand group (specify bundle ...) — see commands/bundle/.
Expand Down
29 changes: 29 additions & 0 deletions src/specify_cli/artifacts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Public API for artifact inventory and resolution."""

from .catalog import ArtifactCatalog
from .models import (
AmbiguousArtifactError,
Artifact,
ArtifactError,
ArtifactKind,
ArtifactNotFoundError,
ArtifactResolutionError,
LayerName,
NotASpecKitProjectError,
StackLayer,
Strategy,
)

__all__ = [
"AmbiguousArtifactError",
"Artifact",
"ArtifactCatalog",
"ArtifactError",
"ArtifactKind",
"ArtifactNotFoundError",
"ArtifactResolutionError",
"LayerName",
"NotASpecKitProjectError",
"StackLayer",
"Strategy",
]
164 changes: 164 additions & 0 deletions src/specify_cli/artifacts/_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Typer sub-app for the `specify artifact` command group.

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``.
"""

from __future__ import annotations

import contextlib
import io
import json
import sys
from pathlib import Path
from typing import Optional

import typer

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

artifact_app = typer.Typer(
name="artifact",
help="Introspect commands, templates, and scripts Spec Kit exposes.",
no_args_is_help=True,
)


def _resolve_project_root() -> Path:
"""Return the project root without emitting Rich output on failure.

Delegates to :func:`specify_cli._require_specify_project` — the same
resolution chokepoint every other project-scoped subcommand (``preset``,
``extension``, ``workflow``, ...) uses, including its ``SPECIFY_INIT_DIR``
override handling. That helper prints Rich error output and raises
``typer.Exit`` on failure, which would corrupt the strict JSON envelope
``specify artifact list --json`` and ``specify artifact info --json``
emit on stdout/stderr. The Rich output is suppressed here and the
failure is re-raised as the module-local :class:`NotASpecKitProjectError`
for the shared error handler to serialize instead.
"""
from .. import _require_specify_project # lazy: avoids circular import

with contextlib.redirect_stderr(io.StringIO()):
try:
return _require_specify_project()
except typer.Exit:
raise NotASpecKitProjectError() from None


def _emit_error_and_exit(exc: ArtifactError) -> None:
"""Write ``{"error": "..."}`` to stderr and exit with code 1.

The stdout stream is left completely untouched — the contract is that
machine consumers can rely on an empty stdout when the exit code is
non-zero, so no partial JSON payload leaks even on a late-stage failure.
"""
payload = json.dumps({"error": exc.message}, ensure_ascii=False)
print(payload, file=sys.stderr)
raise typer.Exit(code=1)


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

A text-mode formatter is intentionally deferred so the initial release
can commit to exactly one output shape. Callers that omit ``--json``
get a usage error (exit 2) with no stdout output — this makes future
addition of a default text renderer a purely additive, non-breaking
change.
"""
if json_flag:
return
print(
"specify artifact requires --json for now; text output is not yet implemented.",
file=sys.stderr,
)
raise typer.Exit(code=2)


@artifact_app.command("list")
Comment thread
nicolehaugen marked this conversation as resolved.
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, and script Spec Kit exposes."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
catalog = ArtifactCatalog(root)
rows = catalog.list_artifacts_with_stack()
Comment thread
nicolehaugen marked this conversation as resolved.
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: Optional[str] = typer.Option(
None,
"--kind",
help="Narrow the lookup to one artifact family (command/template/script).",
),
) -> None:
"""Show one artifact and its full composition stack."""
_require_json_flag(json_flag)

resolved_kind: Optional[ArtifactKind] = None
if kind is not None:
if kind not in ("command", "template", "script"):
print(
f"invalid --kind {kind!r}: expected one of command, template, script",
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")


def register(app: typer.Typer) -> None:
"""Attach the artifact command group to the root Typer app."""
app.add_typer(artifact_app, name="artifact")
Loading