diff --git a/src/specify_cli/artifacts/_mcp.py b/src/specify_cli/artifacts/_mcp.py index 1a835def04..af17a942f5 100644 --- a/src/specify_cli/artifacts/_mcp.py +++ b/src/specify_cli/artifacts/_mcp.py @@ -8,6 +8,7 @@ from mcp.server import MCPServer +from ._operation_info import ARTIFACT_INFO_OPERATION from ._operation_list import ARTIFACT_LIST_OPERATION from .mcp_list import register as register_list @@ -51,16 +52,16 @@ class ArtifactToolInventory: network_access=ARTIFACT_LIST_OPERATION.network_access, ), ArtifactToolInventory( - operation_id="artifact.info", + operation_id=ARTIFACT_INFO_OPERATION.operation_id, cli_path="specify artifact info", mcp_tool_name="specify_artifact_info", - contract_version=None, + contract_version=ARTIFACT_INFO_OPERATION.contract_version, disposition="unavailable", disposition_reason=( - "The artifact.info CLI leaf does not yet have a shared typed operation." + "The artifact.info shared operation does not yet have an MCP adapter." ), - capabilities=frozenset({"local-read"}), - network_access="none", + capabilities=ARTIFACT_INFO_OPERATION.capabilities, + network_access=ARTIFACT_INFO_OPERATION.network_access, ), ArtifactToolInventory( operation_id="artifact.lookup", diff --git a/src/specify_cli/artifacts/_operation_info.py b/src/specify_cli/artifacts/_operation_info.py new file mode 100644 index 0000000000..894fa88a1e --- /dev/null +++ b/src/specify_cli/artifacts/_operation_info.py @@ -0,0 +1,530 @@ +"""Shared application operation for ``artifact.info``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from ..presets import PresetError +from . import ( + AmbiguousArtifactError, + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from ._identifiers import ( + IdentifierComponentError, + derive_hook_public_id, + derive_public_id, + parse_hook_artifact_name, + validate_component, +) +from .models import HookStackEntry, StackLayer + +NamedArtifactKind = Literal["command", "template", "script"] + + +@dataclass(frozen=True) +class ArtifactInfoRequest: + """Explicit project context and artifact selection for ``artifact.info``.""" + + project_directory: Path + identifier: str + kind: ArtifactKind | None = None + + +@dataclass(frozen=True) +class ArtifactInfoNamedArtifact: + """One named artifact and its complete ordered composition stack.""" + + id: str + name: str + kind: NamedArtifactKind + description: str + stack: tuple[StackLayer, ...] + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + "stack": [entry.to_json_dict() for entry in self.stack], + } + + +@dataclass(frozen=True) +class ArtifactInfoHookArtifact: + """One hook artifact and its additive declaration stack.""" + + id: str + name: str + kind: Literal["hook"] + description: str + eventName: str + targetCommand: str + registered: bool + stack: tuple[HookStackEntry, ...] + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + "eventName": self.eventName, + "targetCommand": self.targetCommand, + "registered": self.registered, + "stack": [entry.to_json_dict() for entry in self.stack], + } + + +ArtifactInfoArtifact = ArtifactInfoNamedArtifact | ArtifactInfoHookArtifact + + +@dataclass(frozen=True) +class ArtifactInfoResult: + """Transport-neutral typed artifact information.""" + + artifact: ArtifactInfoArtifact + + def to_json_dict(self) -> dict[str, Any]: + return self.artifact.to_json_dict() + + +class ArtifactInfoError(ArtifactError): + """Expected transport-neutral failure from ``artifact.info``.""" + + code: str + details: dict[str, Any] + retryable: bool + + def __init__( + self, + *, + code: str, + message: str, + details: dict[str, Any], + retryable: bool = False, + ) -> None: + self.code = code + self.message = message + self.details = details + self.retryable = retryable + super().__init__(message) + + +class ArtifactInfoProjectDirectoryError(ArtifactInfoError): + """The supplied project directory is not an absolute path.""" + + def __init__(self, project_directory: Path) -> None: + super().__init__( + code="invalid_project_directory", + message="project_directory must be an absolute path", + details={"project_directory": str(project_directory)}, + ) + + +class ArtifactInfoProjectError(ArtifactInfoError): + """The supplied directory is not a Spec Kit project root.""" + + def __init__(self, project_directory: Path) -> None: + super().__init__( + code="not_a_spec_kit_project", + message="not a Spec Kit project: no .specify/ directory found", + details={"project_directory": str(project_directory)}, + ) + + +class ArtifactInfoKindError(ArtifactInfoError): + """The requested artifact family is not supported.""" + + def __init__(self, kind: object) -> None: + super().__init__( + code="invalid_artifact_kind", + message=( + f"invalid artifact kind {kind!r}: expected one of " + "command, template, script, hook" + ), + details={"kind": kind}, + ) + + +class ArtifactInfoIdentifierError(ArtifactInfoError): + """The artifact identifier is structurally invalid.""" + + def __init__(self, identifier: object) -> None: + super().__init__( + code="invalid_artifact_identifier", + message=f"unknown artifact {identifier}", + details={"identifier": identifier}, + ) + + +class ArtifactInfoNotFoundError(ArtifactInfoError): + """No named artifact matched the request.""" + + def __init__(self, identifier: str) -> None: + super().__init__( + code="unknown_artifact", + message=f"unknown artifact {identifier}", + details={"identifier": identifier}, + ) + + +class ArtifactInfoHookNotFoundError(ArtifactInfoError): + """No hook artifact matched the request.""" + + def __init__(self, identifier: str) -> None: + super().__init__( + code="unknown_hook", + message=f"unknown artifact {identifier}", + details={"identifier": identifier}, + ) + + +class ArtifactInfoAmbiguousError(ArtifactInfoError): + """A bare artifact name matched more than one artifact family.""" + + def __init__(self, identifier: str, message: str) -> None: + super().__init__( + code="ambiguous_artifact", + message=message, + details={"identifier": identifier}, + ) + + +class ArtifactInfoResolutionError(ArtifactInfoError): + """Artifact composition could not be resolved.""" + + def __init__(self, project_directory: Path, identifier: str) -> None: + super().__init__( + code="artifact_resolution_failed", + message="artifact resolution failed", + details={ + "project_directory": str(project_directory), + "identifier": identifier, + }, + ) + + +class ArtifactInfoInvalidResultError(ArtifactInfoError): + """The catalog returned an incomplete or inconsistent result.""" + + def __init__(self, project_directory: Path, identifier: str) -> None: + super().__init__( + code="invalid_operation_result", + message="artifact resolution failed", + details={ + "project_directory": str(project_directory), + "identifier": identifier, + }, + ) + + +@dataclass(frozen=True) +class ArtifactInfoOperationDescriptor: + """Stable metadata shared by delivery adapters for ``artifact.info``.""" + + operation_id: Literal["artifact.info"] + contract_version: Literal["1"] + request_type: type[ArtifactInfoRequest] + result_type: type[ArtifactInfoResult] + warning_types: tuple[type[object], ...] + error_types: tuple[type[ArtifactInfoError], ...] + capabilities: frozenset[Literal["local-read"]] + network_access: Literal["none"] + + +ARTIFACT_INFO_OPERATION = ArtifactInfoOperationDescriptor( + operation_id="artifact.info", + contract_version="1", + request_type=ArtifactInfoRequest, + result_type=ArtifactInfoResult, + warning_types=(), + error_types=( + ArtifactInfoProjectDirectoryError, + ArtifactInfoProjectError, + ArtifactInfoKindError, + ArtifactInfoIdentifierError, + ArtifactInfoNotFoundError, + ArtifactInfoHookNotFoundError, + ArtifactInfoAmbiguousError, + ArtifactInfoResolutionError, + ArtifactInfoInvalidResultError, + ), + capabilities=frozenset({"local-read"}), + network_access="none", +) + +_NAMED_RESULT_KEYS = {"id", "name", "kind", "description", "stack"} +_HOOK_RESULT_KEYS = { + "id", + "name", + "kind", + "description", + "eventName", + "targetCommand", + "registered", + "stack", +} +_STACK_KEYS = { + "id", + "layer", + "sourceId", + "presetId", + "presetName", + "strategy", + "active", + "hidden", + "manifestPath", + "lookupId", + "sourcePath", +} +_HOOK_STACK_KEYS = { + *_STACK_KEYS, + "priority", + "optional", +} + + +def _is_optional_string(value: object) -> bool: + return value is None or isinstance(value, str) + + +def _invalid_result(request: ArtifactInfoRequest) -> ArtifactInfoInvalidResultError: + return ArtifactInfoInvalidResultError( + Path(request.project_directory), + request.identifier, + ) + + +def _convert_named_stack( + value: object, + *, + artifact_id: str, + request: ArtifactInfoRequest, +) -> tuple[StackLayer, ...]: + if not isinstance(value, list) or not value: + raise _invalid_result(request) + + entries: list[StackLayer] = [] + for item in value: + if not isinstance(item, dict) or set(item) != _STACK_KEYS: + raise _invalid_result(request) + if ( + item["id"] != artifact_id + or item["layer"] not in (None, "project", "preset", "extension") + or not _is_optional_string(item["sourceId"]) + or not _is_optional_string(item["presetId"]) + or not _is_optional_string(item["presetName"]) + or item["strategy"] not in ("replace", "wrap", "prepend", "append") + or not isinstance(item["active"], bool) + or not isinstance(item["hidden"], bool) + or not _is_optional_string(item["manifestPath"]) + or not _is_optional_string(item["lookupId"]) + or not _is_optional_string(item["sourcePath"]) + ): + raise _invalid_result(request) + entries.append(StackLayer(**item)) + return tuple(entries) + + +def _convert_hook_stack( + value: object, + *, + artifact_id: str, + request: ArtifactInfoRequest, +) -> tuple[HookStackEntry, ...]: + if not isinstance(value, list) or not value: + raise _invalid_result(request) + + entries: list[HookStackEntry] = [] + for item in value: + if not isinstance(item, dict) or set(item) != _HOOK_STACK_KEYS: + raise _invalid_result(request) + priority = item["priority"] + if ( + item["id"] != artifact_id + or item["layer"] not in ("preset", "extension") + or not isinstance(item["sourceId"], str) + or not _is_optional_string(item["presetId"]) + or not _is_optional_string(item["presetName"]) + or item["strategy"] != "additive" + or not isinstance(item["active"], bool) + or not isinstance(item["hidden"], bool) + or not isinstance(item["manifestPath"], str) + or not isinstance(item["lookupId"], str) + or item["sourcePath"] is not None + or isinstance(priority, bool) + or not isinstance(priority, int) + or not isinstance(item["optional"], bool) + ): + raise _invalid_result(request) + entries.append(HookStackEntry(**item)) + return tuple(entries) + + +def _convert_catalog_result( + payload: object, + request: ArtifactInfoRequest, +) -> ArtifactInfoResult: + if not isinstance(payload, dict): + raise _invalid_result(request) + + kind = payload.get("kind") + if kind == "hook": + if set(payload) != _HOOK_RESULT_KEYS: + raise _invalid_result(request) + artifact_id = payload["id"] + name = payload["name"] + description = payload["description"] + event_name = payload["eventName"] + target_command = payload["targetCommand"] + if ( + not isinstance(artifact_id, str) + or not isinstance(name, str) + or not isinstance(description, str) + or not isinstance(event_name, str) + or not isinstance(target_command, str) + or not isinstance(payload["registered"], bool) + ): + raise _invalid_result(request) + try: + expected_id = derive_hook_public_id(event_name, target_command) + except IdentifierComponentError as exc: + raise _invalid_result(request) from exc + if artifact_id != expected_id or name != artifact_id.removeprefix("hook:"): + raise _invalid_result(request) + return ArtifactInfoResult( + artifact=ArtifactInfoHookArtifact( + id=artifact_id, + name=name, + kind="hook", + description=description, + eventName=event_name, + targetCommand=target_command, + registered=payload["registered"], + stack=_convert_hook_stack( + payload["stack"], + artifact_id=artifact_id, + request=request, + ), + ) + ) + + if kind not in ("command", "template", "script"): + raise _invalid_result(request) + if set(payload) != _NAMED_RESULT_KEYS: + raise _invalid_result(request) + artifact_id = payload["id"] + name = payload["name"] + description = payload["description"] + if ( + not isinstance(artifact_id, str) + or not isinstance(name, str) + or not isinstance(description, str) + ): + raise _invalid_result(request) + try: + expected_id = derive_public_id(kind, name) + except IdentifierComponentError as exc: + raise _invalid_result(request) from exc + if artifact_id != expected_id: + raise _invalid_result(request) + return ArtifactInfoResult( + artifact=ArtifactInfoNamedArtifact( + id=artifact_id, + name=name, + kind=kind, + description=description, + stack=_convert_named_stack( + payload["stack"], + artifact_id=artifact_id, + request=request, + ), + ) + ) + + +def _validate_identifier(identifier: str, kind: ArtifactKind | None) -> None: + if kind == "hook": + if identifier.startswith("hook:"): + try: + parse_hook_artifact_name(identifier.removeprefix("hook:")) + except IdentifierComponentError: + pass + else: + return + try: + parse_hook_artifact_name(identifier) + except IdentifierComponentError as exc: + raise ArtifactInfoIdentifierError(identifier) from exc + return + + if ":" not in identifier: + return + + prefix, _, bare = identifier.partition(":") + if prefix in ("command", "template", "script"): + try: + validate_component(bare, f"{prefix} name") + except IdentifierComponentError as exc: + raise ArtifactInfoIdentifierError(identifier) from exc + return + if prefix == "hook": + try: + parse_hook_artifact_name(bare) + except IdentifierComponentError as exc: + raise ArtifactInfoIdentifierError(identifier) from exc + return + raise ArtifactInfoIdentifierError(identifier) + + +def _requests_hook(identifier: str, kind: ArtifactKind | None) -> bool: + return kind == "hook" or (kind is None and identifier.startswith("hook:")) + + +def get_artifact_info(request: ArtifactInfoRequest) -> ArtifactInfoResult: + """Return typed artifact information for an explicit Spec Kit project.""" + project_directory = Path(request.project_directory) + if not project_directory.is_absolute(): + raise ArtifactInfoProjectDirectoryError(project_directory) + + if request.kind is not None and request.kind not in ( + "command", + "template", + "script", + "hook", + ): + raise ArtifactInfoKindError(request.kind) + if not isinstance(request.identifier, str) or not request.identifier: + raise ArtifactInfoIdentifierError(request.identifier) + _validate_identifier(request.identifier, request.kind) + if not (project_directory / ".specify").is_dir(): + raise ArtifactInfoProjectError(project_directory) + + try: + payload = ArtifactCatalog(project_directory).get_artifact_info( + request.identifier, + kind=request.kind, + ) + except NotASpecKitProjectError as exc: + raise ArtifactInfoProjectError(project_directory) from exc + except AmbiguousArtifactError as exc: + raise ArtifactInfoAmbiguousError(request.identifier, exc.message) from exc + except ArtifactNotFoundError as exc: + if _requests_hook(request.identifier, request.kind): + raise ArtifactInfoHookNotFoundError(request.identifier) from exc + raise ArtifactInfoNotFoundError(request.identifier) from exc + except (ArtifactResolutionError, OSError, PresetError) as exc: + raise ArtifactInfoResolutionError( + project_directory, + request.identifier, + ) from exc + + return _convert_catalog_result(payload, request) diff --git a/src/specify_cli/artifacts/command_info.py b/src/specify_cli/artifacts/command_info.py index da23fde9a5..6526af4f27 100644 --- a/src/specify_cli/artifacts/command_info.py +++ b/src/specify_cli/artifacts/command_info.py @@ -3,23 +3,37 @@ from __future__ import annotations import json +import os import sys +from pathlib import Path import typer -from ..presets import PresetError -from . import ( - ArtifactCatalog, - ArtifactError, - ArtifactKind, - ArtifactResolutionError, -) +from . import ArtifactError, ArtifactKind from ._commands import ( _emit_error_and_exit, _require_json_flag, - _resolve_project_root, artifact_app, ) +from ._operation_info import ( + ArtifactInfoRequest, + ArtifactInfoResolutionError, + get_artifact_info, +) + + +def _project_directory_from_cli_context() -> Path: + """Return the explicit project directory selected by the CLI invocation.""" + invocation_directory = Path.cwd() + override = os.environ.get("SPECIFY_INIT_DIR", "") + if not override: + return invocation_directory + return (invocation_directory / override).resolve() + + +def _resolve_project_root() -> Path: + """Preserve the command module's established project-root patch seam.""" + return _project_directory_from_cli_context() @artifact_app.command("info") @@ -50,15 +64,26 @@ def artifact_info( resolved_kind = kind # type: ignore[assignment] try: - root = _resolve_project_root() - catalog = ArtifactCatalog(root) - payload = catalog.get_artifact_info(name, kind=resolved_kind) + result = get_artifact_info( + ArtifactInfoRequest( + project_directory=_resolve_project_root(), + identifier=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()) + except OSError: + _emit_error_and_exit(ArtifactInfoResolutionError(Path("."), name)) return # pragma: no cover - sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write( + json.dumps( + result.to_json_dict(), + indent=2, + sort_keys=True, + ensure_ascii=False, + ) + ) sys.stdout.write("\n") diff --git a/tests/specify_cli/artifacts/test_command_info.py b/tests/specify_cli/artifacts/test_command_info.py index 4fb3ddce63..17e02e5a61 100644 --- a/tests/specify_cli/artifacts/test_command_info.py +++ b/tests/specify_cli/artifacts/test_command_info.py @@ -4,11 +4,19 @@ import json from pathlib import Path +from unittest.mock import Mock import pytest from typer.testing import CliRunner from specify_cli import app +from specify_cli.artifacts import command_info +from specify_cli.artifacts._operation_info import ( + ArtifactInfoNamedArtifact, + ArtifactInfoNotFoundError, + ArtifactInfoResult, +) +from specify_cli.artifacts.models import StackLayer from tests.specify_cli.artifacts.helpers import ( ERROR_REGEX, install_extension_with_hooks, @@ -16,13 +24,140 @@ class TestCommandInfo: - def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + def test_info_requires_json_flag( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + ["artifact", "info", "speckit.plan"], + ) + + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr == ( + "specify artifact requires --json for now; " + "text output is not yet implemented.\n" + ) + + def test_info_rejects_invalid_cli_kind_before_dispatch( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + operation = Mock(side_effect=AssertionError("operation reached")) + monkeypatch.setattr(command_info, "get_artifact_info", operation) + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + [ + "artifact", + "info", + "speckit.plan", + "--json", + "--kind", + "invalid", + ], + ) + + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr == ( + "invalid --kind 'invalid': expected one of " + "command, template, script, hook\n" + ) + operation.assert_not_called() + + def test_info_dispatches_directly_to_shared_operation( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + expected = ArtifactInfoResult( + artifact=ArtifactInfoNamedArtifact( + id="template:réview", + name="réview", + kind="template", + description="Réview ✓", + stack=( + StackLayer( + id="template:réview", + layer="extension", + sourceId="quality", + presetId=None, + presetName=None, + strategy="replace", + active=True, + hidden=False, + manifestPath=".specify/extensions/quality/extension.yml", + lookupId="extension:quality:template:réview", + sourcePath=(".specify/extensions/quality/templates/réview.md"), + ), + ), + ) + ) + operation = Mock(return_value=expected) + monkeypatch.setattr(command_info, "get_artifact_info", operation) + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + [ + "artifact", + "info", + "réview", + "--json", + "--kind", + "template", + ], + ) + + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == expected.to_json_dict() + request = operation.call_args.args[0] + assert request.project_directory == spec_kit_project + assert request.identifier == "réview" + assert request.kind == "template" + + def test_info_json_matches_shared_operation_result( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + expected = command_info.get_artifact_info( + command_info.ArtifactInfoRequest( + spec_kit_project, + "command:speckit.plan", + ) + ) + + result = CliRunner().invoke( + app, + ["artifact", "info", "command:speckit.plan", "--json"], + ) + + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == expected.to_json_dict() + + def test_info_json_shape( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.chdir(spec_kit_project) runner = CliRunner() - result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) + result = runner.invoke( + app, ["artifact", "info", "speckit.constitution", "--json"] + ) assert result.exit_code == 0, result.stderr payload = json.loads(result.stdout) assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + assert result.stdout == ( + json.dumps( + payload, + indent=2, + sort_keys=True, + ensure_ascii=False, + ) + + "\n" + ) + assert result.stderr == "" + assert "\x1b[" not in result.stdout def test_info_accepts_id_form_on_cli( self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch @@ -30,12 +165,16 @@ def test_info_accepts_id_form_on_cli( monkeypatch.chdir(spec_kit_project) runner = CliRunner() by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) - by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) + by_id = runner.invoke( + app, ["artifact", "info", "command:speckit.plan", "--json"] + ) assert by_bare.exit_code == 0, by_bare.stderr assert by_id.exit_code == 0, by_id.stderr assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) - def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + def test_info_unknown_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) @@ -44,6 +183,46 @@ def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: err = json.loads(result.stderr) assert set(err.keys()) == {"error"} assert ERROR_REGEX.match(err["error"]) + assert result.stderr.count("\n") == 1 + assert "\x1b[" not in result.stderr + + def test_info_expected_operation_failure_is_one_legacy_json_error( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr( + command_info, + "get_artifact_info", + Mock(side_effect=ArtifactInfoNotFoundError("missing")), + ) + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + ["artifact", "info", "missing", "--json"], + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == '{"error": "unknown artifact missing"}\n' + assert "Traceback" not in result.stderr + + def test_info_cli_context_filesystem_failure_is_json_error( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr( + command_info, + "_project_directory_from_cli_context", + Mock(side_effect=OSError("unreadable cwd")), + ) + + result = CliRunner().invoke( + app, + ["artifact", "info", "speckit.plan", "--json"], + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} def test_info_corrupt_extension_registry_uses_json_error_envelope( self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch @@ -58,7 +237,9 @@ def test_info_corrupt_extension_registry_uses_json_error_envelope( assert result.stdout == "" assert json.loads(result.stderr) == {"error": "artifact resolution failed"} - def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + def test_stdout_empty_on_error( + self, non_project: Path, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.chdir(non_project) runner = CliRunner() for argv in ( diff --git a/tests/specify_cli/artifacts/test_mcp_list.py b/tests/specify_cli/artifacts/test_mcp_list.py index b46a41168d..204f2f4ccd 100644 --- a/tests/specify_cli/artifacts/test_mcp_list.py +++ b/tests/specify_cli/artifacts/test_mcp_list.py @@ -16,7 +16,13 @@ from mcp.shared.memory import create_client_server_memory_streams from mcp.types import CallToolResult -from specify_cli.artifacts import _commands, _mcp, _operation_list, mcp_list +from specify_cli.artifacts import ( + _commands, + _mcp, + _operation_info, + _operation_list, + mcp_list, +) from specify_cli.artifacts._operation_list import ( ARTIFACT_LIST_OPERATION, ArtifactListRequest, @@ -855,10 +861,30 @@ def test_artifact_inventory_matches_cli_leaves_and_operation_contract( "artifact.info", "artifact.lookup", } - assert all(item.contract_version is None for item in unavailable) assert all(item.disposition_reason for item in unavailable) assert all(item.mcp_tool_name not in registered_tool_names for item in unavailable) + info_tool = next( + item for item in unavailable if item.operation_id == "artifact.info" + ) + assert info_tool.contract_version == ( + _operation_info.ARTIFACT_INFO_OPERATION.contract_version + ) + assert info_tool.capabilities == ( + _operation_info.ARTIFACT_INFO_OPERATION.capabilities + ) + assert info_tool.network_access == ( + _operation_info.ARTIFACT_INFO_OPERATION.network_access + ) + assert info_tool.disposition_reason == ( + "The artifact.info shared operation does not yet have an MCP adapter." + ) + + lookup_tool = next( + item for item in unavailable if item.operation_id == "artifact.lookup" + ) + assert lookup_tool.contract_version is None + def test_artifact_registration_adds_available_tool_once_and_rejects_collision( spec_kit_project: Path, diff --git a/tests/specify_cli/artifacts/test_operation_info.py b/tests/specify_cli/artifacts/test_operation_info.py new file mode 100644 index 0000000000..15f5f9b551 --- /dev/null +++ b/tests/specify_cli/artifacts/test_operation_info.py @@ -0,0 +1,672 @@ +"""Tests for the shared ``artifact.info`` operation.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock + +import pytest +import yaml + +from specify_cli import artifacts +from specify_cli.artifacts import ( + ArtifactCatalog, + ArtifactResolutionError, + _operation_info, +) +from specify_cli.artifacts._operation_info import ( + ARTIFACT_INFO_OPERATION, + ArtifactInfoAmbiguousError, + ArtifactInfoHookArtifact, + ArtifactInfoHookNotFoundError, + ArtifactInfoIdentifierError, + ArtifactInfoInvalidResultError, + ArtifactInfoKindError, + ArtifactInfoNamedArtifact, + ArtifactInfoNotFoundError, + ArtifactInfoProjectDirectoryError, + ArtifactInfoProjectError, + ArtifactInfoRequest, + ArtifactInfoResolutionError, + ArtifactInfoResult, + get_artifact_info, +) +from specify_cli.extensions import ExtensionRegistry +from specify_cli.presets import PresetError +from tests.conftest import install_preset +from tests.specify_cli.artifacts.helpers import install_extension_with_hooks + + +def test_artifact_info_operation_descriptor_is_stable(): + assert ARTIFACT_INFO_OPERATION.operation_id == "artifact.info" + assert ARTIFACT_INFO_OPERATION.contract_version == "1" + assert ARTIFACT_INFO_OPERATION.request_type is ArtifactInfoRequest + assert ARTIFACT_INFO_OPERATION.result_type is ArtifactInfoResult + assert ARTIFACT_INFO_OPERATION.warning_types == () + assert ARTIFACT_INFO_OPERATION.error_types == ( + ArtifactInfoProjectDirectoryError, + ArtifactInfoProjectError, + ArtifactInfoKindError, + ArtifactInfoIdentifierError, + ArtifactInfoNotFoundError, + ArtifactInfoHookNotFoundError, + ArtifactInfoAmbiguousError, + ArtifactInfoResolutionError, + ArtifactInfoInvalidResultError, + ) + assert ARTIFACT_INFO_OPERATION.capabilities == frozenset({"local-read"}) + assert ARTIFACT_INFO_OPERATION.network_access == "none" + + +def test_get_artifact_info_returns_complete_typed_core_result( + spec_kit_project: Path, +): + request = ArtifactInfoRequest( + project_directory=spec_kit_project, + identifier="command:speckit.constitution", + ) + + result = get_artifact_info(request) + payload = result.to_json_dict() + + assert isinstance(result, ArtifactInfoResult) + assert isinstance(result.artifact, ArtifactInfoNamedArtifact) + assert payload == ArtifactCatalog(spec_kit_project).get_artifact_info( + request.identifier + ) + assert set(payload) == {"id", "name", "kind", "description", "stack"} + assert payload["id"] == "command:speckit.constitution" + assert payload["stack"] + assert all( + set(entry) + == { + "id", + "layer", + "sourceId", + "presetId", + "presetName", + "strategy", + "active", + "hidden", + "manifestPath", + "lookupId", + "sourcePath", + } + for entry in payload["stack"] + ) + + +def test_bare_name_and_public_id_are_identical(spec_kit_project: Path): + by_name = get_artifact_info(ArtifactInfoRequest(spec_kit_project, "speckit.plan")) + by_id = get_artifact_info( + ArtifactInfoRequest(spec_kit_project, "command:speckit.plan") + ) + + assert by_name == by_id + + +@pytest.mark.parametrize( + ("identifier", "kind"), + [ + ("speckit.constitution", "command"), + ("spec-template", "template"), + ("setup-plan", "script"), + ], +) +def test_explicit_kind_selects_each_named_family( + spec_kit_project: Path, + identifier: str, + kind: str, +): + result = get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + identifier, + kind=kind, # type: ignore[arg-type] + ) + ) + + assert result.artifact.kind == kind + assert result.to_json_dict() == ArtifactCatalog(spec_kit_project).get_artifact_info( + identifier, kind=kind + ) # type: ignore[arg-type] + + +def test_hook_result_preserves_encoded_identity_and_stack_metadata( + spec_kit_project: Path, +): + install_extension_with_hooks( + spec_kit_project, + "quality", + hooks={ + "custom:after": [ + { + "command": "/skill:speckit-quality", + "description": "Réview hook", + "priority": 4, + "optional": False, + } + ] + }, + ) + identifier = "hook:custom%3Aafter:%2Fskill%3Aspeckit-quality" + + result = get_artifact_info(ArtifactInfoRequest(spec_kit_project, identifier)) + payload = result.to_json_dict() + + assert isinstance(result.artifact, ArtifactInfoHookArtifact) + assert payload == ArtifactCatalog(spec_kit_project).get_artifact_info(identifier) + assert payload["name"] == "custom%3Aafter:%2Fskill%3Aspeckit-quality" + assert payload["eventName"] == "custom:after" + assert payload["targetCommand"] == "/skill:speckit-quality" + assert payload["description"] == "Réview hook" + assert payload["stack"] == [ + { + "id": identifier, + "layer": "extension", + "sourceId": "quality", + "presetId": None, + "presetName": None, + "strategy": "additive", + "active": False, + "hidden": False, + "manifestPath": ".specify/extensions/quality/extension.yml", + "lookupId": ( + "extension:quality:hook:custom%3Aafter:%2Fskill%3Aspeckit-quality" + ), + "sourcePath": None, + "priority": 4, + "optional": False, + } + ] + + +def test_info_preserves_unicode_paths_and_matches_list_row( + spec_kit_project: Path, +): + extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" + template = extension_dir / "templates" / "réview.md" + template.parent.mkdir(parents=True) + template.write_text("---\ndescription: Réview ✓\n---\n", encoding="utf-8") + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "quality", + "name": "Quality", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": "réview", + "file": "templates/réview.md", + "description": "Réview ✓", + } + ] + }, + } + ), + encoding="utf-8", + ) + ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( + "quality", + {"version": "1.0.0", "enabled": True}, + ) + + result = get_artifact_info( + ArtifactInfoRequest(spec_kit_project, "template:réview") + ).to_json_dict() + list_row = next( + row + for row in ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + if row["id"] == "template:réview" + ) + + assert result == list_row + assert result["name"] == "réview" + assert result["description"] == "Réview ✓" + assert result["stack"][0]["sourcePath"] == ( + ".specify/extensions/quality/templates/réview.md" + ) + + +def test_info_preserves_preset_layer_order(spec_kit_project: Path): + for pack_id, priority, strategy in ( + ("top", 5, "prepend"), + ("lower", 10, "append"), + ): + pack = install_preset( + spec_kit_project, + pack_id, + { + "templates": [ + { + "name": "spec-template", + "strategy": strategy, + "description": f"{pack_id} description", + } + ] + }, + priority=priority, + ) + (pack / "templates").mkdir() + (pack / "templates" / "spec-template.md").write_text( + f"{pack_id} content", + encoding="utf-8", + ) + + result = get_artifact_info( + ArtifactInfoRequest(spec_kit_project, "template:spec-template") + ).to_json_dict() + + assert [entry["sourceId"] for entry in result["stack"][:2]] == [ + "top", + "lower", + ] + assert [entry["strategy"] for entry in result["stack"]] == [ + "prepend", + "append", + "replace", + ] + + +def test_info_is_stable_across_repeated_calls(spec_kit_project: Path): + request = ArtifactInfoRequest(spec_kit_project, "speckit.plan") + + assert get_artifact_info(request) == get_artifact_info(request) + + +def test_info_isolates_sequential_explicit_projects( + spec_kit_project: Path, + tmp_path: Path, + non_project: Path, + monkeypatch: pytest.MonkeyPatch, +): + second = tmp_path / "second" + second.mkdir() + (second / ".specify").mkdir() + (second / ".specify" / "presets").mkdir() + (second / ".specify" / "extensions").mkdir() + (second / ".specify" / "templates").mkdir() + first_override = spec_kit_project / ".specify" / "templates" / "overrides" + second_override = second / ".specify" / "templates" / "overrides" + first_override.mkdir(parents=True) + second_override.mkdir(parents=True) + (first_override / "isolated.md").write_text( + "---\ndescription: First\n---\n", + encoding="utf-8", + ) + (second_override / "isolated.md").write_text( + "---\ndescription: Second\n---\n", + encoding="utf-8", + ) + monkeypatch.chdir(non_project) + + first = get_artifact_info(ArtifactInfoRequest(spec_kit_project, "command:isolated")) + second_result = get_artifact_info(ArtifactInfoRequest(second, "command:isolated")) + + assert first.artifact.description == "First" + assert second_result.artifact.description == "Second" + assert Path.cwd() == non_project + + +def test_info_rejects_relative_project_directory(): + with pytest.raises(ArtifactInfoProjectDirectoryError) as exc_info: + get_artifact_info(ArtifactInfoRequest(Path("relative"), "speckit.plan")) + + assert exc_info.value.code == "invalid_project_directory" + assert exc_info.value.details == {"project_directory": "relative"} + + +def test_info_rejects_non_project_before_catalog_lookup( + non_project: Path, + monkeypatch: pytest.MonkeyPatch, +): + catalog = Mock(side_effect=AssertionError("catalog reached")) + monkeypatch.setattr(_operation_info, "ArtifactCatalog", catalog) + + with pytest.raises(ArtifactInfoProjectError) as exc_info: + get_artifact_info(ArtifactInfoRequest(non_project, "speckit.plan")) + + assert exc_info.value.code == "not_a_spec_kit_project" + assert exc_info.value.details == {"project_directory": str(non_project)} + catalog.assert_not_called() + + +def test_info_rejects_malformed_identifier_before_project_validation( + non_project: Path, + monkeypatch: pytest.MonkeyPatch, +): + catalog = Mock(side_effect=AssertionError("catalog reached")) + monkeypatch.setattr(_operation_info, "ArtifactCatalog", catalog) + + with pytest.raises(ArtifactInfoIdentifierError) as exc_info: + get_artifact_info(ArtifactInfoRequest(non_project, "unknown-prefix:name")) + + assert exc_info.value.code == "invalid_artifact_identifier" + assert exc_info.value.details == {"identifier": "unknown-prefix:name"} + catalog.assert_not_called() + + +def test_info_rejects_invalid_semantic_kind(spec_kit_project: Path): + with pytest.raises(ArtifactInfoKindError) as exc_info: + get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + "speckit.plan", + kind="invalid", # type: ignore[arg-type] + ) + ) + + assert exc_info.value.code == "invalid_artifact_kind" + assert exc_info.value.details == {"kind": "invalid"} + + +@pytest.mark.parametrize( + "identifier", + ["missing", "command:missing"], +) +def test_info_maps_unknown_named_artifacts( + spec_kit_project: Path, + identifier: str, +): + with pytest.raises(ArtifactInfoNotFoundError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, identifier)) + + assert exc_info.value.code == "unknown_artifact" + assert exc_info.value.message == f"unknown artifact {identifier}" + + +def test_info_maps_ambiguous_artifact(spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "shared.md").write_text("body", encoding="utf-8") + + with pytest.raises(ArtifactInfoAmbiguousError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "shared")) + + assert exc_info.value.code == "ambiguous_artifact" + assert exc_info.value.message.startswith("ambiguous artifact shared:") + + +def test_info_maps_unknown_hook(spec_kit_project: Path): + with pytest.raises(ArtifactInfoHookNotFoundError) as exc_info: + get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + "hook:nope:missing.cmd", + ) + ) + + assert exc_info.value.code == "unknown_hook" + assert exc_info.value.message == "unknown artifact hook:nope:missing.cmd" + + +def test_explicit_named_kind_wins_over_conflicting_hook_shorthand( + spec_kit_project: Path, +): + with pytest.raises(ArtifactInfoNotFoundError) as exc_info: + get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + "hook:nope:missing.cmd", + kind="command", + ) + ) + + assert exc_info.value.code == "unknown_artifact" + assert exc_info.value.message == "unknown artifact hook:nope:missing.cmd" + + +def test_explicit_hook_kind_preserves_event_command_identifier( + spec_kit_project: Path, +): + with pytest.raises(ArtifactInfoHookNotFoundError) as exc_info: + get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + "unknown-prefix:name", + kind="hook", + ) + ) + + assert exc_info.value.code == "unknown_hook" + + +def test_explicit_hook_kind_accepts_public_hook_identifier( + spec_kit_project: Path, +): + install_extension_with_hooks( + spec_kit_project, + "quality", + hooks={"before_specify": [{"command": "speckit.quality.check"}]}, + ) + + result = get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + "hook:before_specify:speckit.quality.check", + kind="hook", + ) + ) + + assert result.artifact.kind == "hook" + assert result.artifact.id == "hook:before_specify:speckit.quality.check" + + +@pytest.mark.parametrize( + ("identifier", "kind"), + [ + ("hook:event:bad%escape", None), + ("hook:event:%FF", None), + ("hook:event", None), + ("unknown-prefix:name", None), + ("command:bad:name", None), + ("hook:event:bad%escape", "hook"), + ("", None), + ], +) +def test_info_maps_malformed_identifiers( + spec_kit_project: Path, + identifier: str, + kind: str | None, +): + with pytest.raises(ArtifactInfoIdentifierError) as exc_info: + get_artifact_info( + ArtifactInfoRequest( + spec_kit_project, + identifier, + kind=kind, # type: ignore[arg-type] + ) + ) + + assert exc_info.value.code == "invalid_artifact_identifier" + assert exc_info.value.message == f"unknown artifact {identifier}" + + +def test_info_maps_corrupt_registry_to_resolution_error( + spec_kit_project: Path, +): + registry = spec_kit_project / ".specify" / "extensions" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactInfoResolutionError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "speckit.plan")) + + assert exc_info.value.code == "artifact_resolution_failed" + assert exc_info.value.message == "artifact resolution failed" + + +@pytest.mark.parametrize( + "failure", + [ + ArtifactResolutionError(), + PresetError("broken preset"), + OSError("unreadable"), + ], +) +def test_info_converts_resolution_failures( + spec_kit_project: Path, + monkeypatch: pytest.MonkeyPatch, + failure: Exception, +): + monkeypatch.setattr( + artifacts.ArtifactCatalog, + "get_artifact_info", + Mock(side_effect=failure), + ) + + with pytest.raises(ArtifactInfoResolutionError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "speckit.plan")) + + assert exc_info.value.__cause__ is failure + + +def _named_payload() -> dict[str, object]: + return { + "id": "template:broken", + "name": "broken", + "kind": "template", + "description": "Broken", + "stack": [ + { + "id": "template:broken", + "layer": None, + "sourceId": None, + "presetId": None, + "presetName": None, + "strategy": "replace", + "active": True, + "hidden": False, + "manifestPath": None, + "lookupId": None, + "sourcePath": None, + } + ], + } + + +def _hook_payload() -> dict[str, object]: + return { + "id": "hook:event:cmd", + "name": "event:cmd", + "kind": "hook", + "description": "Hook", + "eventName": "event", + "targetCommand": "cmd", + "registered": False, + "stack": [ + { + "id": "hook:event:cmd", + "layer": "extension", + "sourceId": "ext", + "presetId": None, + "presetName": None, + "strategy": "additive", + "active": False, + "hidden": False, + "manifestPath": ".specify/extensions/ext/extension.yml", + "lookupId": "extension:ext:hook:event:cmd", + "sourcePath": None, + "priority": 10, + "optional": True, + } + ], + } + + +@pytest.mark.parametrize( + "payload", + [ + None, + {}, + {**_named_payload(), "stack": []}, + {**_named_payload(), "unexpected": True}, + {**_named_payload(), "stack": [None]}, + { + **_named_payload(), + "stack": [ + { + **_named_payload()["stack"][0], # type: ignore[index] + "active": "yes", + } + ], + }, + {**_named_payload(), "id": "template:wrong"}, + {**_named_payload(), "name": None}, + {**_named_payload(), "name": "bad:name"}, + {**_hook_payload(), "stack": []}, + {**_hook_payload(), "unexpected": True}, + {**_hook_payload(), "stack": [None]}, + { + **_hook_payload(), + "stack": [ + { + **_hook_payload()["stack"][0], # type: ignore[index] + "priority": True, + } + ], + }, + {**_hook_payload(), "registered": "no"}, + {**_hook_payload(), "eventName": "\ud800"}, + {**_hook_payload(), "id": "hook:event:wrong"}, + {**_hook_payload(), "name": "event:wrong"}, + ], +) +def test_info_rejects_invalid_catalog_results( + spec_kit_project: Path, + monkeypatch: pytest.MonkeyPatch, + payload: object, +): + monkeypatch.setattr( + _operation_info, + "ArtifactCatalog", + lambda _project_directory: Mock(get_artifact_info=Mock(return_value=payload)), + ) + + with pytest.raises(ArtifactInfoInvalidResultError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "template:broken")) + + assert exc_info.value.code == "invalid_operation_result" + assert exc_info.value.message == "artifact resolution failed" + + +def test_info_converts_catalog_project_error( + spec_kit_project: Path, + monkeypatch: pytest.MonkeyPatch, +): + catalog = Mock() + catalog.get_artifact_info.side_effect = artifacts.NotASpecKitProjectError() + monkeypatch.setattr( + _operation_info, + "ArtifactCatalog", + Mock(return_value=catalog), + ) + + with pytest.raises(ArtifactInfoProjectError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "speckit.plan")) + + assert isinstance(exc_info.value.__cause__, artifacts.NotASpecKitProjectError) + + +def test_operation_contract_is_transport_neutral(): + forbidden_names = { + "typer", + "rich", + "mcp", + "console", + "subprocess", + "os", + "chdir", + "getcwd", + } + + assert forbidden_names.isdisjoint(_operation_info.__dict__) + assert ArtifactInfoResult.__module__ == ("specify_cli.artifacts._operation_info")