Files
github-spec-kit/src/specify_cli/agents.py
Copilot a067d4c2e3 feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133)
* fix: rebase onto upstream/main, resolve conflicts with PR #2189

upstream/main merged PR #2189 (wrap-only strategy) which overlaps with
our comprehensive composition strategies (prepend/append/wrap). Resolved
conflicts keeping our implementation as source of truth:

- README: keep our future considerations (composition is now fully
  implemented, not a future item)
- presets.py: keep our composition architecture (_reconcile_composed_commands,
  collect_all_layers, resolve_content) while preserving #2189's
  _substitute_core_template which is used by agents.py for skill
  generation
- tests: keep both test sets (our composition tests + #2189's wrap
  tests), removed TestReplayWrapsForCommand and
  TestInstallRemoveWrapLifecycle which test the superseded
  _replay_wraps_for_command API; our composition tests cover equivalent
  scenarios
- Restored missing _unregister_commands call in remove() that was lost
  during #2189 merge

* fix: re-create skill directory in _reconcile_skills after removal

After _unregister_skills removes a skill directory, _register_skills
skips writing because the dir no longer passes the is_dir() check.
Fix by ensuring the skill subdirectory exists before calling
_register_skills so the next winning preset's content gets registered.

Fixes the Claude E2E failure where removing a top-priority override
preset left skill-based agents without any SKILL.md file.

* fix: address twenty-third round of Copilot PR review feedback

- Protect reconciliation in remove(): wrap _reconcile_composed_commands
  and _reconcile_skills in try/except so failures emit a warning instead
  of leaving the project in an inconsistent state
- Protect reconciliation in install(): same pattern for post-install
  reconciliation so partial installs don't lack cleanup
- Inherit scripts/agent_scripts from base frontmatter: when composing
  commands, merge scripts and agent_scripts keys from the base command's
  frontmatter into the top layer's frontmatter if missing, preventing
  composed commands from losing required script references
- Add tier-5 bundled core fallback to collect_all_layers(): check the
  bundled core_pack (wheel) or repo-root templates (source checkout) when
  .specify/templates/ doesn't contain the core file, matching resolve()'s
  tier-5 fallback so composition can always find a base layer

* fix: address twenty-fourth round of Copilot PR review feedback

- Use yaml.safe_load for frontmatter parsing in resolve_content instead
  of CommandRegistrar.parse_frontmatter which uses naive find('---',3);
  strip strategy key from final frontmatter to prevent leaking internal
  composition directives into rendered agent command files
- Filter _reconcile_skills to specific commands: use _FilteredManifest
  wrapper so only the commands being reconciled get their skills updated,
  preventing accidental overwrites of other commands' skills that may be
  owned by higher-priority presets

* fix: address twenty-fifth round of Copilot PR review feedback

- Support legacy command-frontmatter strategy: when preset.yml doesn't
  declare a strategy, check the command file's YAML frontmatter for
  strategy: wrap as a fallback so legacy wrap presets participate in
  composition and multi-preset chaining
- Guard skill dir creation in _reconcile_skills: only re-create the
  skill directory if the skill was previously managed (listed in some
  preset's registered_skills), avoiding creation of new skill dirs
  that _register_skills would normally skip

* fix: add explanatory comment to empty except in legacy frontmatter parsing

* fix: address twenty-sixth round of Copilot PR review feedback

- Unregister stale commands when composition fails: when resolve_content
  returns None during reconciliation (base layer removed), unregister
  the command from non-skill agents and emit a warning
- Load extension aliases during reconciliation: _register_command_from_path
  now checks extension.yml for aliases when the winning layer is an
  extension, so alias files are restored after preset removal
- Use line-based fence detection for legacy frontmatter strategy fallback:
  scan for --- on its own line instead of split('---',2) to avoid
  mis-parsing YAML values containing ---

* fix: address twenty-seventh round of Copilot PR review feedback

- Handle non-preset winners in _reconcile_skills: when the winning
  layer is core/extension/project-override, restore skills via
  _unregister_skills so skill-based agents stay consistent with the
  priority stack
- Update base_frontmatter_text on replace layers: when a higher-priority
  replace layer occurs during composition, update both top and base
  frontmatter so scripts/agent_scripts inheritance reflects the
  effective base beneath the top composed layer

* fix: address twenty-eighth round of Copilot PR review feedback

- Parse only interior lines in _parse_fm_yaml: use lines[1:-1] instead
  of filtering all --- lines, preventing corruption when YAML values
  contain a line that is exactly ---
- Omit empty frontmatter: skip re-rendering when top_fm is empty dict
  to avoid emitting ---/{}/--- for intentionally empty frontmatter
- Update scaffold wrap comment: mention both {CORE_TEMPLATE} and
  $CORE_SCRIPT placeholders for templates/commands vs scripts
- Clarify shell composition scope in ARCHITECTURE.md: note that bash/PS1
  resolve_template_content only handles templates; command/script
  composition is handled by the Python resolver

* fix: address twenty-ninth round of Copilot PR review feedback

- Fix TestCollectAllLayers docstring: reference collect_all_layers()
- Add default/unknown strategy handling in bash/PS1 composition: error
  on unrecognized strategy values instead of silently skipping
- Fix comment: .composed/ is a persistent dir, not temporary
- Fix comment: legacy fallback checks all valid strategies, not just wrap
- Cache PresetRegistry in _reconcile_skills: build presets_by_priority
  once instead of constructing registry per-command

* fix: address thirtieth round of Copilot PR review feedback

- Guard legacy frontmatter fallback: only check command file frontmatter
  for strategy when the manifest entry doesn't explicitly include the
  strategy key, preventing override of manifest-declared strategies
- Document rollback limitation: note that mid-registration failures may
  leave orphaned agent command files since partial progress isn't
  captured by the local vars

* fix: handle project override skills and extension context in reconciliation

* fix: add comment to empty except in extension registration fallback

* fix: filter extension commands in reconciliation and fix type annotation

* fix: filter extension commands from post-install reconciliation

Apply the same extension-installed check used in _register_commands to
the reconciliation command list, preventing reconciliation from
registering commands for extensions that are not installed.

* fix: skip convention fallback for explicit file paths and add stem fallback to tier-5

When a preset manifest provides an explicit file path that does not
exist, skip the convention-based fallback to avoid masking typos.
Also add speckit.<stem> to <stem>.md fallback in tier-5 bundled/source
core lookup for consistency with tier-4.

* fix: scan past non-replace layers to find base in resolve_content

The base-finding scan now skips non-replace layers below a replace
layer instead of stopping at the first non-replace. This fixes the
case where a low-priority append/prepend layer sits below a replace
that should serve as the base for composition.

* fix: add context_note to non-skill agent registration for extensions

Add context_note parameter to register_commands_for_non_skill_agents
and pass extension name/id during reconciliation so rendered command
files preserve the extension-specific context markers.

* fix: Optional type, rollback safety, and override skill restoration

- Fix context_note type to Optional[str]
- Wrap shutil.rmtree in try/except during install rollback
- Separate override-backed skills from core/extension in _reconcile_skills

* fix: align bash/PS1 base-finding with Python resolver

Rewrite bash and PowerShell composition loops to find the effective
base replace layer first (scanning bottom-up, skipping non-replace
layers below it), then compose only from the base upward. This
prevents evaluation of irrelevant lower layers (e.g. a wrap with
no placeholder below a replace) and matches resolve_content behavior.

* fix: PS1 no-python warning, integration hook for override skills, alias cleanup

- Warn when no Python 3 found in PS1 and presets use composition strategies
- Apply post_process_skill_content integration hook when restoring
  override-backed skills so agent-specific flags are preserved
- Unregister command aliases alongside primary name when composition
  fails to prevent orphaned alias files

* fix: include aliases in removed_cmd_names during preset removal

Read aliases from preset manifest before deleting pack_dir so alias
command files are included in unregistration and reconciliation.

* fix: add comment to empty except in alias extraction during removal

* fix: scan top-down for effective base in all resolvers

Change base-finding to scan from highest priority downward to find the
nearest replace layer, then compose only layers above it. Prevents
evaluation of irrelevant lower layers (e.g. a wrap without placeholder
below a higher-priority replace) across Python, bash, and PowerShell.

* fix: align CLI composition chain display with top-down base-finding

Show only contributing layers (base and above) in preset resolve
output, matching resolve_content top-down semantics. Layers below
the effective base are omitted since they do not contribute.

* fix: guard corrupted registry entries and make manifest authoritative

- Add isinstance(meta, dict) guard in bash registry parsing so corrupted
  entries are skipped instead of breaking priority ordering
- Only use convention-based file lookup when the manifest does not list
  the requested template, making preset.yml authoritative and preventing
  stray on-disk files from creating unintended layers

* fix: align resolve() with manifest file paths and match extension context_note

- Update resolve() preset tier to consult manifest file paths before
  convention-based lookup, matching collect_all_layers behavior
- Use exact extension context_note format matching extensions.CommandRegistrar
- Update test to declare template in manifest (authoritative manifest)

* revert: restore resolve() convention-based behavior for backwards compatibility

resolve() is the existing public API used by shell scripts and other
callers. Changing it to manifest-authoritative breaks backward compat
for presets that rely on convention-based file lookup. Only the new
collect_all_layers/resolve_content path uses manifest-authoritative
logic.

* fix: only pre-compose when this preset is the top composing layer

Skip composition in _register_commands when a higher-priority replace
layer already wins for the command. Register the raw file instead and
let reconciliation write the correct final content.

* fix: deduplicate PyYAML warnings and use self.registry in reconciliation

- Emit PyYAML-missing warning once per function call in bash/PS1 instead
  of per-preset to avoid spamming stderr
- Use self.registry.list_by_priority() in reconciliation methods instead
  of constructing new PresetRegistry instances to avoid redundant I/O
  and potential consistency issues

* fix: document strategy handling consistency between layers and registrar

Composed output already strips strategy from frontmatter (resolve_content
pops it). Raw file registration preserves legacy frontmatter strategy
for backward compat; reconciliation corrects the final state.

* fix: correct stale comments for alias tracking and base-finding algorithm

* security: validate manifest file paths in bash/PowerShell resolvers

Reject absolute paths and parent directory traversal (..) in the
manifest-declared file field before joining with the preset directory.
Matches the Python-side validation in PresetManifest._validate().

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-04-23 10:07:52 -05:00

747 lines
27 KiB
Python

"""
Agent Command Registrar for Spec Kit
Shared infrastructure for registering commands with AI agents.
Used by both the extension system and the preset system to write
command files into agent-specific directories in the correct format.
"""
import os
from pathlib import Path
from typing import Dict, List, Any, Optional
import platform
import re
from copy import deepcopy
import yaml
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
from specify_cli.integrations import INTEGRATION_REGISTRY
configs: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if key == "generic":
continue
if integration.registrar_config:
configs[key] = dict(integration.registrar_config)
return configs
class CommandRegistrar:
"""Handles registration of commands with AI agents.
Supports writing command files in Markdown or TOML format to the
appropriate agent directory, with correct argument placeholders
and companion files (e.g. Copilot .prompt.md).
"""
# Derived from INTEGRATION_REGISTRY — single source of truth.
# Populated lazily via _ensure_configs() on first use.
AGENT_CONFIGS: dict[str, dict[str, Any]] = {}
_configs_loaded: bool = False
def __init__(self) -> None:
self._ensure_configs()
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
cls._ensure_configs()
@classmethod
def _ensure_configs(cls) -> None:
if not cls._configs_loaded:
try:
cls.AGENT_CONFIGS = _build_agent_configs()
cls._configs_loaded = True
except ImportError:
pass # Circular import during module init; retry on next access
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Args:
content: Markdown content with YAML frontmatter
Returns:
Tuple of (frontmatter_dict, body_content)
"""
if not content.startswith("---"):
return {}, content
# Find second ---
end_marker = content.find("---", 3)
if end_marker == -1:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3 :].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
except yaml.YAMLError:
frontmatter = {}
if not isinstance(frontmatter, dict):
frontmatter = {}
return frontmatter, body
@staticmethod
def render_frontmatter(fm: dict) -> str:
"""Render frontmatter dictionary as YAML.
Args:
fm: Frontmatter dictionary
Returns:
YAML-formatted frontmatter with delimiters
"""
if not fm:
return ""
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Normalize script paths in frontmatter to generated project locations.
Rewrites known repo-relative and top-level script paths under the
``scripts`` key (for example ``../../scripts/``,
``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and
``memory/``) to the ``.specify/...`` paths used in generated projects.
Args:
frontmatter: Frontmatter dictionary
Returns:
Modified frontmatter with normalized project paths
"""
frontmatter = deepcopy(frontmatter)
scripts = frontmatter.get("scripts")
if isinstance(scripts, dict):
for key, script_path in scripts.items():
if isinstance(script_path, str):
scripts[key] = self.rewrite_project_relative_paths(script_path)
return frontmatter
@staticmethod
def rewrite_project_relative_paths(text: str) -> str:
"""Rewrite repo-relative paths to their generated project locations."""
if not isinstance(text, str) or not text:
return text
for old, new in (
("../../memory/", ".specify/memory/"),
("../../scripts/", ".specify/scripts/"),
("../../templates/", ".specify/templates/"),
):
text = text.replace(old, new)
# Only rewrite top-level style references so extension-local paths like
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
return text.replace(".specify/.specify/", ".specify/").replace(
".specify.specify/", ".specify/"
)
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
"""Render command in Markdown format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
context_note: Custom context comment (default: <!-- Source: {source_id} -->)
Returns:
Formatted Markdown command file content
"""
if context_note is None:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str:
"""Render command in TOML format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
Returns:
Formatted TOML command file content
"""
toml_lines = []
if "description" in frontmatter:
toml_lines.append(
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
)
toml_lines.append("")
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
# Keep TOML output valid even when body contains triple-quote delimiters.
# Prefer multiline forms, then fall back to escaped basic string.
if '"""' not in body:
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
elif "'''" not in body:
toml_lines.append("prompt = '''")
toml_lines.append(body)
toml_lines.append("'''")
else:
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
return "\n".join(toml_lines)
@staticmethod
def _render_basic_toml_string(value: str) -> str:
"""Render *value* as a TOML basic string literal."""
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def render_yaml_command(
self,
frontmatter: dict,
body: str,
source_id: str,
cmd_name: str = "",
) -> str:
"""Render command in YAML recipe format for Goose.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
cmd_name: Command name used as title fallback
Returns:
Formatted YAML recipe file content
"""
from specify_cli.integrations.base import YamlIntegration
title = frontmatter.get("title", "") or frontmatter.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title and cmd_name:
title = YamlIntegration._human_title(cmd_name)
if not title and source_id:
title = YamlIntegration._human_title(Path(str(source_id)).stem)
if not title:
title = "Command"
description = frontmatter.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
return YamlIntegration._render_yaml(title, description, body, source_id)
def render_skill_command(
self,
agent_name: str,
skill_name: str,
frontmatter: dict,
body: str,
source_id: str,
source_file: str,
project_root: Path,
) -> str:
"""Render a command override as a SKILL.md file.
SKILL-target agents should receive the same skills-oriented
frontmatter shape used elsewhere in the project instead of the
original command frontmatter.
Technical debt note:
Spec-kit currently has multiple SKILL.md generators (template packaging,
init-time conversion, and extension/preset overrides). Keep the skill
frontmatter keys aligned (name/description/compatibility/metadata, with
metadata.author and metadata.source subkeys) to avoid drift across agents.
"""
if not isinstance(frontmatter, dict):
frontmatter = {}
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
description = frontmatter.get(
"description", f"Spec-kit workflow command: {skill_name}"
)
skill_frontmatter = self.build_skill_frontmatter(
agent_name,
skill_name,
description,
f"{source_id}:{source_file}",
)
return self.render_frontmatter(skill_frontmatter) + "\n" + body
@staticmethod
def build_skill_frontmatter(
agent_name: str,
skill_name: str,
description: str,
source: str,
) -> dict:
"""Build consistent SKILL.md frontmatter across all skill generators."""
skill_frontmatter = {
"name": skill_name,
"description": description,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": source,
},
}
return skill_frontmatter
@staticmethod
def resolve_skill_placeholders(
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
except ImportError:
return body
if not isinstance(frontmatter, dict):
frontmatter = {}
scripts = frontmatter.get("scripts", {}) or {}
if not isinstance(scripts, dict):
scripts = {}
init_opts = load_init_options(project_root)
if not isinstance(init_opts, dict):
init_opts = {}
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts:
fallback_order.append(default_variant)
if secondary_variant in scripts:
fallback_order.append(secondary_variant)
for key in scripts:
if key not in fallback_order:
fallback_order.append(key)
script_variant = fallback_order[0] if fallback_order else None
script_command = scripts.get(script_variant) if script_variant else None
if script_command:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# Resolve __CONTEXT_FILE__ from init-options
context_file = init_opts.get("context_file") or ""
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
) -> str:
"""Convert argument placeholder format.
Args:
content: Command content
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
to_placeholder: Target placeholder (e.g., "{{args}}")
Returns:
Content with converted placeholders
"""
return content.replace(from_placeholder, to_placeholder)
@staticmethod
def _compute_output_name(
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
return cmd_name
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit.") :]
short_name = short_name.replace(".", "-")
return f"speckit-{short_name}"
@staticmethod
def _ensure_inside(candidate: Path, base: Path) -> None:
"""Validate that a write target stays within the expected base directory.
Uses lexical normalization so traversal via ``..`` or absolute paths is
rejected while intentionally symlinked sub-directories remain
supported.
Args:
candidate: Path that will be written.
base: Directory the write must remain within.
Raises:
ValueError: If the normalized candidate path escapes ``base``.
"""
normalized = Path(os.path.normpath(candidate))
base_normalized = Path(os.path.normpath(base))
if not normalized.is_relative_to(base_normalized):
raise ValueError(
f"Output path {candidate!r} escapes directory {base!r}"
)
def register_commands(
self,
agent_name: str,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
) -> List[str]:
"""Register commands for a specific agent.
Args:
agent_name: Agent name (claude, gemini, copilot, etc.)
commands: List of command info dicts with 'name', 'file', and optional 'aliases'
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
List of registered command names
Raises:
ValueError: If agent is not supported
"""
self._ensure_configs()
if agent_name not in self.AGENT_CONFIGS:
raise ValueError(f"Unsupported agent: {agent_name}")
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
for cmd_info in commands:
cmd_name = cmd_info["name"]
cmd_file = cmd_info["file"]
source_file = source_dir / cmd_file
if not source_file.exists():
continue
content = source_file.read_text(encoding="utf-8")
frontmatter, body = self.parse_frontmatter(content)
if frontmatter.get("strategy") == "wrap":
from .presets import _substitute_core_template
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_frontmatter:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
frontmatter = self._adjust_script_paths(frontmatter)
for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None)
if agent_config.get("inject_name") and not frontmatter.get("name"):
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name,
output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, cmd_name)
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = (
format_name(alias) if format_name else alias
)
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name,
alias_output_name,
alias_frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
alias_frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
alias_output = self.render_toml_command(
alias_frontmatter, body, source_id
)
elif agent_config["format"] == "yaml":
alias_output = self.render_yaml_command(
alias_frontmatter, body, source_id, alias
)
else:
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
else:
# For other agents, reuse the primary output
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name,
alias_output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
alias_file = (
commands_dir / f"{alias_output_name}{agent_config['extension']}"
)
self._ensure_inside(alias_file, commands_dir)
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
registered.append(alias)
return registered
@staticmethod
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
"""Generate a companion .prompt.md file for a Copilot agent command.
Args:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
def register_commands_for_all_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
Args:
commands: List of command info dicts
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def register_commands_for_non_skill_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
Like register_commands_for_all_agents but skips skill-based agents
(those with extension '/SKILL.md'). Used by reconciliation to avoid
overwriting properly formatted SKILL.md files.
Args:
commands: List of command info dicts
source_id: Identifier of the source
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if agent_config.get("extension") == "/SKILL.md":
continue
agent_dir = project_root / agent_config["dir"]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id,
source_dir, project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
Args:
registered_commands: Dict mapping agent names to command name lists
project_root: Path to project root
"""
self._ensure_configs()
for agent_name, cmd_names in registered_commands.items():
if agent_name not in self.AGENT_CONFIGS:
continue
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own subdirectory
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
# parent dir when it becomes empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != commands_dir and parent.exists():
try:
parent.rmdir() # no-op if dir still has other files
except OSError:
pass
if agent_name == "copilot":
prompt_file = (
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
)
if prompt_file.exists():
prompt_file.unlink()
# Populate AGENT_CONFIGS after class definition.
# Catches ImportError from circular imports during module loading;
# _configs_loaded stays False so the next explicit access retries.
try:
CommandRegistrar._ensure_configs()
except ImportError:
pass