|
1 | | -"""Agent runtime events for integrations. |
| 1 | +"""Agent runtime events and the ``specify event`` command group. |
2 | 2 |
|
3 | 3 | Provides: |
4 | 4 | - ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in). |
5 | 5 | - ``collect_extension_events`` — scan installed extension.yml files for ``events:``. |
6 | 6 | - ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``. |
| 7 | +
|
| 8 | +The singular CLI namespace intentionally maps to the plural Python package. |
| 9 | +``specify_cli.events`` is an established domain API and monkeypatch boundary, |
| 10 | +so preserving it takes precedence over matching the CLI spelling on disk. |
| 11 | +``command_run`` contains the nested CLI adapter; this module owns the domain |
| 12 | +API, Typer application, and registration. |
7 | 13 | """ |
8 | 14 |
|
9 | 15 | from __future__ import annotations |
|
21 | 27 | from typing import TYPE_CHECKING, Any |
22 | 28 |
|
23 | 29 | import yaml |
| 30 | +import typer |
24 | 31 |
|
25 | 32 | if TYPE_CHECKING: |
26 | | - from .integrations.base import IntegrationBase |
27 | | - from .integrations.manifest import IntegrationManifest |
| 33 | + from ..integrations.base import IntegrationBase |
| 34 | + from ..integrations.manifest import IntegrationManifest |
28 | 35 |
|
29 | 36 | logger = logging.getLogger(__name__) |
30 | 37 |
|
| 38 | +event_app = typer.Typer( |
| 39 | + name="event", |
| 40 | + help="Manage and execute event-driven commands", |
| 41 | + add_completion=False, |
| 42 | +) |
| 43 | + |
31 | 44 | # -- Constants ------------------------------------------------------------- |
32 | 45 |
|
33 | 46 | # Generated hook dispatchers refuse to delegate unless this name is True. |
@@ -526,7 +539,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path |
526 | 539 | # on-disk fallback below. |
527 | 540 | disabled_ids = _disabled_extension_ids(project_root) |
528 | 541 | try: |
529 | | - from .extensions import ExtensionManager |
| 542 | + from ..extensions import ExtensionManager |
530 | 543 | manager = ExtensionManager(project_root) |
531 | 544 | for ext_id in sorted(manager.registry.keys()): |
532 | 545 | if ext_id in disabled_ids: |
@@ -572,7 +585,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path |
572 | 585 | # templates/commands). The previous bespoke inspect.getfile() math |
573 | 586 | # pointed at core_pack/templates/commands, which never exists in a |
574 | 587 | # wheel build (force-include maps templates/commands -> core_pack/commands). |
575 | | - from ._assets import _locate_core_pack, _repo_root |
| 588 | + from .._assets import _locate_core_pack, _repo_root |
576 | 589 | core_pack = _locate_core_pack() |
577 | 590 | candidate_dirs = [ |
578 | 591 | core_pack / "commands" if core_pack is not None else None, |
@@ -626,7 +639,7 @@ def _resolve_event_command_argv( |
626 | 639 | ``.py``, the platform shell otherwise). Returns ``None`` if no runnable |
627 | 640 | script is declared. |
628 | 641 | """ |
629 | | - from .integrations.base import IntegrationBase |
| 642 | + from ..integrations.base import IntegrationBase |
630 | 643 |
|
631 | 644 | try: |
632 | 645 | content = template_path.read_text(encoding="utf-8") |
@@ -730,7 +743,7 @@ def _load_project_script_type(project_root: Path) -> str: |
730 | 743 | """ |
731 | 744 | default = "ps" if platform.system().lower().startswith("win") else "sh" |
732 | 745 | try: |
733 | | - from ._init_options import load_init_options |
| 746 | + from .._init_options import load_init_options |
734 | 747 | opts = load_init_options(project_root) |
735 | 748 | if isinstance(opts, dict): |
736 | 749 | script = opts.get("script") |
@@ -885,7 +898,7 @@ def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> |
885 | 898 | #17). Malformed-but-skipable entries are already dropped by |
886 | 899 | ``_normalize_handlers``. |
887 | 900 | """ |
888 | | - from .extensions import ValidationError |
| 901 | + from ..extensions import ValidationError |
889 | 902 |
|
890 | 903 | if event_name not in CANONICAL_EVENTS: |
891 | 904 | raise ValidationError( |
@@ -1039,7 +1052,7 @@ def _disabled_extension_ids(project_root: Path) -> set[str]: |
1039 | 1052 | disabled extension after its config file was preserved (e.g. a JSONC |
1040 | 1053 | parse failure that skipped native cleanup). |
1041 | 1054 | """ |
1042 | | - from .extensions import ExtensionRegistry |
| 1055 | + from ..extensions import ExtensionRegistry |
1043 | 1056 |
|
1044 | 1057 | exts_dir = project_root / ".specify" / "extensions" |
1045 | 1058 | disabled_ids: set[str] = set() |
@@ -1075,7 +1088,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents: |
1075 | 1088 | obsolete name and ``_find_command_template`` could not match it, leaving |
1076 | 1089 | the hook silently inert. |
1077 | 1090 | """ |
1078 | | - from .extensions import ExtensionManager |
| 1091 | + from ..extensions import ExtensionManager |
1079 | 1092 |
|
1080 | 1093 | events: ResolvedEvents = {} |
1081 | 1094 | exts_dir = project_root / ".specify" / "extensions" |
@@ -1143,7 +1156,7 @@ def _resolve_interpreter(project_root: Path) -> str: |
1143 | 1156 | commands honor the project venv and never hard-code ``python3`` (which is |
1144 | 1157 | commonly absent on Windows even when ``py.exe``/``python.exe`` exist). |
1145 | 1158 | """ |
1146 | | - from .integrations.base import IntegrationBase |
| 1159 | + from ..integrations.base import IntegrationBase |
1147 | 1160 | return IntegrationBase.resolve_python_interpreter(project_root) |
1148 | 1161 |
|
1149 | 1162 |
|
@@ -1686,9 +1699,9 @@ def _other_event_integrations_reference_dispatcher( |
1686 | 1699 | for the dispatcher path so uninstalling one multi-install event-capable |
1687 | 1700 | integration doesn't delete the dispatcher the others still rely on. |
1688 | 1701 | """ |
1689 | | - from .integrations._helpers import _read_integration_json |
1690 | | - from .integrations.manifest import IntegrationManifest |
1691 | | - from .integration_state import installed_integration_keys |
| 1702 | + from ..integrations._helpers import _read_integration_json |
| 1703 | + from ..integrations.manifest import IntegrationManifest |
| 1704 | + from ..integration_state import installed_integration_keys |
1692 | 1705 |
|
1693 | 1706 | state = _read_integration_json(project_root) |
1694 | 1707 | for key in installed_integration_keys(state): |
@@ -1763,7 +1776,7 @@ def remove_integration_events( |
1763 | 1776 |
|
1764 | 1777 | def events_stale_exclusions(integration_key: str) -> set[str]: |
1765 | 1778 | """Return project-relative paths to protect from stale cleanup.""" |
1766 | | - from .integrations import get_integration |
| 1779 | + from ..integrations import get_integration |
1767 | 1780 | integration = get_integration(integration_key) |
1768 | 1781 | if not integration: |
1769 | 1782 | return set() |
@@ -1813,10 +1826,10 @@ def refresh_integration_events(project_root: Path) -> None: |
1813 | 1826 | the lifecycle command can't claim the extension was fully deactivated |
1814 | 1827 | while a stale native hook may still be active (R3). |
1815 | 1828 | """ |
1816 | | - from .integrations import get_integration |
1817 | | - from .integrations._helpers import _read_integration_json, _resolve_integration_options |
1818 | | - from .integrations.manifest import IntegrationManifest |
1819 | | - from .integration_state import installed_integration_keys |
| 1829 | + from ..integrations import get_integration |
| 1830 | + from ..integrations._helpers import _read_integration_json, _resolve_integration_options |
| 1831 | + from ..integrations.manifest import IntegrationManifest |
| 1832 | + from ..integration_state import installed_integration_keys |
1820 | 1833 |
|
1821 | 1834 | state = _read_integration_json(project_root) |
1822 | 1835 | failures: list[tuple[str, str]] = [] |
@@ -1863,7 +1876,7 @@ def refresh_integration_events(project_root: Path) -> None: |
1863 | 1876 |
|
1864 | 1877 | def validate_events(data: dict[str, Any]) -> None: |
1865 | 1878 | """Validate ``events`` field in extension manifest data.""" |
1866 | | - from .extensions import ValidationError |
| 1879 | + from ..extensions import ValidationError |
1867 | 1880 |
|
1868 | 1881 | events = data.get("events") |
1869 | 1882 | if "events" in data and not isinstance(events, dict): |
@@ -1911,7 +1924,7 @@ def has_events(data: dict[str, Any]) -> bool: |
1911 | 1924 |
|
1912 | 1925 | def _toml_quote(value: str) -> str: |
1913 | 1926 | """Render *value* as a TOML basic string via the shared escaper.""" |
1914 | | - from ._toml_string import escape_toml_basic |
| 1927 | + from .._toml_string import escape_toml_basic |
1915 | 1928 | return escape_toml_basic(value) |
1916 | 1929 |
|
1917 | 1930 |
|
@@ -2547,7 +2560,7 @@ def _ensure_safe_destination(dst: Path) -> None: |
2547 | 2560 | outside the repo would redirect writes to external files). Then validates |
2548 | 2561 | lexical containment so ``..`` traversal is also rejected. |
2549 | 2562 | """ |
2550 | | - from .agents import CommandRegistrar |
| 2563 | + from ..agents import CommandRegistrar |
2551 | 2564 |
|
2552 | 2565 | # Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside) |
2553 | 2566 | # cannot be silently followed. Mirrors IntegrationManifest.record_existing. |
@@ -2619,3 +2632,10 @@ def _has_marker(entry: Any) -> bool: |
2619 | 2632 | if isinstance(inner, list): |
2620 | 2633 | return any(_has_marker(h) for h in inner) |
2621 | 2634 | return False |
| 2635 | + |
| 2636 | + |
| 2637 | +def register(app: typer.Typer) -> None: |
| 2638 | + """Attach the event command group to the root application.""" |
| 2639 | + from . import command_run # noqa: F401 -- registers handler |
| 2640 | + |
| 2641 | + app.add_typer(event_app, name="event") |
0 commit comments