Skip to content

Add workflow step catalog — community-installable step types#2394

Open
Copilot wants to merge 17 commits into
mainfrom
copilot/add-community-installable-steps
Open

Add workflow step catalog — community-installable step types#2394
Copilot wants to merge 17 commits into
mainfrom
copilot/add-community-installable-steps

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 28, 2026

The workflow engine shipped with a dynamic STEP_REGISTRY but no distribution mechanism for community-authored step types. This adds a full catalog system for discovering, installing, and managing custom step types, following the same patterns as the workflow/extension catalogs.

New classes (workflows/catalog.py)

  • StepRegistry — persists installed custom steps in .specify/workflows/steps/step-registry.json; validates JSON shape on load and falls back to a clean default on corruption or unreadable files
  • StepCatalog — multi-source catalog stack with SHA256-based caching; resolves SPECKIT_STEP_CATALOG_URL env var → .specify/step-catalogs.yml~/.specify/step-catalogs.yml → built-in defaults (step-catalog.json + step-catalog.community.json)
  • StepCatalogError / StepValidationError / StepCatalogEntry supporting types

Dynamic step loading (workflows/__init__.py)

load_custom_steps(project_root) scans .specify/workflows/steps/, dynamically imports each package's __init__.py, finds the StepBase subclass matching the declared type_key, and registers it into STEP_REGISTRY. Each step directory is imported as a proper Python package (with submodule_search_locations set and pre-registered in sys.modules) so that relative imports within a step package (e.g. from .helpers import ...) work correctly. type_key values are sanitized to valid Python identifiers and suffixed with an 8-char SHA-256 hash of the original type_key (e.g. test-custom_speckit_custom_step_test_custom_<hash>) — the hash suffix makes module names collision-resistant when different type_key values produce the same sanitized form (e.g. "a-b" and "a_b"). Broken packages are silently skipped. Dynamic imports are intentionally deferred to workflow execution time — load_custom_steps() is called in the workflow run and workflow resume CLI entrypoints before engine validation/execution so installed step types are usable. CLI metadata commands (list, info) read from StepRegistry and step.yml only and never execute installed step code.

CLI surface (specify workflow step …)

specify workflow step list              # built-in + installed custom types
specify workflow step add <id>          # fetch step.yml + __init__.py (+ extra_files), validate, install
specify workflow step remove <id>       # delete package dir + registry entry
specify workflow step search [query]    # search across all configured catalogs
specify workflow step info <id>         # detail view (built-in or custom)
specify workflow step catalog list      # show active catalog sources
specify workflow step catalog add <url> # add catalog to .specify/step-catalogs.yml
specify workflow step catalog remove <n># remove by index

add validates that the downloaded step.yml's type_key matches the catalog ID and that all fetches use HTTPS before writing anything to disk. Catalog entries may include an optional extra_files dict (relative-path → URL) to install additional package files (e.g. helper modules) alongside step.yml and __init__.py — each extra file is HTTPS-validated, path-traversal-checked against the resolved staging directory, and downloaded before the atomic rename. Downloads happen in a staging directory under steps_base_dir and are moved into place atomically via os.rename() only after all validation passes — a transient failure never corrupts or deletes any pre-existing data, and install is refused if the target directory already exists.

remove allows cleaning up an orphaned step directory (e.g. after a registry reset due to corruption) even when the registry entry is missing — it prints a warning and removes the directory so a fresh install can be attempted. It still errors if neither the directory nor the registry entry exists.

Catalog files

  • workflows/step-catalog.json — official catalog (empty, ready for entries)
  • workflows/step-catalog.community.json — community catalog (empty, ready for entries)

Robustness & security hardening

  • Path traversal guard: step_id is validated against the resolved base directory in both add and remove before any filesystem operation. extra_files paths are validated by resolving both the destination and the staging base directory before relative_to(), correctly handling symlinks.
  • Reserved ID guard: step add rejects IDs that collide with internal names under .specify/workflows/steps/ (dotfiles, .cache, step-registry.json) to prevent corrupting cache or registry state.
  • Collision guard: step add rejects IDs that collide with built-in or already-installed step types.
  • Atomic install: step add downloads and validates in a staging temp dir under steps_base_dir (same filesystem as the destination), then atomically renames into place via os.rename(); install is refused if the target directory already exists.
  • URL validation: All URL validation checks parsed.hostname (not just parsed.netloc or scheme alone) to correctly reject hostless URLs such as https://:80/path — enforced in _validate_catalog_url(), _safe_fetch(), and the fetch-time _validate_url() helpers in both WorkflowCatalog and StepCatalog.
  • Authenticated fetches: _safe_fetch() (used by workflow step add) and StepCatalog._fetch_single_catalog() route through specify_cli.authentication.http.open_url() to support auth.json-based credentials and safe redirect auth stripping, consistent with all other network fetches in the CLI.
  • Cache resilience: catalog cache writes are wrapped in try/except OSError so a read-only filesystem never aborts a successful HTTP fetch.
  • Registry resilience: StepRegistry._load() validates the on-disk JSON shape and catches OSError/UnicodeError, falling back to a clean default on any read failure.
  • Catalog config resilience: StepCatalog.add_catalog() and remove_catalog() wrap yaml.safe_load() in try/except (yaml.YAMLError, OSError, UnicodeDecodeError) so a malformed or unreadable .specify/step-catalogs.yml raises a clean StepValidationError instead of an unhandled exception.
  • Empty YAML tolerance: StepCatalog.add_catalog() treats an empty .specify/step-catalogs.yml as an empty mapping rather than a corruption error.
  • Non-dict extra_files tolerance: A warning is printed when a catalog entry's extra_files field is present but not a mapping, and the field is safely ignored.

Tests

35+ tests across TestStepRegistryCustom, TestStepCatalog, TestLoadCustomSteps, and TestWorkflowStepRemoveCLI covering CRUD, catalog resolution (env var / project / user / default), URL validation, search, dynamic loading edge cases, registry shape validation, unreadable-file fallback, empty YAML initialization, module name sanitization and collision-resistance for hyphenated type keys, package-style relative imports within custom step packages, and step remove CLI behavior (registered, orphaned, and fully-missing cases). The chmod-based unreadability test is skipped on Windows.

Copilot AI requested review from Copilot and removed request for Copilot April 28, 2026 17:48
Copilot AI linked an issue Apr 28, 2026 that may be closed by this pull request
Copilot AI requested review from Copilot and removed request for Copilot April 28, 2026 17:59
Comment thread tests/test_workflows.py Fixed
Copilot AI changed the title [WIP] Add step catalog and CLI for community-installable step types Add workflow step catalog — community-installable step types Apr 28, 2026
Copilot AI requested a review from mnriem April 28, 2026 18:01
…e-effect'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 28, 2026 19:18
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

Adds a catalog/registry system for community-installable workflow step types, plus CLI commands to discover, install, and manage them alongside built-in steps.

Changes:

  • Introduces StepRegistry and StepCatalog (multi-source resolution + SHA256 cache) for step type distribution/management.
  • Adds dynamic filesystem-based loading of installed custom step packages into STEP_REGISTRY.
  • Expands CLI with specify workflow step … and adds tests and initial (empty) catalog JSON files.
Show a summary per file
File Description
workflows/step-catalog.json Adds the built-in “official” step catalog scaffold (currently empty).
workflows/step-catalog.community.json Adds the built-in “community” step catalog scaffold (currently empty).
src/specify_cli/workflows/catalog.py Implements StepRegistry + StepCatalog with config resolution and caching.
src/specify_cli/workflows/init.py Adds load_custom_steps(project_root) dynamic import/registration for installed step packages.
src/specify_cli/init.py Adds Typer subcommands for listing/searching/installing/removing steps and managing step catalogs.
tests/test_workflows.py Adds unit tests covering registry CRUD, catalog resolution/validation, search/info, and dynamic loading behavior.

Copilot's findings

Tip

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

Comments suppressed due to low confidence (1)

src/specify_cli/init.py:5555

  • workflow_step_remove() builds step_dir from unvalidated step_id and then shutil.rmtree(step_dir). A malicious value like ../... could delete arbitrary directories outside .specify/workflows/steps. Add the same resolved-path relative_to() guard used by workflow_remove/workflow_add before performing deletions.
    step_dir = project_root / ".specify" / "workflows" / "steps" / step_id
    if step_dir.exists():
        import shutil
        shutil.rmtree(step_dir)

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

Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py Outdated
Comment thread src/specify_cli/workflows/catalog.py Outdated
Comment thread src/specify_cli/__init__.py Outdated
… failed-to-load display

- Add resolve()+relative_to() path traversal guards in workflow_step_add and
  workflow_step_remove to prevent directory escape via step_id
- Harden _is_url_cache_valid in both StepCatalog and WorkflowCatalog to
  coerce fetched_at to float and catch TypeError/ValueError
- Check STEP_REGISTRY and StepRegistry before installing to prevent
  collisions with built-in step types or already-installed steps
- Show 'Custom (installed, failed to load)' section in workflow step list
  for steps in the registry that failed to load into STEP_REGISTRY
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/workflows/catalog.py:1021

  • StepCatalog.add_catalog()/remove_catalog() read YAML without handling yaml.YAMLError/OSError; those exceptions will bubble past the CLI handlers (which only catch StepValidationError) and produce a stack trace. Wrap YAML reads in try/except and re-raise as StepValidationError so the CLI consistently reports a user-facing error.
    def remove_catalog(self, index: int) -> str:
        """Remove a catalog source by index (0-based). Returns the removed name."""
        config_path = self.project_root / ".specify" / "step-catalogs.yml"
        if not config_path.exists():
            raise StepValidationError("No step catalog config file found.")

        data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        if not isinstance(data, dict):
            raise StepValidationError(
                "Catalog config file is corrupted (expected a mapping)."
            )
  • Files reviewed: 6/6 changed files
  • Comments generated: 2

Comment on lines +594 to +603
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError):
return {"schema_version": self.SCHEMA_VERSION, "steps": {}}
return {"schema_version": self.SCHEMA_VERSION, "steps": {}}

Comment on lines +972 to +980
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
data = raw

@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 6, 2026

@copilot apply changes based on the comments in this thread

Copilot AI requested review from Copilot and removed request for Copilot May 6, 2026 22:47
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 6, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread in commits e311afb and df061e0:

  • StepRegistry._load() — now validates the loaded JSON is a dict with a dict steps field (resets malformed steps to {}), and catches OSError/UnicodeError so unreadable files fall back to the default structure instead of crashing CLI commands.
  • StepCatalog.add_catalog() — changed yaml.safe_load(...) to yaml.safe_load(...) or {} so an empty .specify/step-catalogs.yml is treated as an empty mapping and can be initialized by the command.

Three new tests cover these cases: malformed steps key, unreadable registry file (with functional-after-fallback assertion), and empty YAML config file.

@mnriem mnriem marked this pull request as ready for review May 6, 2026 22:48
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: 6/6 changed files
  • Comments generated: 2

Comment on lines +111 to +115
spec = _importlib_util.spec_from_file_location(
f"_speckit_custom_step_{type_key}", init_py
)
if spec is None or spec.loader is None:
continue
except ValueError:
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
raise typer.Exit(1)

@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 8, 2026

@copilot apply changes based on the comments in this thread

Copilot AI requested review from Copilot and removed request for Copilot May 8, 2026 19:16
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 8, 2026

@copilot apply changes based on the comments in this thread

Applied both changes in commits b56e6fc and a2a2c70:

  • load_custom_steps() — proper package imports: Moved import re and import sys outside the loop (to the top of the function). The step directory is now imported as a proper Python package: spec_from_file_location(..., submodule_search_locations=[str(step_dir)]), module.__package__ is set, and the module is registered in sys.modules before exec_module so that relative imports (e.g. from .helpers import ...) resolve correctly. The module name is sanitized via re.sub(r"[^A-Za-z0-9_]", "_", type_key) so type_keys like test-custom produce a valid Python identifier (_speckit_custom_step_test_custom). If exec_module raises, the entry is cleaned from sys.modules.
  • workflow_step_add() — reserved ID guard: Added a _RESERVED_STEP_IDS frozenset at module level (.cache, step-registry.json) and reject any step_id that starts with . or is in that set, before creating any staging directory.
  • Added two new tests: test_module_name_sanitized_for_hyphenated_type_key and test_package_relative_import in TestLoadCustomSteps.

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: 6/6 changed files
  • Comments generated: 3

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

mnriem commented May 11, 2026

@copilot apply changes based on the comments in this thread

Copilot AI requested review from Copilot and removed request for Copilot May 11, 2026 15:35
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot apply changes based on the comments in this thread

Applied all three changes from the review thread:

  • Collision-resistant module names (workflows/__init__.py): Synthetic module names now include an 8-char SHA-256 hash of the original type_key (e.g. _speckit_custom_step_a_b_<hash>), so "a-b" and "a_b" produce different module names even though they sanitize to the same form. Updated the two tests that asserted exact module names and added a new test_module_name_collision_resistance test.

  • Multi-file step install (__init__.py): workflow step add now supports an optional extra_files dict in catalog entries (mapping relative path → URL). Each extra file is downloaded via _safe_fetch (HTTPS-validated) and written into the staging directory before the atomic rename. Path traversal is blocked by resolving both the destination and the base directory before calling relative_to() (handles symlinks). A warning is emitted when extra_files is present but is not a mapping.

  • step remove registry fallback (__init__.py): workflow step remove no longer requires a registry entry to proceed. When the step directory exists but the registry entry is missing (e.g. after a corruption-triggered reset), it prints a warning and removes the orphaned directory so a fresh install can be attempted. It still errors if neither the directory nor the registry entry exists. Three new CLI-level tests cover all cases.

Commits: 8d91965, 7432fe5

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 (3)

src/specify_cli/init.py:6034

  • The current path-traversal guard allows step_id values that contain path separators (e.g. foo/bar), which would install into nested directories under .specify/workflows/steps/ while still registering the literal step_id string. It’s safer to enforce that step_id is a single path segment (no / or \\) and reject anything where Path(step_id).name != step_id (or similar).
    steps_base_dir = (project_root / ".specify" / "workflows" / "steps").resolve()
    step_dir = (steps_base_dir / step_id).resolve()
    try:
        step_dir.relative_to(steps_base_dir)
    except ValueError:

src/specify_cli/init.py:6215

  • workflow_step_remove will call shutil.rmtree(step_dir) for any existing path. If the user passes a reserved/internal id like step-registry.json, step_dir is a file (not a directory) and rmtree will raise, causing an unhandled exception; similarly, .cache can be deleted accidentally. Consider applying the same reserved-name / dotfile checks as workflow_step_add, and/or gating the delete with step_dir.is_dir() (and handling file paths explicitly).
    if dir_exists:
        import shutil
        shutil.rmtree(step_dir)

src/specify_cli/workflows/catalog.py:1026

  • StepCatalog.add_catalog() computes max_priority using raw cat.get('priority', 0) values. If an existing step-catalogs.yml contains non-int priorities (e.g. quoted strings), max() can raise TypeError when comparing mixed types. Consider normalizing priorities with int(...) (ignoring/handling invalid values) before computing the max.
        max_priority = max(
            (cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
            default=0,
        )
  • Files reviewed: 6/6 changed files
  • Comments generated: 2

Comment on lines +6008 to +6010
from urllib.parse import urlparse
from urllib.request import urlopen

Comment on lines +860 to +862
from urllib.parse import urlparse
from urllib.request import urlopen

@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 11, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot apply changes based on the comments in this thread

Applied in commit 4e7490e:

  • _safe_fetch() in __init__.py: Replaced from urllib.request import urlopen + urlopen(...) with from specify_cli.authentication.http import open_url as _open_url + _open_url(...). All existing HTTPS/hostname pre- and post-redirect validation is preserved.
  • StepCatalog._fetch_single_catalog() in workflows/catalog.py: Same swap — urlopen_open_url from the authentication module. The redirect URL check via resp.geturl() is retained.

Copilot finished work on behalf of mnriem May 11, 2026 20:51
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.

[Feature] Workflow Step Catalog — community-installable step types

3 participants