Skip to content

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

Open
Copilot wants to merge 14 commits intomainfrom
copilot/add-community-installable-steps
Open

Add workflow step catalog — community-installable step types#2394
Copilot wants to merge 14 commits intomainfrom
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 (e.g. test-custom_speckit_custom_step_test_custom). 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, 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. 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.

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.
  • 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.
  • 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.

Tests

30+ tests across TestStepRegistryCustom, TestStepCatalog, and TestLoadCustomSteps 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 for hyphenated type keys, and package-style relative imports within custom step packages. 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
…etch and _validate_url

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/73865880-fb25-4061-a43e-4e4b4d1c4de6

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 7, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread in commit bf0af27:

  • workflow_run / workflow_resume: Added load_custom_steps(project_root) before WorkflowEngine construction so installed custom step types are registered into STEP_REGISTRY before validation and execution.
  • _safe_fetch(): Added parsed.hostname check for both the initial URL and any redirect, rejecting hostless URLs (e.g. https://:80/path) before urlopen is called.
  • _validate_url() in _fetch_single_catalog(): Added parsed.hostname check in both StepCatalog and WorkflowCatalog fetch helpers, consistent with _validate_catalog_url().

Copilot finished work on behalf of mnriem May 7, 2026 13:30
@mnriem mnriem requested a review from Copilot May 7, 2026 16:38
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:1042

  • StepCatalog.remove_catalog() also calls yaml.safe_load(...) without handling yaml.YAMLError/OSError/UnicodeError. A corrupted/unreadable config will raise and skip the StepValidationError path expected by the CLI command. Consider mirroring _load_catalog_config()'s error wrapping here too.
        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 +993 to +1000
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise StepValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
data = raw
Comment thread src/specify_cli/__init__.py Outdated
Comment on lines +5526 to +5530
# Remove any pre-existing step_dir before the move; shutil.move would
# otherwise place tmp_path as a *subdirectory* of an existing target.
if step_dir.exists():
shutil.rmtree(step_dir)
shutil.move(str(tmp_path), str(step_dir))
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 7, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 7, 2026

@copilot apply changes based on the comments in this thread

Applied both changes from the review thread in commit c310420:

  • add_catalog() / remove_catalog(): Wrapped yaml.safe_load() calls in try/except (yaml.YAMLError, OSError, UnicodeDecodeError) so a malformed or unreadable .specify/step-catalogs.yml raises a clean StepValidationError with a helpful message instead of propagating an unhandled exception.
  • workflow_step_add() — atomic install: Replaced shutil.move() (which could fail non-atomically across filesystems) with:
    • An upfront refusal when step_dir already exists (with a clear error pointing to specify workflow step remove)
    • Staging the temp dir under steps_base_dir (same filesystem as the destination)
    • os.rename() for a truly atomic POSIX rename
    • finally cleans up the temp dir if os.rename() hasn't moved it yet

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:721

  • When step-catalogs.yml exists but has an empty catalogs: list, _load_catalog_config() returns None, which makes get_active_catalogs() fall through to the user-level config. This breaks the documented precedence (project config should override user config). Consider treating an empty project config as an explicit override (e.g., return an empty list sentinel / short-circuit to built-in defaults).
        catalogs_data = data.get("catalogs", [])
        if not catalogs_data:
            return None
  • Files reviewed: 6/6 changed files
  • Comments generated: 3

Comment on lines +714 to +718
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise StepValidationError(
f"Failed to read catalog config {config_path}: {exc}"
)
Comment on lines 336 to +340
)
if not parsed.hostname:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
Comment on lines +5511 to +5515
meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {}
except Exception as exc:
console.print(f"[red]Error:[/red] Invalid step.yml: {exc}")
raise typer.Exit(1)

@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented May 7, 2026

@copilot apply changes based on the comments in this thread and also resolve conflicts

Copilot AI and others added 2 commits May 7, 2026 18:30
…p_add; fix WorkflowCatalog hostname validation

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
…-installable-steps

# Conflicts:
#	src/specify_cli/__init__.py

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 7, 2026

@copilot apply changes based on the comments in this thread and also resolve conflicts

Applied all changes from the review thread and resolved the merge conflict in commit b3d0cf6:

  • WorkflowCatalog._validate_catalog_url(): Changed parsed.netlocparsed.hostname to consistently reject hostless URLs like https://:80/path at config-add time (matching StepCatalog's existing implementation).
  • StepCatalog._load_catalog_config(): Added isinstance(data, dict) check after yaml.safe_load() so a YAML list/string at the root raises StepValidationError instead of crashing with AttributeError.
  • workflow_step_add(): Added isinstance(meta, dict) and isinstance(step_meta, dict) guards after parsing step.yml, giving clear CLI error messages for invalid YAML shapes.
  • Merge conflict resolved: workflow_run/workflow_resume now use _require_specify_project() (introduced in main) followed by load_custom_steps(project_root).

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.

Copilot finished work on behalf of mnriem May 8, 2026 19:17
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