mirror of
https://github.com/github/spec-kit.git
synced 2026-03-22 05:13:08 +00:00
feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
* Initial plan * feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * test(templates): add comprehensive unit tests for template pack system Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat(presets): pluggable preset system with template/command overrides, catalog, and resolver - Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing * feat(presets): propagate command overrides to skills via init-options - Add save_init_options() / load_init_options() helpers that persist CLI flags from 'specify init' to .specify/init-options.json - PresetManager._register_skills() overwrites SKILL.md files when --ai-skills was used during init and corresponding skill dirs exist - PresetManager._unregister_skills() restores core template content on preset removal - registered_skills stored in preset registry metadata - 8 new tests covering skill override, skip conditions, and restore * fix: address PR check failures (ruff F541, CodeQL URL substring) - Remove extraneous f-prefix from two f-strings without placeholders - Replace substring URL check in test with startswith/endswith assertions to satisfy CodeQL incomplete URL substring sanitization rule * fix: address Copilot PR review comments - Move save_init_options() before preset install so skills propagation works during 'specify init --preset --ai-skills' - Clean up downloaded ZIP after successful preset install during init - Validate --from URL scheme (require HTTPS, HTTP only for localhost) - Expose unregister_commands() on extensions.py CommandRegistrar wrapper instead of reaching into private _registrar field - Use _get_merged_packs() for search() and get_pack_info() so all active catalogs are searched, not just the highest-priority one - Fix fetch_catalog() cache to verify cached URL matches current URL - Fix PresetResolver: script resolution uses .sh extension, consistent file extensions throughout resolve(), and resolve_with_source() delegates to resolve() to honor template_type parameter - Fix bash common.sh: fall through to directory scan when python3 returns empty preset list - Fix PowerShell Resolve-Template: filter out dot-folders and sort extensions deterministically * fix: narrow empty except blocks and add explanatory comments * fix: address Copilot PR review comments (round 2) - Fix init --preset error masking: distinguish "not found" from real errors - Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS) - Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp - Fix self-test catalog entry: add note that it's local-only (no download_url) - Fix Windows path issue in resolve_with_source: use Path.relative_to() - Fix skill restore path: use project's .specify/templates/commands/ not source tree - Add encoding="utf-8" to all file read/write in agents.py - Update test to set up core command templates for skill restoration * fix: remove self-test from catalog.json (local-only preset) * fix: address Copilot PR review comments (round 3) - Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache) - Rename _catalog to _catalog_name for consistency with extension system - Enforce install_allowed policy in CLI preset add and download_pack() - Fix shell injection: pass registry path via env var instead of string interpolation * fix: correct PresetError docstring from template to preset * Removed CHANGELOG requirement * Applying review recommendations * Applying review recommendations * Applying review recommendations * Applying review recommendations --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
This commit is contained in:
@@ -578,23 +578,7 @@ class ExtensionManager:
|
||||
# Unregister commands from all AI agents
|
||||
if registered_commands:
|
||||
registrar = CommandRegistrar()
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
|
||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = self.project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
# Also remove companion .prompt.md for Copilot
|
||||
if agent_name == "copilot":
|
||||
prompt_file = self.project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
prompt_file.unlink()
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
if keep_config:
|
||||
# Preserve config files, only remove non-config files
|
||||
@@ -718,255 +702,47 @@ def version_satisfies(current: str, required: str) -> bool:
|
||||
|
||||
|
||||
class CommandRegistrar:
|
||||
"""Handles registration of extension commands with AI agents."""
|
||||
"""Handles registration of extension commands with AI agents.
|
||||
|
||||
# Agent configurations with directory, format, and argument placeholder
|
||||
AGENT_CONFIGS = {
|
||||
"claude": {
|
||||
"dir": ".claude/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"gemini": {
|
||||
"dir": ".gemini/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"copilot": {
|
||||
"dir": ".github/agents",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".agent.md"
|
||||
},
|
||||
"cursor": {
|
||||
"dir": ".cursor/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qwen": {
|
||||
"dir": ".qwen/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"opencode": {
|
||||
"dir": ".opencode/command",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"codex": {
|
||||
"dir": ".codex/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kilocode": {
|
||||
"dir": ".kilocode/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"auggie": {
|
||||
"dir": ".augment/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"roo": {
|
||||
"dir": ".roo/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"codebuddy": {
|
||||
"dir": ".codebuddy/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qodercli": {
|
||||
"dir": ".qoder/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kiro-cli": {
|
||||
"dir": ".kiro/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"amp": {
|
||||
"dir": ".agents/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"shai": {
|
||||
"dir": ".shai/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"tabnine": {
|
||||
"dir": ".tabnine/agent/commands",
|
||||
"format": "toml",
|
||||
"args": "{{args}}",
|
||||
"extension": ".toml"
|
||||
},
|
||||
"bob": {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"kimi": {
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
}
|
||||
}
|
||||
This is a backward-compatible wrapper around the shared CommandRegistrar
|
||||
in agents.py. Extension-specific methods accept ExtensionManifest objects
|
||||
and delegate to the generic API.
|
||||
"""
|
||||
|
||||
# Re-export AGENT_CONFIGS at class level for direct attribute access
|
||||
from .agents import CommandRegistrar as _AgentRegistrar
|
||||
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
|
||||
|
||||
def __init__(self):
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
self._registrar = _Registrar()
|
||||
|
||||
# Delegate static/utility methods
|
||||
@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 = {}
|
||||
|
||||
return frontmatter, body
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.parse_frontmatter(content)
|
||||
|
||||
@staticmethod
|
||||
def render_frontmatter(fm: dict) -> str:
|
||||
"""Render frontmatter dictionary as YAML.
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
return _Registrar.render_frontmatter(fm)
|
||||
|
||||
Args:
|
||||
fm: Frontmatter dictionary
|
||||
@staticmethod
|
||||
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
|
||||
from .agents import CommandRegistrar as _Registrar
|
||||
_Registrar.write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
Returns:
|
||||
YAML-formatted frontmatter with delimiters
|
||||
"""
|
||||
if not fm:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
"""Adjust script paths from extension-relative to repo-relative.
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
if "scripts" in frontmatter:
|
||||
for key in frontmatter["scripts"]:
|
||||
script_path = frontmatter["scripts"][key]
|
||||
if script_path.startswith("../../scripts/"):
|
||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
def _render_markdown_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
ext_id: str
|
||||
) -> str:
|
||||
"""Render command in Markdown format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
ext_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Formatted Markdown command file content
|
||||
"""
|
||||
def _render_markdown_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific comment format for backward compatibility
|
||||
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
|
||||
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
|
||||
|
||||
def _render_toml_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
ext_id: str
|
||||
) -> str:
|
||||
"""Render command in TOML format.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
ext_id: Extension ID
|
||||
|
||||
Returns:
|
||||
Formatted TOML command file content
|
||||
"""
|
||||
# TOML format for Gemini/Qwen
|
||||
toml_lines = []
|
||||
|
||||
# Add description if present
|
||||
if "description" in frontmatter:
|
||||
# Escape quotes in description
|
||||
desc = frontmatter["description"].replace('"', '\\"')
|
||||
toml_lines.append(f'description = "{desc}"')
|
||||
toml_lines.append("")
|
||||
|
||||
# Add extension context as comments
|
||||
toml_lines.append(f"# Extension: {ext_id}")
|
||||
toml_lines.append(f"# Config: .specify/extensions/{ext_id}/")
|
||||
toml_lines.append("")
|
||||
|
||||
# Add prompt content
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
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)
|
||||
def _render_toml_command(self, frontmatter, body, ext_id):
|
||||
# Preserve extension-specific context comments for backward compatibility
|
||||
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
|
||||
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
|
||||
return base.rstrip("\n") + "\n" + context_lines
|
||||
|
||||
def register_commands_for_agent(
|
||||
self,
|
||||
@@ -975,96 +751,14 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name: Agent name (claude, gemini, copilot, etc.)
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
|
||||
Raises:
|
||||
ExtensionError: If agent is not supported
|
||||
"""
|
||||
"""Register extension commands for a specific agent."""
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
raise ExtensionError(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 manifest.commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
cmd_file = cmd_info["file"]
|
||||
|
||||
# Read source command file
|
||||
source_file = extension_dir / cmd_file
|
||||
if not source_file.exists():
|
||||
continue
|
||||
|
||||
content = source_file.read_text()
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
# Adjust script paths
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
# Convert argument placeholders
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
# Render in agent-specific format
|
||||
if agent_config["format"] == "markdown":
|
||||
output = self._render_markdown_command(frontmatter, body, manifest.id)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self._render_toml_command(frontmatter, body, manifest.id)
|
||||
else:
|
||||
raise ExtensionError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
# Write command file
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output)
|
||||
|
||||
# Generate companion .prompt.md for Copilot agents
|
||||
if agent_name == "copilot":
|
||||
self._write_copilot_prompt(project_root, cmd_name)
|
||||
|
||||
registered.append(cmd_name)
|
||||
|
||||
# Register aliases
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(output)
|
||||
# Generate companion .prompt.md for alias too
|
||||
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.
|
||||
|
||||
Copilot requires a .prompt.md file in .github/prompts/ that references
|
||||
the corresponding .agent.md file in .github/agents/ via an ``agent:``
|
||||
frontmatter field.
|
||||
|
||||
Args:
|
||||
project_root: Path to project root
|
||||
cmd_name: Command name (used as the file stem, 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"
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n")
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands(
|
||||
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
@@ -1072,35 +766,20 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register extension commands for all detected agents.
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands_for_all_agents(
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
)
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Detect which agents are present in the project
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
|
||||
# Register if agent directory exists
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands_for_agent(
|
||||
agent_name, manifest, extension_dir, project_root
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
except ExtensionError:
|
||||
# Skip agent on error
|
||||
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."""
|
||||
self._registrar.unregister_commands(registered_commands, project_root)
|
||||
|
||||
def register_commands_for_claude(
|
||||
self,
|
||||
@@ -1108,16 +787,7 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
) -> List[str]:
|
||||
"""Register extension commands for Claude Code agent.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest
|
||||
extension_dir: Path to extension directory
|
||||
project_root: Path to project root
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
"""
|
||||
"""Register extension commands for Claude Code agent."""
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user