Skip to content

fix(cli): harden extension registration and discovery workflows#2499

Merged
mnriem merged 31 commits into
github:mainfrom
DyanGalih:fix/extension-registration
May 13, 2026
Merged

fix(cli): harden extension registration and discovery workflows#2499
mnriem merged 31 commits into
github:mainfrom
DyanGalih:fix/extension-registration

Conversation

@DyanGalih
Copy link
Copy Markdown
Contributor

@DyanGalih DyanGalih commented May 8, 2026

This PR hardens the extension registration process in the Spec Kit CLI.

Key Changes

  1. CLI Hardening: Updated HookExecutor and ExtensionManager to automatically maintain an installed list in .specify/extensions.yml, ensuring robust extension discovery without requiring separate template changes.
  2. Unit Tests: Added comprehensive tests in tests/test_extension_registration.py and tests/test_extension_update_hardening.py covering corrupted config, rollback, and edge cases.

Copilot AI review requested due to automatic review settings May 8, 2026 10:41
@DyanGalih DyanGalih requested a review from mnriem as a code owner May 8, 2026 10:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens how the Spec Kit CLI tracks installed extensions by ensuring hook registration/unregistration updates an installed list in .specify/extensions.yml, and adds tests to validate the intended behavior. It also updates metadata for a couple of community extensions in the catalog.

Changes:

  • Add installed list maintenance to HookExecutor (register on hook registration; unregister on hook removal).
  • Introduce unit tests covering registration sorting/idempotency and initialization when installed is missing.
  • Bump versions + download URLs (and updated_at) for two entries in extensions/catalog.community.json.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
tests/test_extension_registration.py Adds new unit tests validating installed list behavior in .specify/extensions.yml.
src/specify_cli/extensions.py Updates hook registration/unregistration to maintain an installed extension list; adds helper methods.
extensions/catalog.community.json Updates version/download metadata for architecture-guard and memory-md.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/specify_cli/extensions.py
Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py
Comment thread tests/test_extension_registration.py Outdated
@DyanGalih
Copy link
Copy Markdown
Contributor Author

Addressing feedback:

  1. Defensive Logic: Normalizing config/list types in and to handle corrupted YAML.
  2. Cleanup Flow: Moved to the start of to ensure cleanup even if hooks are missing.
  3. Template Clarification: The orchestration templates reside in and have been updated in a companion change to match this CLI hardening.
  4. Clean Code: Removed unused import in tests.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/specify_cli/extensions.py:2562

  • register_hooks() now always calls register_extension(), but the subsequent hook registration path still assumes config is a dict and that config["hooks"] is a dict. Since get_project_config() can return any YAML type and existing configs could have a non-mapping hooks value, consider hardening here too (e.g., return defaults/coerce when config or config["hooks"] isn’t a mapping) so installs don’t crash on corrupted extensions.yml.
        # Always ensure the extension is in the installed list
        self.register_extension(manifest.id)

        if not hasattr(manifest, "hooks") or not manifest.hooks:
            return

        config = self.get_project_config()

        # Ensure hooks dict exists
        if "hooks" not in config:
            config["hooks"] = {}

Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py
Comment thread src/specify_cli/extensions.py Outdated
Comment thread tests/test_extension_registration.py
…ive unregister_hooks tests

- Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root)
- Add 5 comprehensive tests for unregister_hooks() workflow:
  * Full workflow with hooks + installed list removal
  * Resilience when config has no 'hooks' key
  * Corrupted YAML handling
  * Multiple extension scenarios
  * All 11 tests passing
Copy link
Copy Markdown
Contributor Author

@DyanGalih DyanGalih left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All feedback incorporated. Ready for approval! 🎉

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py
Comment thread src/specify_cli/extensions.py
Comment thread src/specify_cli/extensions.py
Comment thread tests/test_extension_registration.py
Comment thread tests/test_extension_registration.py
…le null hook values

- register_extension(): filter non-string entries from installed before sort
- register_hooks(): normalize hooks to {} when missing or not a dict
- unregister_hooks(): add isinstance(config, dict) guard before key checks
- unregister_hooks(): coerce null/scalar hook lists to [] before iteration
- tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values
- All 14 tests passing
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Comments suppressed due to low confidence (2)

src/specify_cli/extensions.py:2601

  • register_hooks() assumes config["hooks"][hook_name] is a list of dicts. If extensions.yml is partially corrupted/hand-edited (e.g., hooks: {after_tasks: null} or an existing hook list containing non-dict entries), the existing = [...] if h.get(...) comprehension and later update loop will raise (TypeError on iterating non-list / AttributeError on .get). To keep the hardening goal intact, coerce non-list hook values to [] before iterating/appending and filter to dicts when reading existing hook entries.
        # Register each hook
        for hook_name, hook_config in manifest.hooks.items():
            if hook_name not in config["hooks"]:
                config["hooks"][hook_name] = []

            # Add hook entry
            hook_entry = {
                "extension": manifest.id,
                "command": hook_config.get("command"),
                "enabled": True,
                "optional": hook_config.get("optional", True),
                "prompt": hook_config.get(
                    "prompt", f"Execute {hook_config.get('command')}?"
                ),
                "description": hook_config.get("description", ""),
                "condition": hook_config.get("condition"),
            }

            # Check if already registered
            existing = [
                h
                for h in config["hooks"][hook_name]
                if h.get("extension") == manifest.id
            ]

            if not existing:
                config["hooks"][hook_name].append(hook_entry)
            else:
                # Update existing
                for i, h in enumerate(config["hooks"][hook_name]):
                    if h.get("extension") == manifest.id:
                        config["hooks"][hook_name][i] = hook_entry

src/specify_cli/extensions.py:2546

  • unregister_extension() only removes the ID when config["installed"] is already a list. If extensions.yml has installed: null/string (or a list with non-string values), uninstalling via unregister_hooks() will leave the installed marker behind and won’t repair the invalid shape. Consider normalizing installed here the same way as register_extension() (coerce missing/non-list to [], drop non-strings, and then remove + save if anything changed).
        config = self.get_project_config()

        if not isinstance(config, dict):
            return

        if (
            "installed" in config
            and isinstance(config["installed"], list)
            and extension_id in config["installed"]
        ):
            config["installed"].remove(extension_id)
            self.save_project_config(config)
  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread src/specify_cli/extensions.py
Comment thread tests/test_extension_registration.py
Copilot AI review requested due to automatic review settings May 11, 2026 15:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/extensions.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment thread src/specify_cli/extensions.py Outdated
Comment thread src/specify_cli/__init__.py Outdated
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 12, 2026

Please address Copilot feedback

@mnriem mnriem requested a review from Copilot May 13, 2026 12:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

Comments suppressed due to low confidence (1)

src/specify_cli/extensions.py:2728

  • unregister_hooks() always writes extensions.yml even when no hooks are removed/normalized (e.g., extension has no hooks, or config already has no matching entries). This can cause unnecessary YAML churn and overwrite user formatting. Consider tracking whether any changes were made (removal, coercion, cleanup) and only calling save_project_config() when the config actually changed.
        # Always remove from installed list (Feedback from review)
        self.unregister_extension(extension_id)

        config = self.get_project_config()

        if not isinstance(config, dict):
            config = {}
            # We don't save yet, as there are no hooks to unregister, 
            # but unregister_extension above might have already saved a normalized config.
            return

        if "hooks" not in config or not isinstance(config["hooks"], dict):
            return

        # Remove hooks for this extension
        for hook_name in list(config["hooks"].keys()):
            hook_list = config["hooks"][hook_name]
            if not isinstance(hook_list, list):
                config["hooks"][hook_name] = []
                continue
            config["hooks"][hook_name] = [
                h
                for h in hook_list
                if isinstance(h, dict) and h.get("extension") != extension_id
            ]

        # Clean up empty hook arrays
        config["hooks"] = {
            name: hooks for name, hooks in config["hooks"].items() if hooks
        }

        self.save_project_config(config)

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment thread tests/test_extension_update_hardening.py Outdated
Comment thread tests/test_extension_update_hardening.py
Copilot AI review requested due to automatic review settings May 13, 2026 12:46
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 3

Comment thread src/specify_cli/extensions.py Outdated
Comment thread tests/test_extension_registration.py Outdated
Comment thread src/specify_cli/extensions.py
Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copiot feedback

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/specify_cli/extensions.py Outdated
Comment thread tests/test_extension_registration.py Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/specify_cli/extensions.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new

@mnriem mnriem self-requested a review May 13, 2026 16:49
@mnriem mnriem merged commit 59fdca5 into github:main May 13, 2026
18 of 19 checks passed
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 13, 2026

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants