Skip to content

Bug: preset commands with 3-part names (speckit.<domain>.<cmd>) silently skipped during registration #2862

@markuswondrak

Description

@markuswondrak

Summary

Preset commands that follow the speckit.<domain>.<cmd> naming convention are silently skipped during installation if .specify/extensions/<domain>/ does not exist as a directory. This means any preset that introduces new commands under a three-part speckit.*.* namespace can never register them — the filter intended for extension command overrides is incorrectly applied to preset-provided commands.

Affected preset: spec-kit-extended-flow — all three of its commands (speckit.extendedflow.reviewer, speckit.extendedflow.documentation, speckit.extendedflow.documentation-init) are dropped.

Steps to Reproduce

  1. Install any preset that declares new commands with a three-part speckit.<domain>.<cmd> name, where <domain> is not an installed extension ID.
  2. Run specify preset add --dev <path> or install from a ZIP.
  3. Check the agent command directories (e.g. .opencode/commands/, .claude/commands/).

Expected: The preset's command files are written to all detected agent directories.
Actual: No command files are created. specify preset add reports success but silently drops all three-part commands.

Running the workflow later fails with:

Error: Command not found: "speckit.extendedflow.reviewer"

Root Cause

Three locations in src/specify_cli/presets.py share identical filter logic:

Location Method Line (v0.9.4)
Command registration _register_commands() 618–629
Skill registration _register_skills() 1237–1247
Post-install reconciliation install_from_directory() 1589–1602

All three contain:

# Filter out extension command overrides if the extension isn't installed.
extensions_dir = self.project_root / ".specify" / "extensions"
filtered = []
for cmd in command_templates:
    parts = cmd["name"].split(".")
    if len(parts) >= 3 and parts[0] == "speckit":
        ext_id = parts[1]
        if not (extensions_dir / ext_id).is_dir():
            continue          # ← silently skips the command
    filtered.append(cmd)

The intent is correct for extension command overrides — commands a preset provides to wrap/replace a command that lives inside an extension (e.g. speckit.git.feature should only register when the git extension is installed). But the filter cannot distinguish:

  • Extension command overrides — wrap an existing extension command; should be skipped when the extension is absent.
  • Preset-provided commands — brand-new commands introduced by the preset; should always register.

Both use three-part names, so all three-part names are gated on an extension directory that will never exist for a purely preset-provided command.

Impact

  • Any preset that introduces new agent commands under a speckit.<domain>.<cmd> namespace is broken out of the box.
  • Failure is completely silent — no warning, no error, install reports success.
  • All downstream workflow steps that invoke those commands fail at runtime.

Workaround (until upstream fix)

Create an empty stub directory to satisfy the filter, then reinstall:

mkdir -p .specify/extensions/extendedflow
specify preset remove spec-kit-extended-flow
specify preset add --dev ../spec-kit-extended-flow

The filter only calls is_dir() — an empty directory passes.

Suggested Fix

The filter needs to distinguish extension command overrides from preset-provided commands. One clean approach: check whether the <domain> segment matches the name of any installed extension. If not, the command is preset-provided and should register unconditionally. A minimal, backward-compatible patch in all three locations:

for cmd in command_templates:
    parts = cmd["name"].split(".")
    if len(parts) >= 3 and parts[0] == "speckit":
        ext_id = parts[1]
        ext_dir = extensions_dir / ext_id
        if not ext_dir.is_dir():
            # Only skip if this command overrides an extension command (i.e.
            # an extension with this ID exists elsewhere / was once installed).
            # Pure preset-provided commands (no matching extension) always register.
            if not any(
                (self.project_root / ".specify" / "extensions" / ext_id).parent.exists()
                for _ in [None]
            ):
                pass  # falls through to filtered.append(cmd)
            # For a cleaner fix: gate only on cmd.get('strategy') != 'replace'
            # (extension overrides typically use wrap/prepend/append).
    filtered.append(cmd)

The simplest safe heuristic: if no extension directory with that ID exists anywhere in the installed extension set, treat the command as preset-provided and always register it.

Spec-Kit Version

v0.9.4 (latest release). The same filter code is present in main (v0.9.5.dev0) — not yet fixed.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions