Skip to content
Merged
45 changes: 41 additions & 4 deletions docs/reference/artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ An **artifact** is any command, template, script, or hook Spec Kit exposes in a

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.
All 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

Expand Down Expand Up @@ -135,7 +135,7 @@ For command, template, and script artifacts, `stack` is ordered by resolution pr
| -------------- | -------------------------------------------------------------------------------- |
| `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 |
| `sourceId` | Installed preset or extension ID used by the resolver, 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`, `append`, or `additive` |
Expand All @@ -147,7 +147,44 @@ For command, template, and script artifacts, `stack` is ordered by resolution pr

`active` and `hidden` are independent labels, not opposites. For command, template, and script artifacts, `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`.
Here, **provenance** means the origin of one artifact layer: the installed
preset or extension that supplied it, the manifest that declared it, and the
concrete file that backs it. Lookup IDs use the installed preset or extension
ID, matching the identity and ordering used by the existing resolver. The
installed ID is the registry/directory name and may differ from the logical
`id` declared inside the manifest. Using the installed ID keeps separate
installed directories distinct even when their manifests declare the same
logical ID.

Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while
built-in layers have no `lookupId`. These values identify artifact-stack
provenance; they are not the artifact round-trip key — use `id` with
`artifact info` for that. `manifestPath` identifies the declaring manifest,
and `sourcePath` identifies the concrete file backing the layer when one
exists. Core, project-override, and other synthetic rows may report
`sourcePath: null`.

## Contribution Lookup

```bash
specify artifact lookup <lookupId> --json
```

Resolves a manifest-backed stack `lookupId` to the validated preset or extension
declaration that Spec Kit uses. The returned declaration reflects normal
manifest processing, including canonical extension command names, injected
defaults such as empty alias lists, and lowercase preset strategies. It is not
a verbatim representation of the authored YAML.

This keeps cross-reference behavior inside the artifact command: preset and
extension commands, manifests, and resolver return shapes are unchanged.

The response includes the stable lookup ID, provider coordinates, manifest and
source paths, and the effective declaration under `contribution`.
Convention-only contributions and project overrides have no originating
manifest declaration, so lookup returns
`{"error": "unknown contribution <lookupId>"}` with exit code `1`. Built-in
rows never have a `lookupId`.

### Hook artifacts

Expand Down Expand Up @@ -184,7 +221,7 @@ For hooks, `active` reports registration state from `.specify/extensions.yml`, n

Declared-but-unregistered hooks remain visible when their extension is included by the normal resolver. Registry-disabled extensions are excluded entirely, consistently with their other contributions. Invalid individual extension manifests are also omitted by the existing resolver and remain diagnosable through extension inspection and validation commands.

Hook lookup IDs use the artifact-private `{layer}:{sourceId}:hook:{encodedEventName}:{encodedTargetCommand}` grammar. Hook provenance is restricted to `preset` and `extension` layers; hooks never receive a built-in/core layer. The current manifest API exposes extension hook declarations, so current rows use the `extension` layer. The `preset` layer remains reserved by the hook identifier grammar for preset-provided hooks without requiring artifact IDs to be added to preset or extension manifest APIs.
Hook lookup IDs use the artifact-private `{layer}:{sourceId}:hook:{encodedEventName}:{encodedTargetCommand}` grammar, where `sourceId` is the installed provider ID. Hook provenance is restricted to `preset` and `extension` layers; hooks never receive a built-in/core layer. The current manifest API exposes extension hook declarations, so current rows use the `extension` layer. The `preset` layer remains reserved by the hook identifier grammar for preset-provided hooks without requiring artifact IDs to be added to preset or extension manifest APIs.

## JSON Errors

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/artifacts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
ArtifactKind,
ArtifactNotFoundError,
ArtifactResolutionError,
ContributionNotFoundError,
HookArtifact,
HookLayerName,
HookStackEntry,
Expand All @@ -25,6 +26,7 @@
"ArtifactKind",
"ArtifactNotFoundError",
"ArtifactResolutionError",
"ContributionNotFoundError",
"HookArtifact",
"HookLayerName",
"HookStackEntry",
Expand Down
38 changes: 38 additions & 0 deletions src/specify_cli/artifacts/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,44 @@ def artifact_info(
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."""
app.add_typer(artifact_app, name="artifact")
25 changes: 25 additions & 0 deletions src/specify_cli/artifacts/_identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ def parse_hook_artifact_name(name: str) -> tuple[str, str]:
)


def parse_lookup_id(value: str) -> tuple[str, str, str, str]:
"""Parse a contribution lookup ID into layer, source, kind, and name."""
if not isinstance(value, str):
raise IdentifierComponentError("Invalid lookupId")
parts = value.split(":")
Comment thread
nicolehaugen marked this conversation as resolved.
if len(parts) == 4:
layer, source_id, kind, name = parts
derive_lookup_id(layer, source_id, kind, name)
return layer, source_id, kind, name
if len(parts) == 5 and parts[2] == "hook":
layer, source_id, kind, encoded_event, encoded_command = parts
Comment thread
nicolehaugen marked this conversation as resolved.
if layer not in _HOOK_LAYERS or source_id == "_":
raise IdentifierComponentError("Invalid hook lookupId")
event_name, command = parse_hook_artifact_name(
f"{encoded_event}:{encoded_command}"
)
if (
derive_hook_lookup_id(layer, source_id, event_name, command)
!= value
):
raise IdentifierComponentError("Invalid hook lookupId")
return layer, source_id, kind, f"{encoded_event}:{encoded_command}"
Comment thread
nicolehaugen marked this conversation as resolved.
raise IdentifierComponentError("Invalid lookupId")


def _encode_hook_component(value: Any, field_label: str) -> str:
"""Encode one hook ID component without narrowing manifest syntax."""
if not isinstance(value, str):
Expand Down
157 changes: 155 additions & 2 deletions src/specify_cli/artifacts/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
derive_hook_public_id,
derive_public_id,
parse_hook_artifact_name,
parse_lookup_id,
validate_component,
)
from .models import (
Expand All @@ -33,6 +34,7 @@
ArtifactKind,
ArtifactNotFoundError,
ArtifactResolutionError,
ContributionNotFoundError,
HookArtifact,
HookStackEntry,
NotASpecKitProjectError,
Expand Down Expand Up @@ -405,6 +407,155 @@ def get_artifact_info(
"stack": [layer.to_json_dict() for layer in stack],
}

def get_contribution_info(self, lookup_id: str) -> dict[str, Any]:
"""Resolve a stack ``lookupId`` to its validated manifest entry."""
_validate_project(self.project_root)
_validate_extension_registry(self.project_root)
try:
layer, source_id, kind, name = parse_lookup_id(lookup_id)
except IdentifierComponentError as exc:
raise ContributionNotFoundError(lookup_id) from exc
if layer == "project":
raise ContributionNotFoundError(lookup_id)

from ..presets import PresetError, PresetResolver

resolver = PresetResolver(self.project_root)
Comment thread
nicolehaugen marked this conversation as resolved.
try:
if layer == "preset":
resolved = self._find_preset_contribution(
resolver, source_id, kind, name
)
else:
resolved = self._find_extension_contribution(
resolver, source_id, kind, name
)
except (OSError, PresetError) as exc:
raise ArtifactResolutionError() from exc
if resolved is None:
raise ContributionNotFoundError(lookup_id)

contribution, manifest_path, source_path = resolved
return {
"id": lookup_id,
"layer": layer,
"sourceId": source_id,
"kind": kind,
"name": name,
"manifestPath": manifest_path,
"sourcePath": source_path,
"contribution": contribution,
}

def _find_preset_contribution(
self,
resolver: Any,
source_id: str,
kind: str,
name: str,
) -> tuple[dict[str, Any], str, str | None] | None:
for pack_id, _metadata in resolver._get_all_presets_by_priority():
if pack_id != source_id:
continue
pack_dir = resolver.presets_dir / pack_id
manifest = resolver._get_manifest(pack_dir)
if manifest is None:
return None
for entry in manifest.templates:
if (
isinstance(entry, dict)
and entry.get("type") == kind
and entry.get("name") == name
):
return self._contribution_result(
entry, pack_dir, manifest.path
)
return None

def _find_extension_contribution(
self,
resolver: Any,
source_id: str,
kind: str,
name: str,
) -> tuple[dict[str, Any], str, str | None] | None:
from ..extensions import (
ExtensionManifest,
ValidationError,
coerce_hook_entries,
)

for _priority, extension_id, _metadata in resolver._get_all_extensions_by_priority():
if extension_id != source_id:
continue
extension_dir = resolver.extensions_dir / extension_id
manifest_path = extension_dir / "extension.yml"
try:
manifest = ExtensionManifest(manifest_path)
except (ValidationError, OSError, TypeError, AttributeError):
return None
if kind == "hook":
event_name, command = parse_hook_artifact_name(name)
matching: dict[str, Any] | None = None
hook_config = (manifest.hooks or {}).get(event_name)
for entry in coerce_hook_entries(hook_config):
if isinstance(entry, dict) and entry.get("command") == command:
matching = entry
if matching is None:
return None
relative_manifest = _repo_relative_existing_file(
self.project_root, manifest.path
)
if relative_manifest is None:
raise ArtifactResolutionError()
return (
{**matching, "eventName": event_name},
relative_manifest,
None,
)

entries = {
"command": manifest.commands,
"template": manifest.templates,
"script": manifest.scripts,
}[kind]
for entry in entries:
if isinstance(entry, dict) and entry.get("name") == name:
return self._contribution_result(
entry, extension_dir, manifest.path
)
return None

def _contribution_result(
self,
entry: dict[str, Any],
pack_dir: Path,
manifest_path: Path,
) -> tuple[dict[str, Any], str, str | None]:
relative_manifest = _repo_relative_existing_file(
self.project_root, manifest_path
)
if relative_manifest is None:
raise ArtifactResolutionError()
relative_file = entry.get("file")
source_path = None
if isinstance(relative_file, str):
try:
resolved_pack = pack_dir.resolve()
candidate = pack_dir / relative_file
candidate.resolve().relative_to(resolved_pack)
except (OSError, ValueError):
pass
else:
source_path = _repo_relative_existing_file(
self.project_root, candidate
)
return (
dict(entry),
relative_manifest,
source_path,
)

def _get_hook_info(
self,
bare_name: str,
Expand Down Expand Up @@ -480,7 +631,7 @@ def _collect_hook_inventory(
)
if manifest_path is None:
raise ArtifactResolutionError()
source_id = manifest.id
source_id = extension_id

for event_name, hook_config in (manifest.hooks or {}).items():
entries_by_command: dict[
Expand Down Expand Up @@ -511,6 +662,7 @@ def _collect_hook_inventory(
"id": public_id,
"layer": "extension",
"sourceId": source_id,
"runtimeExtensionId": manifest.id,
"presetId": None,
"presetName": None,
"manifestPath": manifest_path,
Expand Down Expand Up @@ -553,7 +705,8 @@ def _collect_hook_inventory(
presetName=None,
strategy="additive",
active=any(
binding.get("extension") == declaration["sourceId"]
binding.get("extension")
== declaration["runtimeExtensionId"]
and binding.get("command") == command
for binding in enabled_bindings
),
Expand Down
7 changes: 7 additions & 0 deletions src/specify_cli/artifacts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ def __init__(self, name: str) -> None:
super().__init__(self.message)


class ContributionNotFoundError(ArtifactError):
def __init__(self, lookup_id: str) -> None:
self.message = f"unknown contribution {lookup_id}"
super().__init__(self.message)


class AmbiguousArtifactError(ArtifactError):
def __init__(self, name: str, kinds: Iterable[str]) -> None:
kinds_list = sorted(kinds)
Expand All @@ -165,6 +171,7 @@ def __init__(self) -> None:
"ArtifactKind",
"ArtifactNotFoundError",
"ArtifactResolutionError",
"ContributionNotFoundError",
"HookArtifact",
"HookLayerName",
"HookStackEntry",
Expand Down
Loading