mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
refactor: move extension command handlers to extensions/_commands.py (PR-7/8) (#3014)
* refactor: move extension command handlers to extensions/_commands.py (PR-7/8) Convert the flat extensions.py module into an extensions/ package and extract all extension_app and catalog_app command handlers plus their private helpers (_resolve_installed_extension, _resolve_catalog_extension, _print_extension_info) out of __init__.py into the new extensions/_commands.py, mirroring the domain-dir layout used for presets/_commands.py (PR-6) and integrations/_commands.py (PR-5). - extensions.py -> extensions/__init__.py (pure rename, 99%); intra-module relative imports bumped from `.x` to `..x` since they reference root siblings. - Root helpers (_require_specify_project, _locate_bundled_extension, load_init_options, _display_project_path) are reached through thin shims that re-fetch from the parent package at call time, so test monkeypatching of specify_cli.<helper> keeps working unchanged. - __init__.py drops ~1444 lines (3511 -> 2067); CLI surface preserved via register(app). No behavior change. Full suite failure set is identical before/after (82 pre-existing env failures, 0 new). * fix(extensions): preserve per-command path in update backup for skills agents Skills agents (extension == "/SKILL.md") name every command file SKILL.md, each in its own per-command subdir (e.g. speckit-plan/SKILL.md). The update backup keyed the backup path on cmd_file.name alone, so all of an agent's skill files collided onto a single backup path — each shutil.copy2 overwrote the previous one, and rollback restored one skill's content over all the others, corrupting or losing the rest. Mirror the real on-disk layout by using cmd_file.relative_to(commands_dir), keeping each backup path unique. This also makes backed_up_command_files values unique so restore copies the correct content back to each command. Add a regression test asserting two distinct skill files survive a backup -> failed-update -> rollback cycle with their own content. * style(extensions): use yaml.safe_dump when writing catalog config The catalog add/remove handlers wrote the integration catalog config with yaml.dump. Switch to yaml.safe_dump to align with the SafeDumper used by the presets commands and to refuse emitting !!python/object tags if a non-basic value ever reaches the config dict. Output is unchanged for the current basic-type payload (str/int/bool/dict/ list) — this is a defensive/consistency change, not a behavioral fix. * fix(extensions): correct _print_cli_warning import path in skill registration register_enabled_extensions_for_agent imported _print_cli_warning from `.` (the extensions package), but the helper lives in the parent specify_cli package. The wrong level raised ImportError inside the error handlers, aborting extension/skill registration on the first failure instead of warning and continuing. Use `..` to match the other parent-package imports. * fix(extensions): escape untrusted values in Rich markup output User-provided arguments and extension/catalog metadata (names, descriptions, versions, IDs, paths) were interpolated into Rich markup strings without escaping. Values containing markup sequences (e.g. [red]...) would be parsed as markup, allowing output injection that could corrupt or mislead CLI messages. Wrap all such interpolations with rich.markup.escape across the extension/catalog command handlers: list, search, info (_print_extension_info), add (including --dev paths), remove, enable, disable, set-priority, update, and the ambiguous-match resolvers (error strings and Table rows). Reuse the already-computed safe_extension where available. Escaping is a no-op for benign strings, so normal output is unchanged. * Prevent Rich markup injection in extension CLI output User-controlled catalog URLs and extension IDs are rendered through Rich-enabled console paths, so every remaining output-only interpolation now escapes markup while leaving stored values and filesystem behavior unchanged. Regression tests cover catalog add, install hints, remove hints, and state command messages with bracketed markup-like values. * Prevent markup injection from exception text Rich markup remains enabled for styled CLI messages, so exception text and config path labels must be escaped before rendering. YAML parser errors, URL validation failures, download errors, and extension validation errors can include user-controlled catalog or manifest values. Constraint: Preserve existing exception handling and user-facing error paths Rejected: Disable Rich markup for these messages | existing output intentionally uses markup for labels and styling Confidence: high Scope-risk: narrow Directive: Escape user-controlled exception text before interpolating into Rich-rendered strings Tested: .venv/bin/python -m pytest tests/test_extensions.py -q Co-authored-by: OmX <omx@oh-my-codex.dev> * Prevent path and manifest review regressions Catalog path labels are rendered through Rich markup and downloaded update manifests are trusted long enough to validate extension IDs. Escape displayed project paths before rendering, and reject non-mapping extension.yml payloads before ID validation so bad archives fail with a clear rollback reason. --------- Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,11 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
from ._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from ._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from .catalogs import CatalogStackBase
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from ..catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from ..catalogs import CatalogStackBase
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
|
||||
{
|
||||
@@ -905,7 +905,7 @@ class ExtensionManager:
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import (
|
||||
from .. import (
|
||||
_print_cli_warning,
|
||||
load_init_options,
|
||||
resolve_active_skills_dir,
|
||||
@@ -948,7 +948,7 @@ class ExtensionManager:
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return _ensure_usable(skills_dir)
|
||||
|
||||
from .agents import CommandRegistrar
|
||||
from ..agents import CommandRegistrar
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
|
||||
@@ -985,9 +985,9 @@ class ExtensionManager:
|
||||
if not skills_dir:
|
||||
return []
|
||||
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
from .. import load_init_options
|
||||
from ..agents import CommandRegistrar
|
||||
from ..integrations import get_integration
|
||||
|
||||
written: List[str] = []
|
||||
opts = load_init_options(self.project_root)
|
||||
@@ -1201,7 +1201,7 @@ class ExtensionManager:
|
||||
shutil.rmtree(skill_subdir)
|
||||
else:
|
||||
# Fallback: scan all possible agent skills directories
|
||||
from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||
from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||
|
||||
candidate_dirs: set[Path] = set()
|
||||
for cfg in AGENT_CONFIG.values():
|
||||
@@ -1616,7 +1616,7 @@ class ExtensionManager:
|
||||
# Resolve the skills directory for the specific agent so cleanup is
|
||||
# agent-scoped and does not depend on the currently-active agent in
|
||||
# init-options. Use the same helper that extension install uses.
|
||||
from . import _get_skills_dir as resolve_skills_dir
|
||||
from .. import _get_skills_dir as resolve_skills_dir
|
||||
|
||||
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
|
||||
|
||||
@@ -1692,7 +1692,7 @@ class ExtensionManager:
|
||||
if not agent_name:
|
||||
return
|
||||
|
||||
from . import load_init_options
|
||||
from .. import load_init_options
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
|
||||
@@ -1750,7 +1750,7 @@ class ExtensionManager:
|
||||
# Skills are a companion artifact. If command registration
|
||||
# already succeeded, still persist it so later cleanup can
|
||||
# find those command files.
|
||||
from . import _print_cli_warning
|
||||
from .. import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"register extension skills for",
|
||||
@@ -1775,7 +1775,7 @@ class ExtensionManager:
|
||||
except Exception as ext_err:
|
||||
# Best-effort per extension: warn and move on so a single bad
|
||||
# extension cannot silently drop the others. See #2950.
|
||||
from . import _print_cli_warning
|
||||
from .. import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"register extension artifacts for",
|
||||
@@ -1882,31 +1882,31 @@ class CommandRegistrar:
|
||||
"""
|
||||
|
||||
# Re-export AGENT_CONFIGS at class level for direct attribute access
|
||||
from .agents import CommandRegistrar as _AgentRegistrar
|
||||
from ..agents import CommandRegistrar as _AgentRegistrar
|
||||
|
||||
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
|
||||
|
||||
def __init__(self):
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
self._registrar = _Registrar()
|
||||
|
||||
# Delegate static/utility methods
|
||||
@staticmethod
|
||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
return _Registrar.parse_frontmatter(content)
|
||||
|
||||
@staticmethod
|
||||
def render_frontmatter(fm: dict) -> str:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
return _Registrar.render_frontmatter(fm)
|
||||
|
||||
@staticmethod
|
||||
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
_Registrar.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
@@ -2857,7 +2857,7 @@ class HookExecutor:
|
||||
instance to avoid repeated filesystem reads during hook rendering.
|
||||
"""
|
||||
if self._init_options_cache is None:
|
||||
from . import load_init_options
|
||||
from .. import load_init_options
|
||||
|
||||
payload = load_init_options(self.project_root)
|
||||
self._init_options_cache = payload if isinstance(payload, dict) else {}
|
||||
@@ -2896,7 +2896,7 @@ class HookExecutor:
|
||||
if kimi_skill_mode and skill_name:
|
||||
return f"/skill:{skill_name}"
|
||||
if cline_mode:
|
||||
from .integrations.cline import format_cline_command_name
|
||||
from ..integrations.cline import format_cline_command_name
|
||||
|
||||
return f"/{format_cline_command_name(command_id)}"
|
||||
|
||||
1556
src/specify_cli/extensions/_commands.py
Normal file
1556
src/specify_cli/extensions/_commands.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user