Skip to content
53 changes: 45 additions & 8 deletions docs/reference/artifacts.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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/`.
An **artifact** is any command, template, script, or hook 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.

Expand All @@ -16,7 +16,7 @@ specify artifact list --json
| -------- | -------------------------------------------------------- |
| `--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.
Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack`. Named artifacts are sorted by kind (`command`, then `template`, then `script`) and name. Hook rows follow them, sorted by event and then by the first declaration's priority.

```json
[
Expand Down Expand Up @@ -69,7 +69,7 @@ Prints the full inventory of every visible artifact — one row per `(kind, name
| ------------- | ------------------------------------------------------------------------- |
| `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` |
| `kind` | One of `command`, `template`, `script`, `hook` |
| `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` |

Expand All @@ -84,7 +84,7 @@ 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` |
| `--kind <kind>` | Narrow the lookup to `command`, `template`, `script`, or `hook` |

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

Expand Down Expand Up @@ -129,7 +129,7 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the

### Stack semantics

`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer:
For command, template, and script artifacts, `stack` is ordered by resolution precedence: index `0` is the winning layer. Hook stacks are additive rather than winner-based: multiple entries may be active, and their declaration ordering is described in [Hook artifacts](#hook-artifacts). Each row describes one contributing layer:

| Field | Description |
| -------------- | -------------------------------------------------------------------------------- |
Expand All @@ -138,17 +138,54 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the
| `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 |
| `strategy` | `replace`, `wrap`, `prepend`, `append`, or `additive` |
| `active` | Whether the layer is active; for named artifacts this is `true` only at index `0` |
| `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`.
`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`.

### Hook artifacts

Hook rows project hook declarations from extensions included by the standard preset and extension resolver. The round-trip shorthand is:

```text
hook:{encodedEventName}:{encodedTargetCommand}
```

For example, both of these select the same hook artifact:

```bash
specify artifact info hook:before_specify:speckit.compliance.pre-check --json
specify artifact info before_specify:speckit.compliance.pre-check --kind hook --json
```

Hook event and target-command components are UTF-8 percent-encoded using URL quoting: ASCII letters, digits, `-`, `.`, `_`, and `~` remain literal, while all other bytes are encoded. This encoding is limited to the hook components in the artifact `id`, `name`, and `lookupId`; hook manifests, runtime bindings, `eventName`, and `targetCommand` are unchanged. For example, an event named `custom:after` targeting `/skill:speckit-test-ext-hello` has the artifact ID:

```text
hook:custom%3Aafter:%2Fskill%3Aspeckit-test-ext-hello
```

Hook rows add three top-level fields:

| Field | Description |
| --------------- | ------------------------------------------------------------------------------ |
| `eventName` | Hook event that triggers the declaration |
| `targetCommand` | Command invoked by the hook declaration |
| `registered` | `true` when at least one declaring stack entry has an enabled runtime binding |

Each hook stack entry also includes the declaration's normalized `priority` and `optional` values. Hook entries always use `strategy: "additive"` and `hidden: false`: multiple extensions may declare and register the same event-command pair, and every enabled declaration remains active. Stack entries are sorted by hook priority, with the resolver's deterministic declaration order used as the tiebreaker. This ordering describes the declaration inventory; it is not a promise that equal-priority hooks execute in the same order at runtime.

For hooks, `active` reports registration state from `.specify/extensions.yml`, not condition evaluation. A declaration is active only when the runtime configuration contains an enabled binding with the same extension id, event, and command. Conditions are evaluated later when the hook executes. Missing, malformed, or unreadable runtime configuration therefore leaves declarations visible with `registered: false` and `active: false`.

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.

## 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`:
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/artifacts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
ArtifactKind,
ArtifactNotFoundError,
ArtifactResolutionError,
HookArtifact,
HookLayerName,
HookStackEntry,
LayerName,
NotASpecKitProjectError,
StackLayer,
Expand All @@ -22,6 +25,9 @@
"ArtifactKind",
"ArtifactNotFoundError",
"ArtifactResolutionError",
"HookArtifact",
"HookLayerName",
"HookStackEntry",
"LayerName",
"NotASpecKitProjectError",
"StackLayer",
Expand Down
17 changes: 8 additions & 9 deletions src/specify_cli/artifacts/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,21 @@
import json
import sys
from pathlib import Path
from typing import Optional

import typer

from ..presets import PresetError
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.",
help="Introspect commands, templates, scripts, and hooks Spec Kit exposes.",
no_args_is_help=True,
)

Expand Down Expand Up @@ -100,7 +99,7 @@ def artifact_list(
help="Emit the inventory as a JSON array on stdout.",
),
) -> None:
"""List every command, template, and script Spec Kit exposes."""
"""List every command, template, script, and hook Spec Kit exposes."""
_require_json_flag(json_flag)
try:
root = _resolve_project_root()
Expand All @@ -125,20 +124,20 @@ def artifact_info(
"--json",
help="Emit the composition stack as a JSON object on stdout.",
),
kind: Optional[str] = typer.Option(
kind: str | None = typer.Option(
None,
"--kind",
help="Narrow the lookup to one artifact family (command/template/script).",
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: Optional[ArtifactKind] = None
resolved_kind: ArtifactKind | None = None
if kind is not None:
if kind not in ("command", "template", "script"):
if kind not in ("command", "template", "script", "hook"):
print(
f"invalid --kind {kind!r}: expected one of command, template, script",
f"invalid --kind {kind!r}: expected one of command, template, script, hook",
file=sys.stderr,
)
raise typer.Exit(code=2)
Expand Down
78 changes: 77 additions & 1 deletion src/specify_cli/artifacts/_identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from __future__ import annotations

import re
from typing import Any

from urllib.parse import quote, unquote_to_bytes

PROJECT_OVERRIDE_LAYER = "project"
_ARTIFACT_KINDS = frozenset({"command", "template", "script"})
_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"})
_HOOK_LAYERS = frozenset({"preset", "extension"})
_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})")


class IdentifierComponentError(ValueError):
Expand Down Expand Up @@ -59,3 +62,76 @@ def derive_lookup_id(layer: str, source_id: str, kind: str, name: str) -> str:
"Invalid sourceId '_': reserved for project layer"
)
return f"{layer}:{source_id}:{kind}:{name}"


def derive_hook_public_id(event_name: str, command: str) -> str:
"""Build the source-agnostic identifier for a hook artifact."""
encoded_event = _encode_hook_component(event_name, "eventName")
encoded_command = _encode_hook_component(command, "command")
return f"hook:{encoded_event}:{encoded_command}"


def derive_hook_lookup_id(
layer: str, source_id: str, event_name: str, command: str
) -> str:
"""Build the artifact-private lookup identifier for a hook declaration."""
validate_component(layer, "layer")
validate_component(source_id, "sourceId")
encoded_event = _encode_hook_component(event_name, "eventName")
encoded_command = _encode_hook_component(command, "command")
if layer not in _HOOK_LAYERS:
raise IdentifierComponentError(f"Invalid hook layer '{layer}'")
if source_id == "_":
raise IdentifierComponentError(
"Invalid sourceId '_': hooks require a preset or extension source"
)
return f"{layer}:{source_id}:hook:{encoded_event}:{encoded_command}"


def parse_hook_artifact_name(name: str) -> tuple[str, str]:
"""Decode the ``{eventName}:{targetCommand}`` portion of a hook artifact ID."""
encoded_event, separator, encoded_command = name.partition(":")
if not separator or ":" in encoded_command:
raise IdentifierComponentError("Invalid hook artifact name")
return (
_decode_hook_component(encoded_event, "eventName"),
_decode_hook_component(encoded_command, "command"),
)


def _encode_hook_component(value: Any, field_label: str) -> str:
"""Encode one hook ID component without narrowing manifest syntax."""
if not isinstance(value, str):
raise IdentifierComponentError(
f"Invalid {field_label}: expected a string, got {type(value).__name__}"
)
if not value:
raise IdentifierComponentError(
f"Invalid {field_label}: value must not be empty"
)
try:
return quote(value, safe="")
except UnicodeEncodeError as exc:
raise IdentifierComponentError(
f"Invalid {field_label}: value cannot be UTF-8 encoded"
) from exc


def _decode_hook_component(value: str, field_label: str) -> str:
"""Decode one hook ID component, rejecting malformed percent escapes."""
validate_component(value, field_label)
if _INVALID_PERCENT_ESCAPE.search(value):
raise IdentifierComponentError(
f"Invalid {field_label}: malformed percent escape"
)
try:
decoded = unquote_to_bytes(value).decode("utf-8")
except UnicodeDecodeError as exc:
raise IdentifierComponentError(
f"Invalid {field_label}: value is not valid UTF-8"
) from exc
if not decoded:
raise IdentifierComponentError(
f"Invalid {field_label}: value must not be empty"
)
return decoded
Loading