From c78f8423f69f03d89e459d69dd8dda7167e2b418 Mon Sep 17 00:00:00 2001 From: Laurent Thiebault Date: Tue, 17 Feb 2026 14:22:43 +0100 Subject: [PATCH 001/321] fix: typo in plan-template.md (#1446) --- templates/plan-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/plan-template.md b/templates/plan-template.md index 6a8bfc6c8a..dd47efceb4 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -3,7 +3,7 @@ **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] **Input**: Feature specification from `/specs/[###-feature-name]/spec.md` -**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. ## Summary From 22036732d8c81f0f620faddf4e8663bbf48e2962 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Wed, 18 Feb 2026 16:11:16 -0600 Subject: [PATCH 002/321] Remove Maintainers section from README.md (#1618) --- README.md | 6 ------ scripts/bash/update-agent-context.sh | 5 ++--- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e189750f28..426d5712dc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ - [📖 Learn More](#-learn-more) - [📋 Detailed Process](#-detailed-process) - [🔍 Troubleshooting](#-troubleshooting) -- [👥 Maintainers](#-maintainers) - [💬 Support](#-support) - [🙏 Acknowledgements](#-acknowledgements) - [📄 License](#-license) @@ -637,11 +636,6 @@ echo "Cleaning up..." rm gcm-linux_amd64.2.6.1.deb ``` -## 👥 Maintainers - -- Den Delimarsky ([@localden](https://github.com/localden)) -- John Lam ([@jflam](https://github.com/jflam)) - ## 💬 Support For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development. diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 5b19abf659..40db5e30af 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -35,7 +35,7 @@ # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|agy|bob|qoder +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder # Leave empty to update all existing agent files set -e @@ -723,7 +723,6 @@ update_all_existing_agents() { update_agent_file "$AGY_FILE" "Antigravity" found_agent=true fi - if [[ -f "$BOB_FILE" ]]; then update_agent_file "$BOB_FILE" "IBM Bob" found_agent=true @@ -753,7 +752,7 @@ print_summary() { echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|shai|q|agy|bob|qoder]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder]" } #============================================================================== From 686c91f94ebb6940c3b6bdff31dc39bd22c49475 Mon Sep 17 00:00:00 2001 From: Manfred Riem Date: Wed, 18 Feb 2026 17:42:41 -0600 Subject: [PATCH 003/321] feat: add dependabot configuration for pip and GitHub Actions updates (#1622) --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..47762116a2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From d410d188fc868811283cfdefc7aec99fef92c267 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:41:50 -0600 Subject: [PATCH 004/321] chore(deps): bump actions/stale from 9 to 10 (#1623) Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v9...v10) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '10' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index fd8102ce28..d2836b7919 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: # Days of inactivity before an issue or PR becomes stale days-before-stale: 150 From 9402ebd00ae6470bf8cb862868a5b3cec1163398 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:24:41 -0600 Subject: [PATCH 005/321] Feat/ai skills (#1632) * implement ai-skills command line switch * fix: address review comments, remove breaking change for existing projects, add tests * fix: review comments * fix: review comments * fix: review comments * fix: review comments * fix: review comments, add test cases for all the agents * fix: review comments * fix: review comments * chore: trigger CI * chore: trigger CodeQL * ci: add CodeQL workflow for code scanning * ci: add actions language to CodeQL workflow, disable default setup --------- Co-authored-by: dhilipkumars --- .github/workflows/codeql.yml | 32 ++ CHANGELOG.md | 16 + README.md | 7 + pyproject.toml | 2 +- src/specify_cli/__init__.py | 234 +++++++++++++ tests/test_ai_skills.py | 630 +++++++++++++++++++++++++++++++++++ 6 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 tests/test_ai_skills.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..55829ce5cd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,32 @@ +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + fail-fast: false + matrix: + language: [ 'actions', 'python' ] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 174b429cbc..d945780015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,27 @@ + All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2026-02-13 + +### Added + +- **Agent Skills Installation**: New `--ai-skills` CLI option to install Prompt.MD templates as agent skills following [agentskills.io specification](https://agentskills.io/specification) + - Skills are installed to agent-specific directories (e.g., `.claude/skills/`, `.gemini/skills/`, `.github/skills/`) + - Codex uses `.agents/skills/` following Codex agent directory conventions + - Default fallback directory is `.agents/skills/` for agents without a specific mapping + - Requires `--ai` flag to be specified + - Converts all 9 spec-kit command templates (specify, plan, tasks, implement, analyze, clarify, constitution, checklist, taskstoissues) to properly formatted SKILL.md files + - **New projects**: command files are not installed when `--ai-skills` is used (skills replace commands) + - **Existing repos** (`--here`): pre-existing command files are preserved — no breaking changes + - `pyyaml` dependency (already present) used for YAML frontmatter parsing +- **Unit tests** for `install_ai_skills`, `_get_skills_dir`, and `--ai-skills` CLI validation (51 test cases covering all 18 supported agents) + ## [0.1.0] - 2026-01-28 ### Added diff --git a/README.md b/README.md index 426d5712dc..f1ead5c05f 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | +| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) | ### Examples @@ -238,6 +239,12 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here +# Install agent skills with the project +specify init my-project --ai claude --ai-skills + +# Initialize in current directory with agent skills +specify init --here --ai gemini --ai-skills + # Check system requirements specify check ``` diff --git a/pyproject.toml b/pyproject.toml index de6fe5fe9a..7ca679cb08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.0" +version = "0.1.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 70c5bd27c5..52e2290a35 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -32,6 +32,7 @@ import shutil import shlex import json +import yaml from pathlib import Path from typing import Optional, Tuple @@ -983,6 +984,203 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") +# Agent-specific skill directory overrides for agents whose skills directory +# doesn't follow the standard /skills/ pattern +AGENT_SKILLS_DIR_OVERRIDES = { + "codex": ".agents/skills", # Codex agent layout override +} + +# Default skills directory for agents not in AGENT_CONFIG +DEFAULT_SKILLS_DIR = ".agents/skills" + +# Enhanced descriptions for each spec-kit command skill +SKILL_DESCRIPTIONS = { + "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", + "plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.", + "tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.", + "implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.", + "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.", + "clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.", + "constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.", + "checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.", + "taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.", +} + + +def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: + """Resolve the agent-specific skills directory for the given AI assistant. + + Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to + ``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to + ``DEFAULT_SKILLS_DIR``. + """ + if selected_ai in AGENT_SKILLS_DIR_OVERRIDES: + return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai] + + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + return project_path / agent_folder.rstrip("/") / "skills" + + return project_path / DEFAULT_SKILLS_DIR + + +def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool: + """Install Prompt.MD files from templates/commands/ as agent skills. + + Skills are written to the agent-specific skills directory following the + `agentskills.io `_ specification. + Installation is additive — existing files are never removed and prompt + command files in the agent's commands directory are left untouched. + + Args: + project_path: Target project directory. + selected_ai: AI assistant key from ``AGENT_CONFIG``. + tracker: Optional progress tracker. + + Returns: + ``True`` if at least one skill was installed or all skills were + already present (idempotent re-run), ``False`` otherwise. + """ + # Locate command templates in the agent's extracted commands directory. + # download_and_extract_template() already placed the .md files here. + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + templates_dir = project_path / agent_folder.rstrip("/") / "commands" + else: + templates_dir = project_path / "commands" + + if not templates_dir.exists() or not any(templates_dir.glob("*.md")): + # Fallback: try the repo-relative path (for running from source checkout) + # This also covers agents whose extracted commands are in a different + # format (e.g. gemini uses .toml, not .md). + script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ + fallback_dir = script_dir / "templates" / "commands" + if fallback_dir.exists() and any(fallback_dir.glob("*.md")): + templates_dir = fallback_dir + + if not templates_dir.exists() or not any(templates_dir.glob("*.md")): + if tracker: + tracker.error("ai-skills", "command templates not found") + else: + console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") + return False + + command_files = sorted(templates_dir.glob("*.md")) + if not command_files: + if tracker: + tracker.skip("ai-skills", "no command templates found") + else: + console.print("[yellow]No command templates found to install[/yellow]") + return False + + # Resolve the correct skills directory for this agent + skills_dir = _get_skills_dir(project_path, selected_ai) + skills_dir.mkdir(parents=True, exist_ok=True) + + if tracker: + tracker.start("ai-skills") + + installed_count = 0 + skipped_count = 0 + for command_file in command_files: + try: + content = command_file.read_text(encoding="utf-8") + + # Parse YAML frontmatter + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + # File starts with --- but has no closing --- + console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + command_name = command_file.stem + # Normalize: extracted commands may be named "speckit..md"; + # strip the "speckit." prefix so skill names stay clean and + # SKILL_DESCRIPTIONS lookups work. + if command_name.startswith("speckit."): + command_name = command_name[len("speckit."):] + skill_name = f"speckit-{command_name}" + + # Create skill directory (additive — never removes existing content) + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + + # Select the best description available + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}") + + # Build SKILL.md following agentskills.io spec + # Use yaml.safe_dump to safely serialise the frontmatter and + # avoid YAML injection from descriptions containing colons, + # quotes, or newlines. + # Normalize source filename for metadata — strip speckit. prefix + # so it matches the canonical templates/commands/.md path. + source_name = command_file.name + if source_name.startswith("speckit."): + source_name = source_name[len("speckit."):] + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"templates/commands/{source_name}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {command_name.title()} Skill\n\n" + f"{body}\n" + ) + + skill_file = skill_dir / "SKILL.md" + if skill_file.exists(): + # Do not overwrite user-customized skills on re-runs + skipped_count += 1 + continue + skill_file.write_text(skill_content, encoding="utf-8") + installed_count += 1 + + except Exception as e: + console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]") + continue + + if tracker: + if installed_count > 0 and skipped_count > 0: + tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}") + elif installed_count > 0: + tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}") + elif skipped_count > 0: + tracker.complete("ai-skills", f"{skipped_count} skills already present") + else: + tracker.error("ai-skills", "no skills installed") + else: + if installed_count > 0: + console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/") + elif skipped_count > 0: + console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/") + else: + console.print("[yellow]No skills were installed[/yellow]") + + return installed_count > 0 or skipped_count > 0 + + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -995,6 +1193,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), ): """ Initialize a new Specify project from the latest template. @@ -1019,6 +1218,8 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --ai claude --ai-skills # Install agent skills + specify init --here --ai gemini --ai-skills """ show_banner() @@ -1035,6 +1236,11 @@ def init( console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) + if ai_skills and not ai_assistant: + console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") + raise typer.Exit(1) + if here: project_name = Path.cwd().name project_path = Path.cwd() @@ -1150,6 +1356,11 @@ def init( ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), + ]: + tracker.add(key, label) + if ai_skills: + tracker.add("ai-skills", "Install agent skills") + for key, label in [ ("cleanup", "Cleanup"), ("git", "Initialize git repository"), ("final", "Finalize") @@ -1172,6 +1383,29 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + if ai_skills: + skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) + + # When --ai-skills is used on a NEW project and skills were + # successfully installed, remove the command files that the + # template archive just created. Skills replace commands, so + # keeping both would be confusing. For --here on an existing + # repo we leave pre-existing commands untouched to avoid a + # breaking change. We only delete AFTER skills succeed so the + # project always has at least one of {commands, skills}. + if skills_ok and not here: + agent_cfg = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_cfg.get("folder", "") + if agent_folder: + cmds_dir = project_path / agent_folder.rstrip("/") / "commands" + if cmds_dir.exists(): + try: + shutil.rmtree(cmds_dir) + except OSError: + # Best-effort cleanup: skills are already installed, + # so leaving stale commands is non-fatal. + console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") + if not no_git: tracker.start("git") if is_git_repo(project_path): diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py new file mode 100644 index 0000000000..1b1b71e3a0 --- /dev/null +++ b/tests/test_ai_skills.py @@ -0,0 +1,630 @@ +""" +Unit tests for AI agent skills installation. + +Tests cover: +- Skills directory resolution for different agents (_get_skills_dir) +- YAML frontmatter parsing and SKILL.md generation (install_ai_skills) +- Cleanup of duplicate command files when --ai-skills is used +- Missing templates directory handling +- Malformed template error handling +- CLI validation: --ai-skills requires --ai +""" + +import pytest +import tempfile +import shutil +import yaml +from pathlib import Path +from unittest.mock import patch + +import specify_cli + +from specify_cli import ( + _get_skills_dir, + install_ai_skills, + AGENT_SKILLS_DIR_OVERRIDES, + DEFAULT_SKILLS_DIR, + SKILL_DESCRIPTIONS, + AGENT_CONFIG, + app, +) + + +# ===== Fixtures ===== + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock project directory.""" + proj_dir = temp_dir / "test-project" + proj_dir.mkdir() + return proj_dir + + +@pytest.fixture +def templates_dir(project_dir): + """Create mock command templates in the project's agent commands directory. + + This simulates what download_and_extract_template() does: it places + command .md files into project_path//commands/. + install_ai_skills() now reads from here instead of from the repo + source tree. + """ + tpl_root = project_dir / ".claude" / "commands" + tpl_root.mkdir(parents=True, exist_ok=True) + + # Template with valid YAML frontmatter + (tpl_root / "specify.md").write_text( + "---\n" + "description: Create or update the feature specification.\n" + "handoffs:\n" + " - label: Build Plan\n" + " agent: speckit.plan\n" + "scripts:\n" + " sh: scripts/bash/create-new-feature.sh\n" + "---\n" + "\n" + "# Specify Command\n" + "\n" + "Run this to create a spec.\n", + encoding="utf-8", + ) + + # Template with minimal frontmatter + (tpl_root / "plan.md").write_text( + "---\n" + "description: Generate implementation plan.\n" + "---\n" + "\n" + "# Plan Command\n" + "\n" + "Plan body content.\n", + encoding="utf-8", + ) + + # Template with no frontmatter + (tpl_root / "tasks.md").write_text( + "# Tasks Command\n" + "\n" + "Body without frontmatter.\n", + encoding="utf-8", + ) + + # Template with empty YAML frontmatter (yaml.safe_load returns None) + (tpl_root / "empty_fm.md").write_text( + "---\n" + "---\n" + "\n" + "# Empty Frontmatter Command\n" + "\n" + "Body with empty frontmatter.\n", + encoding="utf-8", + ) + + return tpl_root + + +@pytest.fixture +def commands_dir_claude(project_dir): + """Create a populated .claude/commands directory simulating template extraction.""" + cmd_dir = project_dir / ".claude" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: + (cmd_dir / name).write_text(f"# {name}\nContent here\n") + return cmd_dir + + +@pytest.fixture +def commands_dir_gemini(project_dir): + """Create a populated .gemini/commands directory (TOML format).""" + cmd_dir = project_dir / ".gemini" / "commands" + cmd_dir.mkdir(parents=True) + for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]: + (cmd_dir / name).write_text(f'[command]\nname = "{name}"\n') + return cmd_dir + + +# ===== _get_skills_dir Tests ===== + +class TestGetSkillsDir: + """Test the _get_skills_dir() helper function.""" + + def test_claude_skills_dir(self, project_dir): + """Claude should use .claude/skills/.""" + result = _get_skills_dir(project_dir, "claude") + assert result == project_dir / ".claude" / "skills" + + def test_gemini_skills_dir(self, project_dir): + """Gemini should use .gemini/skills/.""" + result = _get_skills_dir(project_dir, "gemini") + assert result == project_dir / ".gemini" / "skills" + + def test_copilot_skills_dir(self, project_dir): + """Copilot should use .github/skills/.""" + result = _get_skills_dir(project_dir, "copilot") + assert result == project_dir / ".github" / "skills" + + def test_codex_uses_override(self, project_dir): + """Codex should use the AGENT_SKILLS_DIR_OVERRIDES value.""" + result = _get_skills_dir(project_dir, "codex") + assert result == project_dir / ".agents" / "skills" + + def test_cursor_agent_skills_dir(self, project_dir): + """Cursor should use .cursor/skills/.""" + result = _get_skills_dir(project_dir, "cursor-agent") + assert result == project_dir / ".cursor" / "skills" + + def test_unknown_agent_uses_default(self, project_dir): + """Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" + result = _get_skills_dir(project_dir, "nonexistent-agent") + assert result == project_dir / DEFAULT_SKILLS_DIR + + def test_all_configured_agents_resolve(self, project_dir): + """Every agent in AGENT_CONFIG should resolve to a valid path.""" + for agent_key in AGENT_CONFIG: + result = _get_skills_dir(project_dir, agent_key) + assert result is not None + assert str(result).startswith(str(project_dir)) + # Should always end with "skills" + assert result.name == "skills" + + def test_override_takes_precedence_over_config(self, project_dir): + """AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG.""" + for agent_key in AGENT_SKILLS_DIR_OVERRIDES: + result = _get_skills_dir(project_dir, agent_key) + expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key] + assert result == expected + + +# ===== install_ai_skills Tests ===== + +class TestInstallAiSkills: + """Test SKILL.md generation and installation logic.""" + + def test_skills_installed_with_correct_structure(self, project_dir, templates_dir): + """Verify SKILL.md files have correct agentskills.io structure.""" + result = install_ai_skills(project_dir, "claude") + + assert result is True + + skills_dir = project_dir / ".claude" / "skills" + assert skills_dir.exists() + + # Check that skill directories were created + skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) + assert "speckit-plan" in skill_dirs + assert "speckit-specify" in skill_dirs + assert "speckit-tasks" in skill_dirs + assert "speckit-empty_fm" in skill_dirs + + # Verify SKILL.md content for speckit-specify + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Check agentskills.io frontmatter + assert content.startswith("---\n") + assert "name: speckit-specify" in content + assert "description:" in content + assert "compatibility:" in content + assert "metadata:" in content + assert "author: github-spec-kit" in content + assert "source: templates/commands/specify.md" in content + + # Check body content is included + assert "# Speckit Specify Skill" in content + assert "Run this to create a spec." in content + + def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir): + """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + + # Extract and parse frontmatter + assert content.startswith("---\n") + parts = content.split("---", 2) + assert len(parts) >= 3 + parsed = yaml.safe_load(parts[1]) + assert isinstance(parsed, dict) + assert "name" in parsed + assert parsed["name"] == "speckit-specify" + assert "description" in parsed + + def test_empty_yaml_frontmatter(self, project_dir, templates_dir): + """Templates with empty YAML frontmatter (---\\n---) should not crash.""" + result = install_ai_skills(project_dir, "claude") + + assert result is True + + skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: speckit-empty_fm" in content + assert "Body with empty frontmatter." in content + + def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir): + """SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions.""" + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + + # Parse the generated YAML to compare the description value + # (yaml.safe_dump may wrap long strings across multiple lines) + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + + if "specify" in SKILL_DESCRIPTIONS: + assert parsed["description"] == SKILL_DESCRIPTIONS["specify"] + + def test_template_without_frontmatter(self, project_dir, templates_dir): + """Templates without YAML frontmatter should still produce valid skills.""" + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Should still have valid SKILL.md structure + assert "name: speckit-tasks" in content + assert "Body without frontmatter." in content + + def test_missing_templates_directory(self, project_dir): + """Returns False when no command templates exist anywhere.""" + # No .claude/commands/ exists, and __file__ fallback won't find anything + fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + assert result is False + + # Skills directory should not exist + skills_dir = project_dir / ".claude" / "skills" + assert not skills_dir.exists() + + def test_empty_templates_directory(self, project_dir): + """Returns False when commands directory has no .md files.""" + # Create empty .claude/commands/ + empty_cmds = project_dir / ".claude" / "commands" + empty_cmds.mkdir(parents=True) + + # Block the __file__ fallback so it can't find real templates + fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + assert result is False + + def test_malformed_yaml_frontmatter(self, project_dir): + """Malformed YAML in a template should be handled gracefully, not crash.""" + # Create .claude/commands/ with a broken template + cmds_dir = project_dir / ".claude" / "commands" + cmds_dir.mkdir(parents=True) + + (cmds_dir / "broken.md").write_text( + "---\n" + "description: [unclosed bracket\n" + " invalid: yaml: content: here\n" + "---\n" + "\n" + "# Broken\n", + encoding="utf-8", + ) + + # Should not raise — errors are caught per-file + result = install_ai_skills(project_dir, "claude") + + # The broken template should be skipped but not crash the process + assert result is False + + def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir): + """Installing skills should not remove non-speckit files in the skills dir.""" + # Pre-create a custom skill + custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill" + custom_dir.mkdir(parents=True) + custom_file = custom_dir / "SKILL.md" + custom_file.write_text("# My Custom Skill\n") + + install_ai_skills(project_dir, "claude") + + # Custom skill should still exist + assert custom_file.exists() + assert custom_file.read_text() == "# My Custom Skill\n" + + def test_return_value(self, project_dir, templates_dir): + """install_ai_skills returns True when skills installed, False otherwise.""" + assert install_ai_skills(project_dir, "claude") is True + + def test_return_false_when_no_templates(self, project_dir): + """install_ai_skills returns False when no templates found.""" + fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + assert install_ai_skills(project_dir, "claude") is False + + def test_non_md_commands_dir_falls_back(self, project_dir): + """When extracted commands are .toml (e.g. gemini), fall back to repo templates.""" + # Simulate gemini template extraction: .gemini/commands/ with .toml files only + cmds_dir = project_dir / ".gemini" / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n') + (cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n') + + # The __file__ fallback should find the real repo templates/commands/*.md + result = install_ai_skills(project_dir, "gemini") + + assert result is True + skills_dir = project_dir / ".gemini" / "skills" + assert skills_dir.exists() + # Should have installed skills from the fallback .md templates + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert len(skill_dirs) >= 1 + # .toml commands should be untouched + assert (cmds_dir / "speckit.specify.toml").exists() + + @pytest.mark.parametrize("agent_key", list(AGENT_CONFIG.keys())) + def test_skills_install_for_all_agents(self, temp_dir, agent_key): + """install_ai_skills should produce skills for every configured agent.""" + proj = temp_dir / f"proj-{agent_key}" + proj.mkdir() + + # Place .md templates in the agent's commands directory + agent_folder = AGENT_CONFIG[agent_key]["folder"] + cmds_dir = proj / agent_folder.rstrip("/") / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "specify.md").write_text( + "---\ndescription: Test command\n---\n\n# Test\n\nBody.\n" + ) + + result = install_ai_skills(proj, agent_key) + + assert result is True + skills_dir = _get_skills_dir(proj, agent_key) + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert "speckit-specify" in skill_dirs + assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + + + +class TestCommandCoexistence: + """Verify install_ai_skills never touches command files. + + Cleanup of freshly-extracted commands for NEW projects is handled + in init(), not in install_ai_skills(). These tests confirm that + install_ai_skills leaves existing commands intact. + """ + + def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): + """install_ai_skills must NOT remove pre-existing .claude/commands files.""" + # Verify commands exist before + assert len(list(commands_dir_claude.glob("speckit.*"))) == 3 + + install_ai_skills(project_dir, "claude") + + # Commands must still be there — install_ai_skills never touches them + remaining = list(commands_dir_claude.glob("speckit.*")) + assert len(remaining) == 3 + + def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini): + """install_ai_skills must NOT remove pre-existing .gemini/commands files.""" + assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3 + + install_ai_skills(project_dir, "gemini") + + remaining = list(commands_dir_gemini.glob("speckit.*")) + assert len(remaining) == 3 + + def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): + """install_ai_skills must not remove the commands directory.""" + install_ai_skills(project_dir, "claude") + + assert commands_dir_claude.exists() + + def test_no_commands_dir_no_error(self, project_dir, templates_dir): + """No error when installing skills — commands dir has templates and is preserved.""" + result = install_ai_skills(project_dir, "claude") + + # Should succeed since templates are in .claude/commands/ via fixture + assert result is True + + +# ===== New-Project Command Skip Tests ===== + +class TestNewProjectCommandSkip: + """Test that init() removes extracted commands for new projects only. + + These tests run init() end-to-end via CliRunner with + download_and_extract_template patched to create local fixtures. + """ + + def _fake_extract(self, agent, project_path, **_kwargs): + """Simulate template extraction: create agent commands dir.""" + agent_cfg = AGENT_CONFIG.get(agent, {}) + agent_folder = agent_cfg.get("folder", "") + if agent_folder: + cmds_dir = project_path / agent_folder.rstrip("/") / "commands" + cmds_dir.mkdir(parents=True, exist_ok=True) + (cmds_dir / "speckit.specify.md").write_text("# spec") + + def test_new_project_commands_removed_after_skills_succeed(self, tmp_path): + """For new projects, commands should be removed when skills succeed.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "new-proj" + + def fake_download(project_path, *args, **kwargs): + self._fake_extract("claude", project_path) + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + + # Skills should have been called + mock_skills.assert_called_once() + + # Commands dir should have been removed after skills succeeded + cmds_dir = target / ".claude" / "commands" + assert not cmds_dir.exists() + + def test_commands_preserved_when_skills_fail(self, tmp_path): + """If skills fail, commands should NOT be removed (safety net).""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "fail-proj" + + def fake_download(project_path, *args, **kwargs): + self._fake_extract("claude", project_path) + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=False), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + + # Commands should still exist since skills failed + cmds_dir = target / ".claude" / "commands" + assert cmds_dir.exists() + assert (cmds_dir / "speckit.specify.md").exists() + + def test_here_mode_commands_preserved(self, tmp_path, monkeypatch): + """For --here on existing repos, commands must NOT be removed.""" + from typer.testing import CliRunner + + runner = CliRunner() + # Create a mock existing project with commands already present + target = tmp_path / "existing" + target.mkdir() + agent_folder = AGENT_CONFIG["claude"]["folder"] + cmds_dir = target / agent_folder.rstrip("/") / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text("# spec") + + # --here uses CWD, so chdir into the target + monkeypatch.chdir(target) + + def fake_download(project_path, *args, **kwargs): + pass # commands already exist, no need to re-create + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=True), \ + patch("specify_cli.is_git_repo", return_value=True), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + + # Commands must remain for --here + assert cmds_dir.exists() + assert (cmds_dir / "speckit.specify.md").exists() + + +# ===== Skip-If-Exists Tests ===== + +class TestSkipIfExists: + """Test that install_ai_skills does not overwrite existing SKILL.md files.""" + + def test_existing_skill_not_overwritten(self, project_dir, templates_dir): + """Pre-existing SKILL.md should not be replaced on re-run.""" + # Pre-create a custom SKILL.md for speckit-specify + skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" + skill_dir.mkdir(parents=True) + custom_content = "# My Custom Specify Skill\nUser-modified content\n" + (skill_dir / "SKILL.md").write_text(custom_content) + + result = install_ai_skills(project_dir, "claude") + + # The custom SKILL.md should be untouched + assert (skill_dir / "SKILL.md").read_text() == custom_content + + # But other skills should still be installed + assert result is True + assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists() + + def test_fresh_install_writes_all_skills(self, project_dir, templates_dir): + """On first install (no pre-existing skills), all should be written.""" + result = install_ai_skills(project_dir, "claude") + + assert result is True + skills_dir = project_dir / ".claude" / "skills" + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + # All 4 templates should produce skills (specify, plan, tasks, empty_fm) + assert len(skill_dirs) == 4 + + +# ===== SKILL_DESCRIPTIONS Coverage Tests ===== + +class TestSkillDescriptions: + """Test SKILL_DESCRIPTIONS constants.""" + + def test_all_known_commands_have_descriptions(self): + """All standard spec-kit commands should have enhanced descriptions.""" + expected_commands = [ + "specify", "plan", "tasks", "implement", "analyze", + "clarify", "constitution", "checklist", "taskstoissues", + ] + for cmd in expected_commands: + assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'" + assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short" + + +# ===== CLI Validation Tests ===== + +class TestCliValidation: + """Test --ai-skills CLI flag validation.""" + + def test_ai_skills_without_ai_fails(self): + """--ai-skills without --ai should fail with exit code 1.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + + assert result.exit_code == 1 + assert "--ai-skills requires --ai" in result.output + + def test_ai_skills_without_ai_shows_usage(self): + """Error message should include usage hint.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + + assert "Usage:" in result.output + assert "--ai" in result.output + + def test_ai_skills_flag_appears_in_help(self): + """--ai-skills should appear in init --help output.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "--help"]) + + assert "--ai-skills" in result.output + assert "agent skills" in result.output.lower() From 0f7d04b12ba27385d1c97186925be880ead05fc7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:49:34 -0600 Subject: [PATCH 006/321] feat: add pull request template for better contribution guidelines (#1634) --- .github/PULL_REQUEST_TEMPLATE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..68c4aeb255 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Description + + + +## Testing + + + +- [ ] Tested locally with `uv run specify --help` +- [ ] Ran existing tests with `uv sync && uv run pytest` +- [ ] Tested with a sample project (if applicable) + +## AI Disclosure + + + + +- [ ] I **did not** use AI assistance for this contribution +- [ ] I **did** use AI assistance (describe below) + + + From 24d76b5d928fdef54c321096e84e0b52aa72773e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:21:58 -0600 Subject: [PATCH 007/321] Add pytest and Python linting (ruff) to CI (#1637) * feat: add GitHub Actions workflow for testing and linting Python code * fix: resolve ruff lint errors in specify_cli - Remove extraneous f-string prefixes (F541) - Split multi-statement lines (E701, E702) - Remove unused variable assignments (F841) - Remove ruff format check from CI workflow (format-only PR to follow) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: strip ANSI codes in ai-skills help text test The Rich/Typer CLI injects ANSI escape codes into option names in --help output, causing plain string matching to fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test.yml | 50 ++++++++++++++++++++++++++++++++++ src/specify_cli/__init__.py | 54 ++++++++++++++++++++----------------- tests/test_ai_skills.py | 6 +++-- 3 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..60e3114c85 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Test & Lint Python + +permissions: + contents: read + +on: + push: + branches: ["main"] + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Run ruff check + run: uvx ruff check src/ + + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra test + + - name: Run tests + run: uv run pytest diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 52e2290a35..3794933211 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -670,7 +670,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri except ValueError as je: raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") except Exception as e: - console.print(f"[red]Error fetching release information[/red]") + console.print("[red]Error fetching release information[/red]") console.print(Panel(str(e), title="Fetch Error", border_style="red")) raise typer.Exit(1) @@ -700,7 +700,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri zip_path = download_dir / filename if verbose: - console.print(f"[cyan]Downloading template...[/cyan]") + console.print("[cyan]Downloading template...[/cyan]") try: with client.stream( @@ -739,7 +739,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) except Exception as e: - console.print(f"[red]Error downloading template[/red]") + console.print("[red]Error downloading template[/red]") detail = str(e) if zip_path.exists(): zip_path.unlink() @@ -823,7 +823,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Found nested directory structure[/cyan]") + console.print("[cyan]Found nested directory structure[/cyan]") for item in source_dir.iterdir(): dest_path = project_path / item.name @@ -848,7 +848,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") shutil.copy2(item, dest_path) if verbose and not tracker: - console.print(f"[cyan]Template files merged into current directory[/cyan]") + console.print("[cyan]Template files merged into current directory[/cyan]") else: zip_ref.extractall(project_path) @@ -874,7 +874,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Flattened nested directory structure[/cyan]") + console.print("[cyan]Flattened nested directory structure[/cyan]") except Exception as e: if tracker: @@ -924,13 +924,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = continue except Exception: continue - st = script.stat(); mode = st.st_mode + st = script.stat() + mode = st.st_mode if mode & 0o111: continue new_mode = mode - if mode & 0o400: new_mode |= 0o100 - if mode & 0o040: new_mode |= 0o010 - if mode & 0o004: new_mode |= 0o001 + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 if not (new_mode & 0o100): new_mode |= 0o100 os.chmod(script, new_mode) @@ -976,7 +980,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") else: - console.print(f"[cyan]Initialized constitution from template[/cyan]") + console.print("[cyan]Initialized constitution from template[/cyan]") except Exception as e: if tracker: tracker.add("constitution", "Constitution setup") @@ -1510,9 +1514,9 @@ def init( enhancement_lines = [ "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", "", - f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", - f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", + "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", + "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" ] enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) console.print() @@ -1545,10 +1549,10 @@ def check(): # Check VS Code variants (not in agent config) tracker.add("code", "Visual Studio Code") - code_ok = check_tool("code", tracker=tracker) + check_tool("code", tracker=tracker) tracker.add("code-insiders", "Visual Studio Code Insiders") - code_insiders_ok = check_tool("code-insiders", tracker=tracker) + check_tool("code-insiders", tracker=tracker) console.print(tracker.render()) @@ -1814,14 +1818,14 @@ def extension_add( if zip_path.exists(): zip_path.unlink() - console.print(f"\n[green]✓[/green] Extension installed successfully!") + console.print("\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") console.print(f" {manifest.description}") - console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") + console.print("\n[bold cyan]Provided commands:[/bold cyan]") for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") - console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{manifest.id}/") except ValidationError as e: @@ -1871,11 +1875,11 @@ def extension_remove( # Confirm removal if not force: - console.print(f"\n[yellow]⚠ This will remove:[/yellow]") + console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") console.print(f" • Extension directory: .specify/extensions/{extension}/") if not keep_config: - console.print(f" • Config files (will be backed up)") + console.print(" • Config files (will be backed up)") console.print() confirm = typer.confirm("Continue?") @@ -1894,7 +1898,7 @@ def extension_remove( console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") console.print(f"\nTo reinstall: specify extension add {extension}") else: - console.print(f"[red]Error:[/red] Failed to remove extension") + console.print("[red]Error:[/red] Failed to remove extension") raise typer.Exit(1) @@ -2169,8 +2173,8 @@ def extension_update( # TODO: Implement download and reinstall from URL # For now, just show message console.print( - f"[yellow]Note:[/yellow] Automatic update not yet implemented. " - f"Please update manually:" + "[yellow]Note:[/yellow] Automatic update not yet implemented. " + "Please update manually:" ) console.print(f" specify extension remove {ext_id} --keep-config") console.print(f" specify extension add {ext_id}") @@ -2270,7 +2274,7 @@ def extension_disable( hook_executor.save_project_config(config) console.print(f"[green]✓[/green] Extension '{extension}' disabled") - console.print(f"\nCommands will no longer be available. Hooks will not execute.") + console.print("\nCommands will no longer be available. Hooks will not execute.") console.print(f"To re-enable: specify extension enable {extension}") diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 1b1b71e3a0..b86b4a470a 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -10,6 +10,7 @@ - CLI validation: --ai-skills requires --ai """ +import re import pytest import tempfile import shutil @@ -626,5 +627,6 @@ def test_ai_skills_flag_appears_in_help(self): runner = CliRunner() result = runner.invoke(app, ["init", "--help"]) - assert "--ai-skills" in result.output - assert "agent skills" in result.output.lower() + plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + assert "--ai-skills" in plain + assert "agent skills" in plain.lower() From 04fc3fd1ba394001e7cec87c087dad651e10b56a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:31:23 -0600 Subject: [PATCH 008/321] chore(deps): bump github/codeql-action from 3 to 4 (#1635) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 55829ce5cd..01e0df4a51 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,11 +22,11 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{ matrix.language }}" From 465acd9024c5644acb8b4115509c9e9d5b21b953 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:46:29 -0600 Subject: [PATCH 009/321] fix: include 'src/**' path in release workflow triggers (#1646) Co-authored-by: Manfred Riem --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ad2087436..1890587c2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: paths: - 'memory/**' - 'scripts/**' + - 'src/**' - 'templates/**' - '.github/workflows/**' workflow_dispatch: From 6fca5d83b21594f92417e72b30ac3f17cb1989c7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:22:16 -0600 Subject: [PATCH 010/321] fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation crash (#1648) * fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation failures Fixes #1631. When uv installs specify-cli on macOS with Homebrew Python 3.14, the virtual environment can fail to fully isolate from the system site-packages, causing Homebrew's click to be loaded instead of the one uv installed. If that system click is older than 8.1, it lacks the `ctx` keyword argument in `ParamType.get_metavar()`, which typer 0.24.0 requires, resulting in: TypeError: ParamType.get_metavar() got an unexpected keyword argument 'ctx' Adding an explicit `click>=8.1` dependency gives uv a hard constraint so the correct version is always resolved and installed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ pyproject.toml | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d945780015..ab4198beb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.2] - 2026-02-20 + +### Fixed + +- **Python 3.14 / Homebrew compatibility**: Added explicit `click>=8.1` dependency so the resolver always selects a Click version compatible with Python 3.14 and avoids errors such as `TypeError: ParamType.get_metavar() got an unexpected keyword argument 'ctx'` when an older Click would otherwise be used. Note that broader uv/Homebrew environment isolation or `sys.path` bleed issues (see [#1631](https://github.com/github/spec-kit/issues/1631)) may still require environment-level workarounds. + ## [0.1.1] - 2026-02-13 ### Added diff --git a/pyproject.toml b/pyproject.toml index 7ca679cb08..73b50738bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "specify-cli" -version = "0.1.1" +version = "0.1.2" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ "typer", + "click>=8.1", "rich", "httpx[socks]", "platformdirs", From 6150f1e31747a387119bf1456128b90a05fed15e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:05:38 -0600 Subject: [PATCH 011/321] Add generic agent support with customizable command directories (#1639) - Add --ai generic option for unsupported AI agents (bring your own agent) - Require --ai-commands-dir to specify where agent reads commands from - Generate Markdown commands with $ARGUMENTS format (compatible with most agents) - Rebuild CHANGELOG from GitHub releases (last 10 releases) - Align version to 0.1.3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 8 + .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 8 +- .../scripts/create-release-packages.sh | 7 +- AGENTS.md | 1 + CHANGELOG.md | 315 ++---------------- README.md | 7 +- pyproject.toml | 2 +- scripts/bash/update-agent-context.sh | 5 +- scripts/powershell/update-agent-context.ps1 | 7 +- src/specify_cli/__init__.py | 55 ++- tests/test_ai_skills.py | 2 +- 12 files changed, 110 insertions(+), 309 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1890587c2f..30f28f3210 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,4 +58,12 @@ jobs: run: | chmod +x .github/workflows/scripts/update-version.sh .github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }} + - name: Commit version bump to main + if: steps.check_release.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git diff --cached --quiet || git commit -m "chore: bump version to ${{ steps.get_tag.outputs.new_version }} [skip ci]" + git push diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 56f1bac85d..a122daf8e0 100644 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -52,5 +52,7 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ --notes-file release_notes.md diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index a59df6e13f..5953ca91a2 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -347,6 +347,10 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".qoder/commands" Generate-Commands -Agent 'qoder' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'generic' { + $cmdDir = Join-Path $baseDir ".speckit/commands" + Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } } # Create zip archive @@ -356,7 +360,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index a825bd7bb9..1ea1482a8f 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -221,13 +221,16 @@ build_variant() { bob) mkdir -p "$base_dir/.bob/commands" generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; + generic) + mkdir -p "$base_dir/.speckit/commands" + generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; esac ( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . ) echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qoder) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qoder generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index d7360487b8..15137bc92b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | +| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | ### Step-by-Step Integration Guide diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4198beb7..dd0d528e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,318 +2,59 @@ - All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.2] - 2026-02-20 - -### Fixed - -- **Python 3.14 / Homebrew compatibility**: Added explicit `click>=8.1` dependency so the resolver always selects a Click version compatible with Python 3.14 and avoids errors such as `TypeError: ParamType.get_metavar() got an unexpected keyword argument 'ctx'` when an older Click would otherwise be used. Note that broader uv/Homebrew environment isolation or `sys.path` bleed issues (see [#1631](https://github.com/github/spec-kit/issues/1631)) may still require environment-level workarounds. - -## [0.1.1] - 2026-02-13 - -### Added - -- **Agent Skills Installation**: New `--ai-skills` CLI option to install Prompt.MD templates as agent skills following [agentskills.io specification](https://agentskills.io/specification) - - Skills are installed to agent-specific directories (e.g., `.claude/skills/`, `.gemini/skills/`, `.github/skills/`) - - Codex uses `.agents/skills/` following Codex agent directory conventions - - Default fallback directory is `.agents/skills/` for agents without a specific mapping - - Requires `--ai` flag to be specified - - Converts all 9 spec-kit command templates (specify, plan, tasks, implement, analyze, clarify, constitution, checklist, taskstoissues) to properly formatted SKILL.md files - - **New projects**: command files are not installed when `--ai-skills` is used (skills replace commands) - - **Existing repos** (`--here`): pre-existing command files are preserved — no breaking changes - - `pyyaml` dependency (already present) used for YAML frontmatter parsing -- **Unit tests** for `install_ai_skills`, `_get_skills_dir`, and `--ai-skills` CLI validation (51 test cases covering all 18 supported agents) - -## [0.1.0] - 2026-01-28 - -### Added - -- **Extension System**: Introduced modular extension architecture for Spec Kit - - Extensions are self-contained packages that add commands and functionality without bloating core - - Extension manifest schema (`extension.yml`) with validation - - Extension registry (`.specify/extensions/.registry`) for tracking installed extensions - - Extension manager module (`src/specify_cli/extensions.py`) for installation/removal - - New CLI commands: - - `specify extension list` - List installed extensions - - `specify extension add` - Install extension from local directory or URL - - `specify extension remove` - Uninstall extension - - `specify extension search` - Search extension catalog - - `specify extension info` - Show detailed extension information - - Semantic versioning compatibility checks - - Support for extension configuration files - - Command registration system for AI agents (Claude support initially) - - Added dependencies: `pyyaml>=6.0`, `packaging>=23.0` - -- **Extension Catalog**: Extension discovery and distribution system - - Central catalog (`extensions/catalog.json`) for published extensions - - Extension catalog manager (`ExtensionCatalog` class) with: - - Catalog fetching from GitHub - - 1-hour local caching for performance - - Search by query, tag, author, or verification status - - Extension info retrieval - - Catalog cache stored in `.specify/extensions/.cache/` - - Search and info commands with rich console output - - Added 9 catalog-specific unit tests (100% pass rate) - -- **Jira Extension**: First official extension for Jira integration - - Extension ID: `jira` - - Version: 1.0.0 - - Commands: - - `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks - - `/speckit.jira.discover-fields` - Discover Jira custom fields - - `/speckit.jira.sync-status` - Sync task completion status - - Comprehensive documentation (README, usage guide, examples) - - MIT licensed - -- **Hook System**: Extension lifecycle hooks for automation - - `HookExecutor` class for managing extension hooks - - Hooks registered in `.specify/extensions.yml` - - Hook registration during extension installation - - Hook unregistration during extension removal - - Support for optional and mandatory hooks - - Hook execution messages for AI agent integration - - Condition support for conditional hook execution (placeholder) - -- **Extension Management**: Advanced extension management commands - - `specify extension update` - Check and update extensions to latest version - - `specify extension enable` - Enable a disabled extension - - `specify extension disable` - Disable extension without removing it - - Version comparison with catalog - - Update notifications - - Preserve configuration during updates - -- **Multi-Agent Support**: Extensions now work with all supported AI agents (Phase 6) - - Automatic detection and registration for all agents in project - - Support for 16+ AI agents (Claude, Gemini, Copilot, Cursor, Qwen, and more) - - Agent-specific command formats (Markdown and TOML) - - Automatic argument placeholder conversion ($ARGUMENTS → {{args}}) - - Commands registered for all detected agents during installation - - Multi-agent command unregistration on extension removal - - `CommandRegistrar.register_commands_for_agent()` method - - `CommandRegistrar.register_commands_for_all_agents()` method - -- **Configuration Layers**: Full configuration cascade system (Phase 6) - - **Layer 1**: Defaults from extension manifest (`extension.yml`) - - **Layer 2**: Project config (`.specify/extensions/{ext-id}/{ext-id}-config.yml`) - - **Layer 3**: Local config (`.specify/extensions/{ext-id}/local-config.yml`, gitignored) - - **Layer 4**: Environment variables (`SPECKIT_{EXT_ID}_{KEY}` pattern) - - Recursive config merging with proper precedence - - `ConfigManager` class for programmatic config access - - `get_config()`, `get_value()`, `has_value()` methods - - Support for nested configuration paths with dot-notation - -- **Hook Condition Evaluation**: Smart hook execution based on runtime conditions (Phase 6) - - Config conditions: `config.key.path is set`, `config.key == 'value'`, `config.key != 'value'` - - Environment conditions: `env.VAR is set`, `env.VAR == 'value'`, `env.VAR != 'value'` - - Automatic filtering of hooks based on condition evaluation - - Safe fallback behavior on evaluation errors - - Case-insensitive pattern matching - -- **Hook Integration**: Agent-level hook checking and execution (Phase 6) - - `check_hooks_for_event()` method for AI agents to query hooks after core commands - - Condition-aware hook filtering before execution - - `enable_hooks()` and `disable_hooks()` methods per extension - - Formatted hook messages for agent display - - `execute_hook()` method for hook execution information - -- **Documentation Suite**: Comprehensive documentation for users and developers - - **EXTENSION-USER-GUIDE.md**: Complete user guide with installation, usage, configuration, and troubleshooting - - **EXTENSION-API-REFERENCE.md**: Technical API reference with manifest schema, Python API, and CLI commands - - **EXTENSION-PUBLISHING-GUIDE.md**: Publishing guide for extension authors - - **RFC-EXTENSION-SYSTEM.md**: Extension architecture design document - -- **Extension Template**: Starter template in `extensions/template/` for creating new extensions - - Fully commented `extension.yml` manifest template - - Example command file with detailed explanations - - Configuration template with all options - - Complete project structure (README, LICENSE, CHANGELOG, .gitignore) - - EXAMPLE-README.md showing final documentation format - -- **Unit Tests**: Comprehensive test suite with 39 tests covering all extension system components - - Test coverage: 83% of extension module code - - Test dependencies: `pytest>=7.0`, `pytest-cov>=4.0` - - Configured pytest in `pyproject.toml` - -### Changed - -- Version bumped to 0.1.0 (minor release for new feature) - -## [0.0.22] - 2025-11-07 - -- Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs. -- Move to use `AGENTS.md` for Copilot workloads, since it's already supported out-of-the-box. -- Adds support for the version command. ([#486](https://github.com/github/spec-kit/issues/486)) -- Fixes potential bug with the `create-new-feature.ps1` script that ignores existing feature branches when determining next feature number ([#975](https://github.com/github/spec-kit/issues/975)) -- Add graceful fallback and logging for GitHub API rate-limiting during template fetch ([#970](https://github.com/github/spec-kit/issues/970)) - -## [0.0.21] - 2025-10-21 - -- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)). -- Adds support for Amp CLI. -- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes. -- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/mcasalaina) and [@dentity007](https://github.com/dentity007)). -- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)). - -## [0.0.20] - 2025-10-14 - -### Added - -- **Intelligent Branch Naming**: `create-new-feature` scripts now support `--short-name` parameter for custom branch names - - When `--short-name` provided: Uses the custom name directly (cleaned and formatted) - - When omitted: Automatically generates meaningful names using stop word filtering and length-based filtering - - Filters out common stop words (I, want, to, the, for, etc.) - - Removes words shorter than 3 characters (unless they're uppercase acronyms) - - Takes 3-4 most meaningful words from the description - - **Enforces GitHub's 244-byte branch name limit** with automatic truncation and warnings - - Examples: - - "I want to create user authentication" → `001-create-user-authentication` - - "Implement OAuth2 integration for API" → `001-implement-oauth2-integration-api` - - "Fix payment processing bug" → `001-fix-payment-processing` - - Very long descriptions are automatically truncated at word boundaries to stay within limits - - Designed for AI agents to provide semantic short names while maintaining standalone usability - -### Changed - -- Enhanced help documentation for `create-new-feature.sh` and `create-new-feature.ps1` scripts with examples -- Branch names now validated against GitHub's 244-byte limit with automatic truncation if needed - -## [0.0.19] - 2025-10-10 - -### Added - -- Support for CodeBuddy (thank you to [@lispking](https://github.com/lispking) for the contribution). -- You can now see Git-sourced errors in the Specify CLI. - -### Changed - -- Fixed the path to the constitution in `plan.md` (thank you to [@lyzno1](https://github.com/lyzno1) for spotting). -- Fixed backslash escapes in generated TOML files for Gemini (thank you to [@hsin19](https://github.com/hsin19) for the contribution). -- Implementation command now ensures that the correct ignore files are added (thank you to [@sigent-amazon](https://github.com/sigent-amazon) for the contribution). - -## [0.0.18] - 2025-10-06 +## [0.1.3] - Unreleased ### Added -- Support for using `.` as a shorthand for current directory in `specify init .` command, equivalent to `--here` flag but more intuitive for users. -- Use the `/speckit.` command prefix to easily discover Spec Kit-related commands. -- Refactor the prompts and templates to simplify their capabilities and how they are tracked. No more polluting things with tests when they are not needed. -- Ensure that tasks are created per user story (simplifies testing and validation). -- Add support for Visual Studio Code prompt shortcuts and automatic script execution. +- **Generic Agent Support**: Added `--ai generic` option for unsupported AI agents ("bring your own agent") + - Requires `--ai-commands-dir ` to specify where the agent reads commands from + - Generates Markdown commands with `$ARGUMENTS` format (compatible with most agents) + - Example: `specify init my-project --ai generic --ai-commands-dir .myagent/commands/` + - Enables users to start with Spec Kit immediately while their agent awaits formal support -### Changed +## [0.0.102] - 2026-02-20 -- All command files now prefixed with `speckit.` (e.g., `speckit.specify.md`, `speckit.plan.md`) for better discoverability and differentiation in IDE/CLI command palettes and file explorers +- fix: include 'src/**' path in release workflow triggers (#1646) -## [0.0.17] - 2025-09-22 +## [0.0.101] - 2026-02-19 -### Added - -- New `/clarify` command template to surface up to 5 targeted clarification questions for an existing spec and persist answers into a Clarifications section in the spec. -- New `/analyze` command template providing a non-destructive cross-artifact discrepancy and alignment report (spec, clarifications, plan, tasks, constitution) inserted after `/tasks` and before `/implement`. - - Note: Constitution rules are explicitly treated as non-negotiable; any conflict is a CRITICAL finding requiring artifact remediation, not weakening of principles. - -## [0.0.16] - 2025-09-22 - -### Added - -- `--force` flag for `init` command to bypass confirmation when using `--here` in a non-empty directory and proceed with merging/overwriting files. - -## [0.0.15] - 2025-09-21 - -### Added - -- Support for Roo Code. - -## [0.0.14] - 2025-09-21 +- chore(deps): bump github/codeql-action from 3 to 4 (#1635) -### Changed +## [0.0.100] - 2026-02-19 -- Error messages are now shown consistently. +- Add pytest and Python linting (ruff) to CI (#1637) +- feat: add pull request template for better contribution guidelines (#1634) -## [0.0.13] - 2025-09-21 +## [0.0.99] - 2026-02-19 -### Added - -- Support for Kilo Code. Thank you [@shahrukhkhan489](https://github.com/shahrukhkhan489) with [#394](https://github.com/github/spec-kit/pull/394). -- Support for Auggie CLI. Thank you [@hungthai1401](https://github.com/hungthai1401) with [#137](https://github.com/github/spec-kit/pull/137). -- Agent folder security notice displayed after project provisioning completion, warning users that some agents may store credentials or auth tokens in their agent folders and recommending adding relevant folders to `.gitignore` to prevent accidental credential leakage. - -### Changed - -- Warning displayed to ensure that folks are aware that they might need to add their agent folder to `.gitignore`. -- Cleaned up the `check` command output. - -## [0.0.12] - 2025-09-21 - -### Changed - -- Added additional context for OpenAI Codex users - they need to set an additional environment variable, as described in [#417](https://github.com/github/spec-kit/issues/417). - -## [0.0.11] - 2025-09-20 - -### Added +- Feat/ai skills (#1632) -- Codex CLI support (thank you [@honjo-hiroaki-gtt](https://github.com/honjo-hiroaki-gtt) for the contribution in [#14](https://github.com/github/spec-kit/pull/14)) -- Codex-aware context update tooling (Bash and PowerShell) so feature plans refresh `AGENTS.md` alongside existing assistants without manual edits. +## [0.0.98] - 2026-02-19 -## [0.0.10] - 2025-09-20 +- chore(deps): bump actions/stale from 9 to 10 (#1623) +- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) -### Fixed +## [0.0.97] - 2026-02-18 -- Addressed [#378](https://github.com/github/spec-kit/issues/378) where a GitHub token may be attached to the request when it was empty. +- Remove Maintainers section from README.md (#1618) -## [0.0.9] - 2025-09-19 +## [0.0.96] - 2026-02-17 -### Changed +- fix: typo in plan-template.md (#1446) -- Improved agent selector UI with cyan highlighting for agent keys and gray parentheses for full names - -## [0.0.8] - 2025-09-19 - -### Added - -- Windsurf IDE support as additional AI assistant option (thank you [@raedkit](https://github.com/raedkit) for the work in [#151](https://github.com/github/spec-kit/pull/151)) -- GitHub token support for API requests to handle corporate environments and rate limiting (contributed by [@zryfish](https://github.com/@zryfish) in [#243](https://github.com/github/spec-kit/pull/243)) - -### Changed - -- Updated README with Windsurf examples and GitHub token usage -- Enhanced release workflow to include Windsurf templates - -## [0.0.7] - 2025-09-18 - -### Changed - -- Updated command instructions in the CLI. -- Cleaned up the code to not render agent-specific information when it's generic. - -## [0.0.6] - 2025-09-17 - -### Added - -- opencode support as additional AI assistant option - -## [0.0.5] - 2025-09-17 - -### Added - -- Qwen Code support as additional AI assistant option - -## [0.0.4] - 2025-09-14 - -### Added +## [0.0.95] - 2026-02-12 -- SOCKS proxy support for corporate environments via `httpx[socks]` dependency +- Feat: add a new agent: Google Anti Gravity (#1220) -### Fixed +## [0.0.94] - 2026-02-11 -N/A +- Add stale workflow for 180-day inactive issues and PRs (#1594) -### Changed +## [0.0.93] - 2026-02-10 -N/A +- Add modular extension system (#1551) diff --git a/README.md b/README.md index f1ead5c05f..e3333c24ec 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://agy.ai/) | ✅ | | +| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | ## 🔧 Specify CLI Reference @@ -179,7 +180,8 @@ The `specify` command supports the following options: | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, or `qoder` | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qoder`, or `generic` (requires `--ai-commands-dir`) | +| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | | `--no-git` | Flag | Skip git repository initialization | @@ -217,6 +219,9 @@ specify init my-project --ai shai # Initialize with IBM Bob support specify init my-project --ai bob +# Initialize with an unsupported agent (generic / bring your own agent) +specify init my-project --ai generic --ai-commands-dir .myagent/commands/ + # Initialize with PowerShell scripts (Windows/cross-platform) specify init my-project --ai copilot --script ps diff --git a/pyproject.toml b/pyproject.toml index 73b50738bc..b1e87c5168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.2" +version = "0.1.3" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 40db5e30af..d55accf927 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -637,9 +637,12 @@ update_specific_agent() { bob) update_agent_file "$BOB_FILE" "IBM Bob" ;; + generic) + log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." + ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|agy|bob|qoder" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|agy|bob|qoder|generic" exit 1 ;; esac diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index eb46b5bece..d495dbf54d 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qoder')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qoder','generic')] [string]$AgentType ) @@ -390,7 +390,8 @@ function Update-SpecificAgent { 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder'; return $false } + 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder|generic'; return $false } } } @@ -427,7 +428,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3794933211..d2f7c44178 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -233,6 +233,12 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": None, # IDE-based "requires_cli": False, }, + "generic": { + "name": "Generic (bring your own agent)", + "folder": None, # Set dynamically via --ai-commands-dir + "install_url": None, + "requires_cli": False, + }, } SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} @@ -1188,7 +1194,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, or qoder "), + ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qoder, or generic (requires --ai-commands-dir)"), + ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), @@ -1224,6 +1231,7 @@ def init( specify init --here --force # Skip confirmation when current directory not empty specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills + specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent """ show_banner() @@ -1308,6 +1316,16 @@ def init( "copilot" ) + # Validate --ai-commands-dir usage + if selected_ai == "generic": + if not ai_commands_dir: + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") + console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") + raise typer.Exit(1) + elif ai_commands_dir: + console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") + raise typer.Exit(1) + if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: @@ -1383,6 +1401,18 @@ def init( download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + # For generic agent, rename placeholder directory to user-specified path + if selected_ai == "generic" and ai_commands_dir: + placeholder_dir = project_path / ".speckit" / "commands" + target_dir = project_path / ai_commands_dir + if placeholder_dir.is_dir(): + target_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(placeholder_dir), str(target_dir)) + # Clean up empty .speckit dir if it's now empty + speckit_dir = project_path / ".speckit" + if speckit_dir.is_dir() and not any(speckit_dir.iterdir()): + speckit_dir.rmdir() + ensure_executable_scripts(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker) @@ -1468,16 +1498,17 @@ def init( # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: - agent_folder = agent_config["folder"] - security_notice = Panel( - f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" - f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", - title="[yellow]Agent Folder Security[/yellow]", - border_style="yellow", - padding=(1, 2) - ) - console.print() - console.print(security_notice) + agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] + if agent_folder: + security_notice = Panel( + f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" + f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", + title="[yellow]Agent Folder Security[/yellow]", + border_style="yellow", + padding=(1, 2) + ) + console.print() + console.print(security_notice) steps_lines = [] if not here: @@ -1535,6 +1566,8 @@ def check(): agent_results = {} for agent_key, agent_config in AGENT_CONFIG.items(): + if agent_key == "generic": + continue # Generic is not a real agent to check agent_name = agent_config["name"] requires_cli = agent_config["requires_cli"] diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index b86b4a470a..d4c607b643 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -380,7 +380,7 @@ def test_non_md_commands_dir_falls_back(self, project_dir): # .toml commands should be untouched assert (cmds_dir / "speckit.specify.toml").exists() - @pytest.mark.parametrize("agent_key", list(AGENT_CONFIG.keys())) + @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) def test_skills_install_for_all_agents(self, temp_dir, agent_key): """install_ai_skills should produce skills for every configured agent.""" proj = temp_dir / f"proj-{agent_key}" From fc3b98ea09acfdbc27bd69a1ed93ecb072baf339 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:30:16 -0600 Subject: [PATCH 012/321] fix: rename Qoder AGENT_CONFIG key from 'qoder' to 'qodercli' to match actual CLI executable (#1651) * fix: rename Qoder CLI to QoderCLI across scripts and documentation * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/scripts/create-release-packages.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/scripts/create-github-release.sh | 4 ++-- .github/workflows/scripts/create-release-packages.ps1 | 8 ++++---- .github/workflows/scripts/create-release-packages.sh | 6 +++--- AGENTS.md | 4 ++-- CHANGELOG.md | 6 ++++++ README.md | 6 +++--- pyproject.toml | 2 +- scripts/bash/update-agent-context.sh | 8 ++++---- scripts/powershell/update-agent-context.ps1 | 10 +++++----- src/specify_cli/__init__.py | 4 ++-- src/specify_cli/extensions.py | 2 +- 11 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index a122daf8e0..29f5024f8c 100644 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -40,8 +40,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-roo-ps-"$VERSION".zip \ .genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \ .genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qoder-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qoder-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \ .genreleases/spec-kit-template-amp-sh-"$VERSION".zip \ .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 5953ca91a2..ed04d9cd3f 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qodercli, shai, agy, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -343,9 +343,9 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".bob/commands" Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } - 'qoder' { + 'qodercli' { $cmdDir = Join-Path $baseDir ".qoder/commands" - Generate-Commands -Agent 'qoder' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } 'generic' { $cmdDir = Join-Path $baseDir ".speckit/commands" @@ -360,7 +360,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qodercli', 'shai', 'agy', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 1ea1482a8f..1b2ced3ea3 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -203,9 +203,9 @@ build_variant() { codebuddy) mkdir -p "$base_dir/.codebuddy/commands" generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;; - qoder) + qodercli) mkdir -p "$base_dir/.qoder/commands" - generate_commands qoder md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; + generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; amp) mkdir -p "$base_dir/.agents/commands" generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;; @@ -230,7 +230,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qoder generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qodercli generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index 15137bc92b..3a11e3eee7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI | | **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | | **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | -| **Qoder CLI** | `.qoder/commands/` | Markdown | `qoder` | Qoder CLI | +| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | | **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI | | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | @@ -314,7 +314,7 @@ Require a command-line tool to be installed: - **opencode**: `opencode` CLI - **Amazon Q Developer CLI**: `q` CLI - **CodeBuddy CLI**: `codebuddy` CLI -- **Qoder CLI**: `qoder` CLI +- **Qoder CLI**: `qodercli` CLI - **Amp**: `amp` CLI - **SHAI**: `shai` CLI diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0d528e47..ab20202cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] - Unreleased + +### Fixed + +- **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `"qoder"` to `"qodercli"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures + ## [0.1.3] - Unreleased ### Added diff --git a/README.md b/README.md index e3333c24ec..85a9a8f1e9 100644 --- a/README.md +++ b/README.md @@ -173,14 +173,14 @@ The `specify` command supports the following options: | Command | Description | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qoder`) | +| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qodercli`) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qoder`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | @@ -205,7 +205,7 @@ specify init my-project --ai claude specify init my-project --ai cursor-agent # Initialize with Qoder support -specify init my-project --ai qoder +specify init my-project --ai qodercli # Initialize with Windsurf support specify init my-project --ai windsurf diff --git a/pyproject.toml b/pyproject.toml index b1e87c5168..7af6f963ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.3" +version = "0.1.4" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index d55accf927..a33ea5cdee 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -35,7 +35,7 @@ # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli # Leave empty to update all existing agent files set -e @@ -619,7 +619,7 @@ update_specific_agent() { codebuddy) update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" ;; - qoder) + qodercli) update_agent_file "$QODER_FILE" "Qoder CLI" ;; amp) @@ -642,7 +642,7 @@ update_specific_agent() { ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|agy|bob|qoder|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic" exit 1 ;; esac @@ -755,7 +755,7 @@ print_summary() { echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index d495dbf54d..61718e96ce 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qoder) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qodercli) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qoder','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qodercli','generic')] [string]$AgentType ) @@ -384,14 +384,14 @@ function Update-SpecificAgent { 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'qoder' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } + 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic'; return $false } } } @@ -428,7 +428,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d2f7c44178..c8c01f4c98 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -191,7 +191,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://www.codebuddy.ai/cli", "requires_cli": True, }, - "qoder": { + "qodercli": { "name": "Qoder CLI", "folder": ".qoder/", "install_url": "https://qoder.com/cli", @@ -1194,7 +1194,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qoder, or generic (requires --ai-commands-dir)"), + ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qodercli, or generic (requires --ai-commands-dir)"), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 08ce2beab3..b8881e7c89 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -647,7 +647,7 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, - "qoder": { + "qodercli": { "dir": ".qoder/commands", "format": "markdown", "args": "$ARGUMENTS", From 12405c01e1008ee31403155552a987851537252b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:46:56 -0600 Subject: [PATCH 013/321] refactor: remove OpenAPI/GraphQL bias from templates (#1652) * chore: bump version to v0.0.5 [skip ci] * refactor: update documentation for interface contracts and integration patterns * Update pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update templates/commands/tasks.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update templates/commands/tasks.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- templates/commands/plan.md | 9 +++++---- templates/commands/specify.md | 2 +- templates/commands/tasks.md | 10 +++++----- templates/plan-template.md | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 147da0afa0..00e83eabd0 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -75,10 +75,11 @@ You **MUST** consider the user input before proceeding (if not empty). - Validation rules from requirements - State transitions if applicable -2. **Generate API contracts** from functional requirements: - - For each user action → endpoint - - Use standard REST/GraphQL patterns - - Output OpenAPI/GraphQL schema to `/contracts/` +2. **Define interface contracts** (if project has external interfaces) → `/contracts/`: + - Identify what interfaces the project exposes to users or other systems + - Document the contract format appropriate for the project type + - Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications + - Skip if project is purely internal (build scripts, one-off tools, etc.) 3. **Agent context update**: - Run `{AGENT_SCRIPT}` diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 3c952d683e..5fd4489eee 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -235,7 +235,7 @@ When creating this spec from a user prompt: - Performance targets: Standard web/mobile app expectations unless specified - Error handling: User-friendly messages with appropriate fallbacks - Authentication method: Standard session-based or OAuth2 for web apps -- Integration patterns: RESTful APIs unless specified otherwise +- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.) ### Success Criteria Guidelines diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index d69d43763e..7320b6f305 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -28,14 +28,14 @@ You **MUST** consider the user input before proceeding (if not empty). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) - - **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios) + - **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios) - Note: Not all projects have all documents. Generate tasks based on what's available. 3. **Execute task generation workflow**: - Load plan.md and extract tech stack, libraries, project structure - Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.) - If data-model.md exists: Extract entities and map to user stories - - If contracts/ exists: Map endpoints to user stories + - If contracts/ exists: Map interface contracts to user stories - If research.md exists: Extract decisions for setup tasks - Generate tasks organized by user story (see Task Generation Rules below) - Generate dependency graph showing user story completion order @@ -112,13 +112,13 @@ Every task MUST strictly follow this format: - Map all related components to their story: - Models needed for that story - Services needed for that story - - Endpoints/UI needed for that story + - Interfaces/UI needed for that story - If tests requested: Tests specific to that story - Mark story dependencies (most stories should be independent) 2. **From Contracts**: - - Map each contract/endpoint → to the user story it serves - - If tests requested: Each contract → contract test task [P] before implementation in that story's phase + - Map each interface contract → to the user story it serves + - If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase 3. **From Data Model**: - Map each entity to the user story(ies) that need it diff --git a/templates/plan-template.md b/templates/plan-template.md index dd47efceb4..5a2fafebe3 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -22,7 +22,7 @@ **Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] **Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] **Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] -**Project Type**: [single/web/mobile - determines source structure] +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] **Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] **Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] **Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] From aeed11f735faf04bbd4548c95799081aa5615fe6 Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Fri, 20 Feb 2026 21:11:51 +0000 Subject: [PATCH 014/321] Add V-Model Extension Pack to catalog (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add V-Model Extension Pack to catalog Second community extension: V-Model paired dev-spec and test-spec generation with regulatory-grade traceability. - 3 commands: requirements, acceptance, trace - Deterministic coverage validation (regex-based, not AI) - Targets: IEC 62304, ISO 26262, DO-178C, FDA 21 CFR Part 820 - Repository: https://github.com/leocamello/spec-kit-v-model - Release: v0.1.0 * Fix catalog entry: provides as number, add timestamps and statistics Address review feedback: - provides.commands: array → number (3), add hooks (1) - Add created_at and updated_at timestamps - Add statistics block (downloads: 0, stars: 0) * Address review: use catalog.community.json and add extensions README Per maintainer feedback: - Revert catalog.json to its original empty state (empty by design) - Rename catalog.example.json → catalog.community.json - Replace example entries with real V-Model Extension Pack entry - Add extensions/README.md with community extensions table --- extensions/README.md | 13 +++++++ extensions/catalog.community.json | 33 +++++++++++++++++ extensions/catalog.example.json | 60 ------------------------------- 3 files changed, 46 insertions(+), 60 deletions(-) create mode 100644 extensions/README.md create mode 100644 extensions/catalog.community.json delete mode 100644 extensions/catalog.example.json diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000000..fa93fbd1a1 --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,13 @@ +# Spec Kit Community Extensions + +Community-contributed extensions for [Spec Kit](https://github.com/github/spec-kit). + +## Available Extensions + +| Extension | Purpose | URL | +|-----------|---------|-----| +| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | + +## Adding Your Extension + +See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for instructions on how to submit your extension to the community catalog. diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json new file mode 100644 index 0000000000..05b566369c --- /dev/null +++ b/extensions/catalog.community.json @@ -0,0 +1,33 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-02-20T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", + "extensions": { + "v-model": { + "name": "V-Model Extension Pack", + "id": "v-model", + "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", + "author": "leocamello", + "version": "0.1.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/leocamello/spec-kit-v-model", + "homepage": "https://github.com/leocamello/spec-kit-v-model", + "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", + "changelog": "https://github.com/leocamello/spec-kit-v-model/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-02-20T00:00:00Z" + } + } +} diff --git a/extensions/catalog.example.json b/extensions/catalog.example.json deleted file mode 100644 index afbcc0b566..0000000000 --- a/extensions/catalog.example.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "schema_version": "1.0", - "updated_at": "2026-02-03T00:00:00Z", - "catalog_url": "https://your-org.example.com/speckit/catalog.json", - "extensions": { - "jira": { - "name": "Jira Integration", - "id": "jira", - "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", - "author": "Your Organization", - "version": "2.1.0", - "download_url": "https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip", - "repository": "https://github.com/your-org/spec-kit-jira", - "homepage": "https://github.com/your-org/spec-kit-jira", - "documentation": "https://github.com/your-org/spec-kit-jira/blob/main/README.md", - "changelog": "https://github.com/your-org/spec-kit-jira/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "atlassian", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": ["jira", "atlassian", "issue-tracking"], - "verified": true, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T00:00:00Z", - "updated_at": "2026-02-03T00:00:00Z" - }, - "linear": { - "name": "Linear Integration", - "id": "linear", - "description": "Sync specs and tasks with Linear issues", - "author": "Your Organization", - "version": "1.0.0", - "download_url": "https://github.com/your-org/spec-kit-linear/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/your-org/spec-kit-linear", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0" - }, - "provides": { - "commands": 2 - }, - "tags": ["linear", "issue-tracking"], - "verified": false, - "created_at": "2026-01-30T00:00:00Z", - "updated_at": "2026-01-30T00:00:00Z" - } - } -} From 07077d0fc26f56aea8ecfc2ae41c6e5db9ce34d8 Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Fri, 20 Feb 2026 23:29:58 +0000 Subject: [PATCH 015/321] Update V-Model Extension Pack to v0.2.0 in community catalog (#1656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 0.1.0 → 0.2.0 - Download URL updated to v0.2.0 tag - Commands: 3 → 5 (added system-design, system-test) --- extensions/catalog.community.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 05b566369c..1f15af1ec3 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -8,8 +8,8 @@ "id": "v-model", "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", "author": "leocamello", - "version": "0.1.0", - "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.1.0.zip", + "version": "0.2.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.2.0.zip", "repository": "https://github.com/leocamello/spec-kit-v-model", "homepage": "https://github.com/leocamello/spec-kit-v-model", "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", @@ -19,7 +19,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 3, + "commands": 5, "hooks": 1 }, "tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"], From 68d1d3a0fcdabba01b3ed31eb504a8ff6fc53baf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:35:48 -0600 Subject: [PATCH 016/321] feat: add GitHub issue templates (#1655) * feat: add issue templates for agent requests, bug reports, feature requests, extension submissions, and configuration * Update .github/ISSUE_TEMPLATE/config.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/ISSUE_TEMPLATE/agent_request.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/ISSUE_TEMPLATE/config.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/ISSUE_TEMPLATE/bug_report.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/ISSUE_TEMPLATE/feature_request.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/agent_request.yml | 141 +++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 118 ++++++++ .github/ISSUE_TEMPLATE/config.yml | 17 ++ .../ISSUE_TEMPLATE/extension_submission.yml | 280 ++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 104 +++++++ 5 files changed, 660 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/agent_request.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/extension_submission.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml new file mode 100644 index 0000000000..a72dacda52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -0,0 +1,141 @@ +name: Agent Request +description: Request support for a new AI agent/assistant in Spec Kit +title: "[Agent]: Add support for " +labels: ["agent-request", "enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. + + **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Amazon Q Developer CLI, Amp, SHAI, IBM Bob, Antigravity + + - type: input + id: agent-name + attributes: + label: Agent Name + description: What is the name of the AI agent/assistant? + placeholder: "e.g., SuperCoder AI" + validations: + required: true + + - type: input + id: website + attributes: + label: Official Website + description: Link to the agent's official website or documentation + placeholder: "https://..." + validations: + required: true + + - type: dropdown + id: agent-type + attributes: + label: Agent Type + description: How is the agent accessed? + options: + - CLI tool (command-line interface) + - IDE extension/plugin + - Both CLI and IDE + - Other + validations: + required: true + + - type: input + id: cli-command + attributes: + label: CLI Command (if applicable) + description: What command is used to invoke the agent from terminal? + placeholder: "e.g., supercode, ai-assistant" + + - type: input + id: install-method + attributes: + label: Installation Method + description: How is the agent installed? + placeholder: "e.g., npm install -g supercode, pip install supercode, IDE marketplace" + validations: + required: true + + - type: textarea + id: command-structure + attributes: + label: Command/Workflow Structure + description: How does the agent define custom commands or workflows? + placeholder: | + - Command file format (Markdown, YAML, TOML, etc.) + - Directory location (e.g., .supercode/commands/) + - Example command file structure + validations: + required: true + + - type: textarea + id: argument-pattern + attributes: + label: Argument Passing Pattern + description: How does the agent handle arguments in commands? + placeholder: | + e.g., Uses {{args}}, $ARGUMENTS, %ARGS%, or other placeholder format + Example: "Run test suite with {{args}}" + + - type: dropdown + id: popularity + attributes: + label: Popularity/Usage + description: How widely is this agent used? + options: + - Widely used (thousands+ of users) + - Growing adoption (hundreds of users) + - New/emerging (less than 100 users) + - Unknown + validations: + required: true + + - type: textarea + id: documentation + attributes: + label: Documentation Links + description: Links to relevant documentation for custom commands/workflows + placeholder: | + - Command documentation: https://... + - API/CLI reference: https://... + - Examples: https://... + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Why do you want this agent supported in Spec Kit? + placeholder: Explain your workflow and how this agent fits into your development process + validations: + required: true + + - type: textarea + id: example-command + attributes: + label: Example Command File + description: If possible, provide an example of a command file for this agent + render: markdown + placeholder: | + ```toml + description = "Example command" + prompt = "Do something with {{args}}" + ``` + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Are you willing to help implement support for this agent? + options: + - label: I can help test the integration + - label: I can provide example command files + - label: I can help with documentation + - label: I can submit a pull request for the integration + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other relevant information about this agent + placeholder: Screenshots, community links, comparison to existing agents, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..20adb6c3f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,118 @@ +name: Bug Report +description: Report a bug or unexpected behavior in Specify CLI or Spec Kit +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the sections below to help us diagnose and fix the issue. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: What went wrong? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Run command '...' + 2. Execute script '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe the expected outcome + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what happened instead + validations: + required: true + + - type: input + id: version + attributes: + label: Specify CLI Version + description: "Run `specify --version` or `pip show spec-kit`" + placeholder: "e.g., 1.3.0" + validations: + required: true + + - type: dropdown + id: ai-agent + attributes: + label: AI Agent + description: Which AI agent are you using? + options: + - Claude Code + - Gemini CLI + - GitHub Copilot + - Cursor + - Qwen Code + - opencode + - Codex CLI + - Windsurf + - Kilo Code + - Auggie CLI + - Roo Code + - CodeBuddy + - Qoder CLI + - Amazon Q Developer CLI + - Amp + - SHAI + - IBM Bob + - Antigravity + - Not applicable + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + description: Your operating system and version + placeholder: "e.g., macOS 14.2, Ubuntu 22.04, Windows 11" + validations: + required: true + + - type: input + id: python + attributes: + label: Python Version + description: "Run `python --version` or `python3 --version`" + placeholder: "e.g., Python 3.11.5" + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error Logs + description: Please paste any relevant error messages or logs + render: shell + placeholder: Paste error output here + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context about the problem + placeholder: Screenshots, related issues, workarounds attempted, etc. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..c352f86260 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 General Discussion + url: https://github.com/github/spec-kit/discussions + about: Ask questions, share ideas, or discuss Spec-Driven Development + - name: 📖 Documentation + url: https://github.com/github/spec-kit/blob/main/README.md + about: Read the Spec Kit documentation and guides + - name: 🛠️ Extension Development Guide + url: https://github.com/manfredseee/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md + about: Learn how to develop and publish Spec Kit extensions + - name: 🤝 Contributing Guide + url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md + about: Learn how to contribute to Spec Kit + - name: 🔒 Security Issues + url: https://github.com/github/spec-kit/blob/main/SECURITY.md + about: Report security vulnerabilities privately diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml new file mode 100644 index 0000000000..d298925e74 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/extension_submission.yml @@ -0,0 +1,280 @@ +name: Extension Submission +description: Submit your extension to the Spec Kit catalog +title: "[Extension]: Add " +labels: ["extension-submission", "enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing an extension! This template helps you submit your extension to the community catalog. + + **Before submitting:** + - Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md) + - Ensure your extension has a valid `extension.yml` manifest + - Create a GitHub release with a version tag (e.g., v1.0.0) + - Test installation: `specify extension add --from ` + + - type: input + id: extension-id + attributes: + label: Extension ID + description: Unique extension identifier (lowercase with hyphens only) + placeholder: "e.g., jira-integration" + validations: + required: true + + - type: input + id: extension-name + attributes: + label: Extension Name + description: Human-readable extension name + placeholder: "e.g., Jira Integration" + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Semantic version number + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of what your extension does (under 200 characters) + placeholder: Integrates Jira issue tracking with Spec Kit workflows for seamless task management + validations: + required: true + + - type: input + id: author + attributes: + label: Author + description: Your name or organization + placeholder: "e.g., John Doe or Acme Corp" + validations: + required: true + + - type: input + id: repository + attributes: + label: Repository URL + description: GitHub repository URL for your extension + placeholder: "https://github.com/your-org/spec-kit-your-extension" + validations: + required: true + + - type: input + id: download-url + attributes: + label: Download URL + description: URL to the GitHub release archive (e.g., v1.0.0.zip) + placeholder: "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip" + validations: + required: true + + - type: input + id: license + attributes: + label: License + description: Open source license type + placeholder: "e.g., MIT, Apache-2.0" + validations: + required: true + + - type: input + id: homepage + attributes: + label: Homepage (optional) + description: Link to extension homepage or documentation site + placeholder: "https://..." + + - type: input + id: documentation + attributes: + label: Documentation URL (optional) + description: Link to detailed documentation + placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/" + + - type: input + id: changelog + attributes: + label: Changelog URL (optional) + description: Link to changelog file + placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md" + + - type: input + id: speckit-version + attributes: + label: Required Spec Kit Version + description: Minimum Spec Kit version required + placeholder: "e.g., >=0.1.0" + validations: + required: true + + - type: textarea + id: required-tools + attributes: + label: Required Tools (optional) + description: List any external tools or dependencies required + placeholder: | + - jira-cli (>=1.0.0) - required + - python (>=3.8) - optional + render: markdown + + - type: input + id: commands-count + attributes: + label: Number of Commands + description: How many commands does your extension provide? + placeholder: "e.g., 3" + validations: + required: true + + - type: input + id: hooks-count + attributes: + label: Number of Hooks (optional) + description: How many hooks does your extension provide? + placeholder: "e.g., 0" + + - type: textarea + id: tags + attributes: + label: Tags + description: 2-5 relevant tags (lowercase, separated by commas) + placeholder: "issue-tracking, jira, atlassian, automation" + validations: + required: true + + - type: textarea + id: features + attributes: + label: Key Features + description: List the main features and capabilities of your extension + placeholder: | + - Create Jira issues from specs + - Sync task status with Jira + - Link specs to existing issues + - Generate Jira reports + validations: + required: true + + - type: checkboxes + id: testing + attributes: + label: Testing Checklist + description: Confirm that your extension has been tested + options: + - label: Extension installs successfully via download URL + required: true + - label: All commands execute without errors + required: true + - label: Documentation is complete and accurate + required: true + - label: No security vulnerabilities identified + required: true + - label: Tested on at least one real project + required: true + + - type: checkboxes + id: requirements + attributes: + label: Submission Requirements + description: Verify your extension meets all requirements + options: + - label: Valid `extension.yml` manifest included + required: true + - label: README.md with installation and usage instructions + required: true + - label: LICENSE file included + required: true + - label: GitHub release created with version tag + required: true + - label: All command files exist and are properly formatted + required: true + - label: Extension ID follows naming conventions (lowercase-with-hyphens) + required: true + + - type: textarea + id: testing-details + attributes: + label: Testing Details + description: Describe how you tested your extension + placeholder: | + **Tested on:** + - macOS 14.0 with Spec Kit v0.1.0 + - Linux Ubuntu 22.04 with Spec Kit v0.1.0 + + **Test project:** [Link or description] + + **Test scenarios:** + 1. Installed extension + 2. Configured settings + 3. Ran all commands + 4. Verified outputs + validations: + required: true + + - type: textarea + id: example-usage + attributes: + label: Example Usage + description: Provide a simple example of using your extension + render: markdown + placeholder: | + ```bash + # Install extension + specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip + + # Use a command + /speckit.your-extension.command-name arg1 arg2 + ``` + validations: + required: true + + - type: textarea + id: catalog-entry + attributes: + label: Proposed Catalog Entry + description: Provide the JSON entry for catalog.json (helps reviewers) + render: json + placeholder: | + { + "your-extension": { + "name": "Your Extension", + "id": "your-extension", + "description": "Brief description", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-your-extension", + "homepage": "https://github.com/your-org/spec-kit-your-extension", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3 + }, + "tags": ["category", "tool"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-02-20T00:00:00Z" + } + } + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that would help reviewers + placeholder: Screenshots, demo videos, links to related projects, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..71786ddcef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,104 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Specify CLI or Spec Kit +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please provide details below to help us understand and evaluate your request. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: "I'm frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like + placeholder: What would you like to happen? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + placeholder: What other approaches might work? + + - type: dropdown + id: component + attributes: + label: Component + description: Which component does this feature relate to? + options: + - Specify CLI (initialization, commands) + - Spec templates (BDD, Testing Strategy, etc.) + - Agent integrations (command files, workflows) + - Scripts (Bash/PowerShell utilities) + - Documentation + - CI/CD workflows + - Other + validations: + required: true + + - type: dropdown + id: ai-agent + attributes: + label: AI Agent (if applicable) + description: Does this feature relate to a specific AI agent? + options: + - All agents + - Claude Code + - Gemini CLI + - GitHub Copilot + - Cursor + - Qwen Code + - opencode + - Codex CLI + - Windsurf + - Kilo Code + - Auggie CLI + - Roo Code + - CodeBuddy + - Qoder CLI + - Amazon Q Developer CLI + - Amp + - SHAI + - IBM Bob + - Antigravity + - Not applicable + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific use cases where this feature would be valuable + placeholder: | + 1. When working on large projects... + 2. During spec review... + 3. When integrating with CI/CD... + + - type: textarea + id: acceptance + attributes: + label: Acceptance Criteria + description: How would you know this feature is complete and working? + placeholder: | + - [ ] Feature does X + - [ ] Documentation is updated + - [ ] Works with all supported agents + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context, screenshots, or examples + placeholder: Links to similar features, mockups, related discussions, etc. From 6f523ede22c9ac22691b50cb4fcaeaa92891f7ff Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 21 Feb 2026 08:05:58 -0600 Subject: [PATCH 017/321] Fix #1658: Add commands_subdir field to support non-standard agent directory structures (#1660) - Added commands_subdir field to AGENT_CONFIG for all agents - Updated install_ai_skills() to use commands_subdir instead of hardcoded 'commands' - Fixed --ai-skills flag for copilot, opencode, windsurf, codex, kilocode, q, and agy - Bumped version to 0.1.5 - Updated AGENTS.md documentation with new field Affected agents now correctly locate their command templates: - copilot: .github/agents/ - opencode: .opencode/command/ (singular) - windsurf: .windsurf/workflows/ - codex: .codex/prompts/ - kilocode: .kilocode/workflows/ - q: .amazonq/prompts/ - agy: .agent/workflows/ All 51 tests pass. --- AGENTS.md | 5 +++++ CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/specify_cli/__init__.py | 26 +++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a11e3eee7..d8dc0f08f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ AGENT_CONFIG = { "new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal) "name": "New Agent Display Name", "folder": ".newagent/", # Directory for agent files + "commands_subdir": "commands", # Subdirectory name for command files (default: "commands") "install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based) "requires_cli": True, # True if CLI tool required, False for IDE-based agents }, @@ -83,6 +84,10 @@ This eliminates the need for special-case mappings throughout the codebase. - `name`: Human-readable display name shown to users - `folder`: Directory where agent-specific files are stored (relative to project root) +- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) + - Most agents use `"commands"` (e.g., `.claude/commands/`) + - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, q), `"command"` (opencode - singular) + - This field enables `--ai-skills` to locate command templates correctly for skill generation - `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `requires_cli`: Whether the agent requires a CLI tool check during initialization diff --git a/CHANGELOG.md b/CHANGELOG.md index ab20202cbb..7163d2b89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.5] - Unreleased + +### Fixed + +- **AI Skills Installation Bug (#1658)**: Fixed `--ai-skills` flag not generating skill files for GitHub Copilot and other agents with non-standard command directory structures + - Added `commands_subdir` field to `AGENT_CONFIG` to explicitly specify the subdirectory name for each agent + - Affected agents now work correctly: copilot (`.github/agents/`), opencode (`.opencode/command/`), windsurf (`.windsurf/workflows/`), codex (`.codex/prompts/`), kilocode (`.kilocode/workflows/`), q (`.amazonq/prompts/`), and agy (`.agent/workflows/`) + - The `install_ai_skills()` function now uses the correct path for all agents instead of assuming `commands/` for everyone + ## [0.1.4] - Unreleased ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 7af6f963ce..2567f0ed4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.4" +version = "0.1.5" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c8c01f4c98..f9a7139993 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -123,119 +123,138 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) return "\n".join(lines) -# Agent configuration with name, folder, install URL, and CLI tool requirement +# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory AGENT_CONFIG = { "copilot": { "name": "GitHub Copilot", "folder": ".github/", + "commands_subdir": "agents", # Special: uses agents/ not commands/ "install_url": None, # IDE-based, no CLI check needed "requires_cli": False, }, "claude": { "name": "Claude Code", "folder": ".claude/", + "commands_subdir": "commands", "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, }, "gemini": { "name": "Gemini CLI", "folder": ".gemini/", + "commands_subdir": "commands", "install_url": "https://github.com/google-gemini/gemini-cli", "requires_cli": True, }, "cursor-agent": { "name": "Cursor", "folder": ".cursor/", + "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, "qwen": { "name": "Qwen Code", "folder": ".qwen/", + "commands_subdir": "commands", "install_url": "https://github.com/QwenLM/qwen-code", "requires_cli": True, }, "opencode": { "name": "opencode", "folder": ".opencode/", + "commands_subdir": "command", # Special: singular 'command' not 'commands' "install_url": "https://opencode.ai", "requires_cli": True, }, "codex": { "name": "Codex CLI", "folder": ".codex/", + "commands_subdir": "prompts", # Special: uses prompts/ not commands/ "install_url": "https://github.com/openai/codex", "requires_cli": True, }, "windsurf": { "name": "Windsurf", "folder": ".windsurf/", + "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "kilocode": { "name": "Kilo Code", "folder": ".kilocode/", + "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "auggie": { "name": "Auggie CLI", "folder": ".augment/", + "commands_subdir": "commands", "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", "requires_cli": True, }, "codebuddy": { "name": "CodeBuddy", "folder": ".codebuddy/", + "commands_subdir": "commands", "install_url": "https://www.codebuddy.ai/cli", "requires_cli": True, }, "qodercli": { "name": "Qoder CLI", "folder": ".qoder/", + "commands_subdir": "commands", "install_url": "https://qoder.com/cli", "requires_cli": True, }, "roo": { "name": "Roo Code", "folder": ".roo/", + "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, "q": { "name": "Amazon Q Developer CLI", "folder": ".amazonq/", + "commands_subdir": "prompts", # Special: uses prompts/ not commands/ "install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/", "requires_cli": True, }, "amp": { "name": "Amp", "folder": ".agents/", + "commands_subdir": "commands", "install_url": "https://ampcode.com/manual#install", "requires_cli": True, }, "shai": { "name": "SHAI", "folder": ".shai/", + "commands_subdir": "commands", "install_url": "https://github.com/ovh/shai", "requires_cli": True, }, "agy": { "name": "Antigravity", "folder": ".agent/", + "commands_subdir": "workflows", # Special: uses workflows/ not commands/ "install_url": None, # IDE-based "requires_cli": False, }, "bob": { "name": "IBM Bob", "folder": ".bob/", + "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir + "commands_subdir": "commands", "install_url": None, "requires_cli": False, }, @@ -1056,10 +1075,11 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker # download_and_extract_template() already placed the .md files here. agent_config = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_config.get("folder", "") + commands_subdir = agent_config.get("commands_subdir", "commands") if agent_folder: - templates_dir = project_path / agent_folder.rstrip("/") / "commands" + templates_dir = project_path / agent_folder.rstrip("/") / commands_subdir else: - templates_dir = project_path / "commands" + templates_dir = project_path / commands_subdir if not templates_dir.exists() or not any(templates_dir.glob("*.md")): # Fallback: try the repo-relative path (for running from source checkout) From cee4f26faca26927beb434549e695fbd9b16b8e5 Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Mon, 23 Feb 2026 15:02:24 +0000 Subject: [PATCH 018/321] Update V-Model Extension Pack to v0.3.0 (#1661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 0.2.0 → 0.3.0 - Commands: 5 → 7 (architecture-design, integration-test) - Download URL updated to v0.3.0 tag --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1f15af1ec3..555814b2ae 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-02-21T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "v-model": { @@ -8,8 +8,8 @@ "id": "v-model", "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", "author": "leocamello", - "version": "0.2.0", - "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.2.0.zip", + "version": "0.3.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.3.0.zip", "repository": "https://github.com/leocamello/spec-kit-v-model", "homepage": "https://github.com/leocamello/spec-kit-v-model", "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", @@ -19,7 +19,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 5, + "commands": 7, "hooks": 1 }, "tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"], @@ -27,7 +27,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-02-20T00:00:00Z" + "updated_at": "2026-02-21T00:00:00Z" } } } From c1034f1d9d0dc924ec0f0b515659e5a5f30c41cb Mon Sep 17 00:00:00 2001 From: Emi <140513018+emi-dm@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:40:41 +0100 Subject: [PATCH 019/321] docs: Fix doc missing step (#1496) * docs: Fix doc missing step * docs: Update steps for generating technical plan and defining tasks --- docs/quickstart.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 4d3b863b35..37d431dbad 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -81,6 +81,9 @@ Then, use the `/speckit.implement` slash command to execute the plan. /speckit.implement ``` +> [!TIP] +> **Phased Implementation**: For complex projects, implement in phases to avoid overwhelming the agent's context. Start with core functionality, validate it works, then add features incrementally. + ## Detailed Example: Building Taskify Here's a complete example of building a team productivity platform: @@ -135,7 +138,15 @@ Be specific about your tech stack and technical requirements: /speckit.plan We are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use Blazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API, tasks API, and a notifications API. ``` -### Step 6: Validate and Implement +### Step 6: Define Tasks + +Generate an actionable task list using the `/speckit.tasks` command: + +```bash +/speckit.tasks +``` + +### Step 7: Validate and Implement Have your AI agent audit the implementation plan using `/speckit.analyze`: @@ -149,6 +160,9 @@ Finally, implement the solution: /speckit.implement ``` +> [!TIP] +> **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage. + ## Key Principles - **Be explicit** about what you're building and why From 6cc61025cb603e9b17e2cfea584a072e78231008 Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Mon, 23 Feb 2026 19:14:02 +0000 Subject: [PATCH 020/321] Update V-Model Extension Pack to v0.4.0 (#1665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 0.2.0 → 0.4.0 - Commands: 5 → 9 (new: architecture-design, integration-test, module-design, unit-test) - Download URL: updated to v0.4.0 tag --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 555814b2ae..0425da80e7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-02-21T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "v-model": { @@ -8,8 +8,8 @@ "id": "v-model", "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", "author": "leocamello", - "version": "0.3.0", - "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.3.0.zip", + "version": "0.4.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.4.0.zip", "repository": "https://github.com/leocamello/spec-kit-v-model", "homepage": "https://github.com/leocamello/spec-kit-v-model", "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", @@ -19,7 +19,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 7, + "commands": 9, "hooks": 1 }, "tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"], @@ -27,7 +27,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-02-21T00:00:00Z" + "updated_at": "2026-02-22T00:00:00Z" } } } From 3040d33c31d8a26d50f91aec5d62d1cecac3298c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:17:08 -0600 Subject: [PATCH 021/321] Fix parameter ordering issues in CLI (#1669) * chore: bump version to v0.0.6 [skip ci] * Fix parameter ordering issues in CLI (#1641) - Add validation to detect when option flags are consumed as values - Provide clear error messages with helpful hints and examples - Add 5 comprehensive tests to prevent regressions - Update CODEOWNERS to @mnriem - Bump version to 0.1.6 with changelog entry Fixes: #1641 * Fix ruff linting errors: remove f-string prefix from strings without placeholders --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- CHANGELOG.md | 23 +++++++++----- pyproject.toml | 2 +- src/specify_cli/__init__.py | 14 +++++++++ tests/test_ai_skills.py | 62 +++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index efb95fc0eb..a60b7a0306 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # Global code owner -* @localden +* @mnriem diff --git a/CHANGELOG.md b/CHANGELOG.md index 7163d2b89a..741ce5d0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,23 @@ -All notable changes to the Specify CLI and templates are documented here. +Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.5] - Unreleased +## [0.1.6] - 2026-02-23 + +### Fixed + +- **Parameter Ordering Issues (#1641)**: Fixed CLI parameter parsing issue where option flags were incorrectly consumed as values for preceding options + - Added validation to detect when `--ai` or `--ai-commands-dir` incorrectly consume following flags like `--here` or `--ai-skills` + - Now provides clear error messages: "Invalid value for --ai: '--here'" + - Includes helpful hints suggesting proper usage and listing available agents + - Commands like `specify init --ai-skills --ai --here` now fail with actionable feedback instead of confusing "Must specify project name" errors + - Added comprehensive test suite (5 new tests) to prevent regressions + +## [0.1.5] - 2026-02-21 ### Fixed @@ -16,13 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Affected agents now work correctly: copilot (`.github/agents/`), opencode (`.opencode/command/`), windsurf (`.windsurf/workflows/`), codex (`.codex/prompts/`), kilocode (`.kilocode/workflows/`), q (`.amazonq/prompts/`), and agy (`.agent/workflows/`) - The `install_ai_skills()` function now uses the correct path for all agents instead of assuming `commands/` for everyone -## [0.1.4] - Unreleased +## [0.1.4] - 2026-02-20 ### Fixed - **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `"qoder"` to `"qodercli"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures -## [0.1.3] - Unreleased +## [0.1.3] - 2026-02-20 ### Added @@ -69,7 +80,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.94] - 2026-02-11 - Add stale workflow for 180-day inactive issues and PRs (#1594) - -## [0.0.93] - 2026-02-10 - -- Add modular extension system (#1551) diff --git a/pyproject.toml b/pyproject.toml index 2567f0ed4a..5f6a2eb7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.5" +version = "0.1.6" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f9a7139993..5651ac7226 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1256,6 +1256,20 @@ def init( show_banner() + # Detect when option values are likely misinterpreted flags (parameter ordering issue) + if ai_assistant and ai_assistant.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") + console.print("[yellow]Example:[/yellow] specify init --ai claude --here") + console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + + if ai_commands_dir and ai_commands_dir.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") + console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") + raise typer.Exit(1) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index d4c607b643..3eec4a419c 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -630,3 +630,65 @@ def test_ai_skills_flag_appears_in_help(self): plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output) assert "--ai-skills" in plain assert "agent skills" in plain.lower() + + +class TestParameterOrderingIssue: + """Test fix for GitHub issue #1641: parameter ordering issues.""" + + def test_ai_flag_consuming_here_flag(self): + """--ai without value should not consume --here flag (issue #1641).""" + from typer.testing import CliRunner + + runner = CliRunner() + # This used to fail with "Must specify project name" because --here was consumed by --ai + result = runner.invoke(app, ["init", "--ai-skills", "--ai", "--here"]) + + assert result.exit_code == 1 + assert "Invalid value for --ai" in result.output + assert "--here" in result.output # Should mention the invalid value + + def test_ai_flag_consuming_ai_skills_flag(self): + """--ai without value should not consume --ai-skills flag.""" + from typer.testing import CliRunner + + runner = CliRunner() + # This should fail with helpful error about missing --ai value + result = runner.invoke(app, ["init", "--here", "--ai", "--ai-skills"]) + + assert result.exit_code == 1 + assert "Invalid value for --ai" in result.output + assert "--ai-skills" in result.output # Should mention the invalid value + + def test_error_message_provides_hint(self): + """Error message should provide helpful hint about missing value.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "--ai", "--here"]) + + assert result.exit_code == 1 + assert "Hint:" in result.output or "hint" in result.output.lower() + assert "forget to provide a value" in result.output.lower() + + def test_error_message_lists_available_agents(self): + """Error message should list available agents.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "--ai", "--here"]) + + assert result.exit_code == 1 + # Should mention some known agents + output_lower = result.output.lower() + assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"]) + + def test_ai_commands_dir_consuming_flag(self): + """--ai-commands-dir without value should not consume next flag.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"]) + + assert result.exit_code == 1 + assert "Invalid value for --ai-commands-dir" in result.output + assert "--here" in result.output From f444ccba3a48dd1976fd5af1972f044712ac4749 Mon Sep 17 00:00:00 2001 From: dsrednicki <116730377+dsrednicki@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:52:40 +0100 Subject: [PATCH 022/321] Add Cleanup Extension to catalog (#1617) **Repository**: https://github.com/dsrednicki/spec-kit-cleanup **Version**: 1.0.0 **License**: MIT **Author**: @dsrednicki Adds catalog entry for the Cleanup Extension - a post-implementation quality gate that fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues. --- extensions/catalog.community.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0425da80e7..f3e6b42451 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3,6 +3,32 @@ "updated_at": "2026-02-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { + "cleanup": { + "name": "Cleanup Extension", + "id": "cleanup", + "description": "Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues.", + "author": "dsrednicki", + "version": "1.0.0", + "download_url": "https://github.com/dsrednicki/spec-kit-cleanup/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dsrednicki/spec-kit-cleanup", + "homepage": "https://github.com/dsrednicki/spec-kit-cleanup", + "documentation": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/README.md", + "changelog": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": ["quality", "tech-debt", "review", "cleanup", "scout-rule"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From c7ecdfb998f2146eb9d62f2d67601512dd1b244c Mon Sep 17 00:00:00 2001 From: Emi <140513018+emi-dm@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:56:03 +0100 Subject: [PATCH 023/321] Add retrospective extension to community catalog. (#1681) Register the new retrospective extension release so users can discover and install it from the community catalog. Co-authored-by: Cursor --- extensions/catalog.community.json | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f3e6b42451..4ab408b7c5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "cleanup": { @@ -29,6 +29,32 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "retrospective": { + "name": "Retrospective Extension", + "id": "retrospective", + "description": "Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates.", + "author": "emi-dm", + "version": "1.0.0", + "download_url": "https://github.com/emi-dm/spec-kit-retrospective/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/emi-dm/spec-kit-retrospective", + "homepage": "https://github.com/emi-dm/spec-kit-retrospective", + "documentation": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/README.md", + "changelog": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": ["retrospective", "spec-drift", "quality", "analysis", "governance"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-24T00:00:00Z", + "updated_at": "2026-02-24T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From 607760e72fd20290ba62085ba07a4814f1b3a5f0 Mon Sep 17 00:00:00 2001 From: dsrednicki <116730377+dsrednicki@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:50:04 +0100 Subject: [PATCH 024/321] Add Cleanup Extension to README (#1678) --- extensions/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/README.md b/extensions/README.md index fa93fbd1a1..0be853a40b 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -7,6 +7,7 @@ Community-contributed extensions for [Spec Kit](https://github.com/github/spec-k | Extension | Purpose | URL | |-----------|---------|-----| | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | +| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | ## Adding Your Extension From 525cdc17ec97de61c59ee0e21f82c7d4126f54b3 Mon Sep 17 00:00:00 2001 From: Santosh Bhavani Date: Wed, 25 Feb 2026 08:20:18 -0600 Subject: [PATCH 025/321] Fix version command in documentation (#1685) Co-authored-by: Santosh Bhavani --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- extensions/EXTENSION-USER-GUIDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 20adb6c3f0..cbe1955ad4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -51,7 +51,7 @@ body: id: version attributes: label: Specify CLI Version - description: "Run `specify --version` or `pip show spec-kit`" + description: "Run `specify version` or `pip show spec-kit`" placeholder: "e.g., 1.3.0" validations: required: true diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 46e87cf6cc..802026231f 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -46,7 +46,7 @@ Extensions are modular packages that add new commands and functionality to Spec ### Check Your Version ```bash -specify --version +specify version # Should show 0.1.0 or higher ``` From 56deda7be3996a6457f917582f5985d82e73c5e1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:38:56 -0600 Subject: [PATCH 026/321] docs: Document dual-catalog system for extensions (#1689) * docs: Document dual-catalog system for extensions - Clarify distinction between catalog.json (curated) and catalog.community.json (reference) - Update EXTENSION-DEVELOPMENT-GUIDE.md to explain community catalog submission - Update EXTENSION-PUBLISHING-GUIDE.md with dual-catalog workflow - Update EXTENSION-USER-GUIDE.md with catalog selection guidance - Expand README.md with comprehensive catalog explanation - Update RFC-EXTENSION-SYSTEM.md with dual-catalog design and current implementation - Change GitHub references from statsperform to github - Add SPECKIT_CATALOG_URL environment variable documentation This clarifies how organizations can curate their own catalog while browsing community-contributed extensions for discovery. * Update extensions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 20 ++-- extensions/EXTENSION-PUBLISHING-GUIDE.md | 34 +++++-- extensions/EXTENSION-USER-GUIDE.md | 12 ++- extensions/README.md | 113 +++++++++++++++++++++- extensions/RFC-EXTENSION-SYSTEM.md | 75 ++++++++++++-- 5 files changed, 221 insertions(+), 33 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ff7a3aabe5..f86beb62bb 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -456,18 +456,20 @@ Users install with: specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip ``` -### Option 3: Extension Catalog (Future) +### Option 3: Community Reference Catalog -Submit to official catalog: +Submit to the community catalog for public discovery: 1. **Fork** spec-kit repository -2. **Add entry** to `extensions/catalog.json` -3. **Create PR** -4. **After merge**, users can install with: - - ```bash - specify extension add my-ext # No URL needed! - ``` +2. **Add entry** to `extensions/catalog.community.json` +3. **Update** `extensions/README.md` with your extension +4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) +5. **After merge**, your extension becomes available: + - Users can browse `catalog.community.json` to discover your extension + - Users copy the entry to their own `catalog.json` + - Users install with: `specify extension add my-ext` (from their catalog) + +See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions. --- diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 10eacbf909..39b744b2e1 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -129,26 +129,32 @@ specify extension add --from https://github.com/your-org/spec-kit-your-extension ## Submit to Catalog +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs). + +**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`. + ### 1. Fork the spec-kit Repository ```bash # Fork on GitHub -# https://github.com/statsperform/spec-kit/fork +# https://github.com/github/spec-kit/fork # Clone your fork git clone https://github.com/YOUR-USERNAME/spec-kit.git cd spec-kit ``` -### 2. Add Extension to Catalog +### 2. Add Extension to Community Catalog -Edit `extensions/catalog.json` and add your extension: +Edit `extensions/catalog.community.json` and add your extension: ```json { "schema_version": "1.0", "updated_at": "2026-01-28T15:54:00Z", - "catalog_url": "https://raw.githubusercontent.com/statsperform/spec-kit/main/extensions/catalog.json", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "your-extension": { "name": "Your Extension Name", @@ -198,15 +204,25 @@ Edit `extensions/catalog.json` and add your extension: - Use current timestamp for `created_at` and `updated_at` - Update the top-level `updated_at` to current time -### 3. Submit Pull Request +### 3. Update Extensions README + +Add your extension to the Available Extensions table in `extensions/README.md`: + +```markdown +| Your Extension Name | Brief description of what it does | [repo-name](https://github.com/your-org/spec-kit-your-extension) | +``` + +Insert your extension in alphabetical order in the table. + +### 4. Submit Pull Request ```bash # Create a branch git checkout -b add-your-extension # Commit your changes -git add extensions/catalog.json -git commit -m "Add your-extension to catalog +git add extensions/catalog.community.json extensions/README.md +git commit -m "Add your-extension to community catalog - Extension ID: your-extension - Version: 1.0.0 @@ -218,7 +234,7 @@ git commit -m "Add your-extension to catalog git push origin add-your-extension # Create Pull Request on GitHub -# https://github.com/statsperform/spec-kit/compare +# https://github.com/github/spec-kit/compare ``` **Pull Request Template**: @@ -243,6 +259,8 @@ Brief description of what your extension does. - [x] Extension tested on real project - [x] All commands working - [x] No security vulnerabilities +- [x] Added to extensions/catalog.community.json +- [x] Added to extensions/README.md Available Extensions table ### Testing Tested on: diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 802026231f..f5b5befaf5 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -76,13 +76,15 @@ vim .specify/extensions/jira/jira-config.yml ## Finding Extensions +**Note**: By default, `specify extension search` uses your organization's catalog (`catalog.json`). If the catalog is empty, you won't see any results. See [Extension Catalogs](#extension-catalogs) to learn how to populate your catalog from the community reference catalog. + ### Browse All Extensions ```bash specify extension search ``` -Shows all available extensions in the catalog. +Shows all extensions in your organization's catalog. ### Search by Keyword @@ -415,11 +417,15 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" --- +## Extension Catalogs + +For information about how Spec Kit's dual-catalog system works (`catalog.json` vs `catalog.community.json`), see the main [Extensions README](README.md#extension-catalogs). + ## Organization Catalog Customization -### Why the Default Catalog is Empty +### Why Customize Your Catalog -The default spec-kit catalog ships empty by design. This allows organizations to: +Organizations customize their `catalog.json` to: - **Control available extensions** - Curate which extensions your team can install - **Host private extensions** - Internal tools that shouldn't be public diff --git a/extensions/README.md b/extensions/README.md index 0be853a40b..574144a4d1 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -1,8 +1,74 @@ -# Spec Kit Community Extensions +# Spec Kit Extensions -Community-contributed extensions for [Spec Kit](https://github.com/github/spec-kit). +Extension system for [Spec Kit](https://github.com/github/spec-kit) - add new functionality without bloating the core framework. -## Available Extensions +## Extension Catalogs + +Spec Kit provides two catalog files with different purposes: + +### Your Catalog (`catalog.json`) + +- **Purpose**: Default upstream catalog of extensions used by the Spec Kit CLI +- **Default State**: Empty by design in the upstream project - you or your organization populate a fork/copy with extensions you trust +- **Location (upstream)**: `extensions/catalog.json` in the GitHub-hosted spec-kit repo +- **CLI Default**: The `specify extension` commands use the upstream catalog URL by default, unless overridden +- **Org Catalog**: Point `SPECKIT_CATALOG_URL` at your organization's fork or hosted catalog JSON to use it instead of the upstream default +- **Customization**: Copy entries from the community catalog into your org catalog, or add your own extensions directly + +**Example override:** +```bash +# Override the default upstream catalog with your organization's catalog +export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" +specify extension search # Now uses your organization's catalog instead of the upstream default +``` + +### Community Reference Catalog (`catalog.community.json`) + +- **Purpose**: Browse available community-contributed extensions +- **Status**: Active - contains extensions submitted by the community +- **Location**: `extensions/catalog.community.json` +- **Usage**: Reference catalog for discovering available extensions +- **Submission**: Open to community contributions via Pull Request + +**How It Works:** + +## Making Extensions Available + +You control which extensions your team can discover and install: + +### Option 1: Curated Catalog (Recommended for Organizations) + +Populate your `catalog.json` with approved extensions: + +1. **Discover** extensions from various sources: + - Browse `catalog.community.json` for community extensions + - Find private/internal extensions in your organization's repos + - Discover extensions from trusted third parties +2. **Review** extensions and choose which ones you want to make available +3. **Add** those extension entries to your own `catalog.json` +4. **Team members** can now discover and install them: + - `specify extension search` shows your curated catalog + - `specify extension add ` installs from your catalog + +**Benefits**: Full control over available extensions, team consistency, organizational approval workflow + +**Example**: Copy an entry from `catalog.community.json` to your `catalog.json`, then your team can discover and install it by name. + +### Option 2: Direct URLs (For Ad-hoc Use) + +Skip catalog curation - team members install directly using URLs: + +```bash +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +``` + +**Benefits**: Quick for one-off testing or private extensions + +**Tradeoff**: Extensions installed this way won't appear in `specify extension search` for other team members unless you also add them to your `catalog.json`. + +## Available Community Extensions + +The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json): | Extension | Purpose | URL | |-----------|---------|-----| @@ -11,4 +77,43 @@ Community-contributed extensions for [Spec Kit](https://github.com/github/spec-k ## Adding Your Extension -See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for instructions on how to submit your extension to the community catalog. +### Submission Process + +To add your extension to the community catalog: + +1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md) +2. **Create a GitHub release** for your extension +3. **Submit a Pull Request** that: + - Adds your extension to `extensions/catalog.community.json` + - Updates this README with your extension in the Available Extensions table +4. **Wait for review** - maintainers will review and merge if criteria are met + +See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions. + +### Submission Checklist + +Before submitting, ensure: + +- ✅ Valid `extension.yml` manifest +- ✅ Complete README with installation and usage instructions +- ✅ LICENSE file included +- ✅ GitHub release created with semantic version (e.g., v1.0.0) +- ✅ Extension tested on a real project +- ✅ All commands working as documented + +## Installing Extensions +Once extensions are available (either in your catalog or via direct URL), install them: + +```bash +# From your curated catalog (by name) +specify extension search # See what's in your catalog +specify extension add # Install by name + +# Direct from URL (bypasses catalog) +specify extension add --from https://github.com///archive/refs/tags/.zip + +# List installed extensions +specify extension list +``` + +For more information, see the [Extension User Guide](EXTENSION-USER-GUIDE.md). diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index 3bfa0ea060..248e6275aa 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -858,11 +858,41 @@ def should_execute_hook(hook: dict, config: dict) -> bool: ## Extension Discovery & Catalog -### Central Catalog +### Dual Catalog System + +Spec Kit uses two catalog files with different purposes: + +#### User Catalog (`catalog.json`) **URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json` -**Format**: +- **Purpose**: Organization's curated catalog of approved extensions +- **Default State**: Empty by design - users populate with extensions they trust +- **Usage**: Default catalog used by `specify extension` CLI commands +- **Control**: Organizations maintain their own fork/version for their teams + +#### Community Reference Catalog (`catalog.community.json`) + +**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json` + +- **Purpose**: Reference catalog of available community-contributed extensions +- **Verification**: Community extensions may have `verified: false` initially +- **Status**: Active - open for community contributions +- **Submission**: Via Pull Request following the Extension Publishing Guide +- **Usage**: Browse to discover extensions, then copy to your `catalog.json` + +**How It Works:** + +1. **Discover**: Browse `catalog.community.json` to find available extensions +2. **Review**: Evaluate extensions for security, quality, and organizational fit +3. **Curate**: Copy approved extension entries from community catalog to your `catalog.json` +4. **Install**: Use `specify extension add ` (pulls from your curated catalog) + +This approach gives organizations full control over which extensions are available to their teams while maintaining a shared community resource for discovery. + +### Catalog Format + +**Format** (same for both catalogs): ```json { @@ -931,25 +961,52 @@ specify extension info jira ### Custom Catalogs -Organizations can host private catalogs: +**⚠️ FUTURE FEATURE - NOT YET IMPLEMENTED** + +The following catalog management commands are proposed design concepts but are not yet available in the current implementation: ```bash -# Add custom catalog +# Add custom catalog (FUTURE - NOT AVAILABLE) specify extension add-catalog https://internal.company.com/spec-kit/catalog.json -# Set as default +# Set as default (FUTURE - NOT AVAILABLE) specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json -# List catalogs +# List catalogs (FUTURE - NOT AVAILABLE) specify extension catalogs ``` -**Catalog priority**: +**Proposed catalog priority** (future design): -1. Project-specific catalog (`.specify/extension-catalogs.yml`) -2. User-level catalog (`~/.specify/extension-catalogs.yml`) +1. Project-specific catalog (`.specify/extension-catalogs.yml`) - *not implemented* +2. User-level catalog (`~/.specify/extension-catalogs.yml`) - *not implemented* 3. Default GitHub catalog +#### Current Implementation: SPECKIT_CATALOG_URL + +**The currently available method** for using custom catalogs is the `SPECKIT_CATALOG_URL` environment variable: + +```bash +# Point to your organization's catalog +export SPECKIT_CATALOG_URL="https://internal.company.com/spec-kit/catalog.json" + +# All extension commands now use your custom catalog +specify extension search # Uses custom catalog +specify extension add jira # Installs from custom catalog +``` + +**Requirements:** +- URL must use HTTPS (HTTP only allowed for localhost testing) +- Catalog must follow the standard catalog.json schema +- Must be publicly accessible or accessible within your network + +**Example for testing:** +```bash +# Test with localhost during development +export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" +specify extension search +``` + --- ## CLI Commands From 61b0637a6dc8d9a72372acc1200301d84c91470a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:43:22 -0600 Subject: [PATCH 027/321] chore: Update outdated GitHub Actions versions (#1706) Co-authored-by: Padraic Slattery --- .github/workflows/docs.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b2811b43bb..3fe3894076 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Fetch all history for git info diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f6fbd24738..3b3bcf8d3c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Run markdownlint-cli2 uses: DavidAnson/markdownlint-cli2-action@v19 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30f28f3210..39e0d8531a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} From ce7bed4823553def43c3b63610d341a0e61379f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:07:19 -0600 Subject: [PATCH 028/321] chore(deps): bump actions/setup-python from 5 to 6 (#1710) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60e3114c85..aa97c46d47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: astral-sh/setup-uv@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -39,7 +39,7 @@ jobs: uses: astral-sh/setup-uv@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From 525eae7f7efcc97520ad9c58800ebc2ccb3cf0da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:09:43 -0600 Subject: [PATCH 029/321] chore(deps): bump astral-sh/setup-uv from 6 to 7 (#1709) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6 to 7. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa97c46d47..9c62304388 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Set up Python uses: actions/setup-python@v6 @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From b55d00beedc1d4596a5170396e2683e7b31be3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Silva?= Date: Fri, 27 Feb 2026 11:20:00 -0300 Subject: [PATCH 030/321] fix: prepend YAML frontmatter to Cursor .mdc files (#1699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prepend YAML frontmatter to Cursor .mdc files for auto-inclusion Cursor IDE requires YAML frontmatter with `alwaysApply: true` in .mdc rule files for them to be automatically loaded. Without this frontmatter, users must manually configure glob patterns for the rules to take effect. This fix adds frontmatter generation to both the bash and PowerShell update-agent-context scripts, handling three scenarios: - New .mdc file creation (frontmatter prepended after template processing) - Existing .mdc file update without frontmatter (frontmatter added) - Existing .mdc file with frontmatter (no duplication) Closes #669 🤖 Generated with [Claude Code](https://claude.com/code) Co-Authored-By: Claude Opus 4.6 * refactor: address Copilot review suggestions - Handle CRLF line endings in frontmatter detection (grep '^---' instead of '^---$') - Fix double blank line after frontmatter in PowerShell New-AgentFile - Remove unused tempfile import from tests - Add encoding="utf-8" to all open() calls for cross-platform safety Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- scripts/bash/update-agent-context.sh | 26 +- scripts/powershell/update-agent-context.ps1 | 12 + tests/test_cursor_frontmatter.py | 263 ++++++++++++++++++++ 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 tests/test_cursor_frontmatter.py diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index a33ea5cdee..e775707c1c 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -351,10 +351,19 @@ create_new_agent_file() { # Convert \n sequences to actual newlines newline=$(printf '\n') sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" - + # Clean up backup files rm -f "$temp_file.bak" "$temp_file.bak2" - + + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if [[ "$target_file" == *.mdc ]]; then + local frontmatter_file + frontmatter_file=$(mktemp) || return 1 + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + return 0 } @@ -492,13 +501,24 @@ update_existing_agent_file() { changes_entries_added=true fi + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if [[ "$target_file" == *.mdc ]]; then + if ! head -1 "$temp_file" | grep -q '^---'; then + local frontmatter_file + frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + fi + # Move temp file to target atomically if ! mv "$temp_file" "$target_file"; then log_error "Failed to update target file" rm -f "$temp_file" return 1 fi - + return 0 } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 61718e96ce..ad8016a725 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -258,6 +258,12 @@ function New-AgentFile { # Convert literal \n sequences introduced by Escape to real newlines $content = $content -replace '\\n',[Environment]::NewLine + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if ($TargetFile -match '\.mdc$') { + $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine + $content = $frontmatter + $content + } + $parent = Split-Path -Parent $TargetFile if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 @@ -334,6 +340,12 @@ function Update-ExistingAgentFile { $newTechEntries | ForEach-Object { $output.Add($_) } } + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { + $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') + $output.InsertRange(0, $frontmatter) + } + Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 return $true } diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py new file mode 100644 index 0000000000..d9d0e34237 --- /dev/null +++ b/tests/test_cursor_frontmatter.py @@ -0,0 +1,263 @@ +""" +Tests for Cursor .mdc frontmatter generation (issue #669). + +Verifies that update-agent-context.sh properly prepends YAML frontmatter +to .mdc files so that Cursor IDE auto-includes the rules. +""" + +import os +import shutil +import subprocess +import textwrap + +import pytest + +SCRIPT_PATH = os.path.join( + os.path.dirname(__file__), + os.pardir, + "scripts", + "bash", + "update-agent-context.sh", +) + +EXPECTED_FRONTMATTER_LINES = [ + "---", + "description: Project Development Guidelines", + 'globs: ["**/*"]', + "alwaysApply: true", + "---", +] + +requires_git = pytest.mark.skipif( + shutil.which("git") is None, + reason="git is not installed", +) + + +class TestScriptFrontmatterPattern: + """Static analysis — no git required.""" + + def test_create_new_has_mdc_frontmatter_logic(self): + """create_new_agent_file() must contain .mdc frontmatter logic.""" + with open(SCRIPT_PATH, encoding="utf-8") as f: + content = f.read() + assert 'if [[ "$target_file" == *.mdc ]]' in content + assert "alwaysApply: true" in content + + def test_update_existing_has_mdc_frontmatter_logic(self): + """update_existing_agent_file() must also handle .mdc frontmatter.""" + with open(SCRIPT_PATH, encoding="utf-8") as f: + content = f.read() + # There should be two occurrences of the .mdc check — one per function + occurrences = content.count('if [[ "$target_file" == *.mdc ]]') + assert occurrences >= 2, ( + f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" + ) + + def test_powershell_script_has_mdc_frontmatter_logic(self): + """PowerShell script must also handle .mdc frontmatter.""" + ps_path = os.path.join( + os.path.dirname(__file__), + os.pardir, + "scripts", + "powershell", + "update-agent-context.ps1", + ) + with open(ps_path, encoding="utf-8") as f: + content = f.read() + assert "alwaysApply: true" in content + occurrences = content.count(r"\.mdc$") + assert occurrences >= 2, ( + f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" + ) + + +@requires_git +class TestCursorFrontmatterIntegration: + """Integration tests using a real git repo.""" + + @pytest.fixture + def git_repo(self, tmp_path): + """Create a minimal git repo with the spec-kit structure.""" + repo = tmp_path / "repo" + repo.mkdir() + + # Init git repo + subprocess.run( + ["git", "init"], cwd=str(repo), capture_output=True, check=True + ) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=str(repo), + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create .specify dir with config + specify_dir = repo / ".specify" + specify_dir.mkdir() + (specify_dir / "config.yaml").write_text( + textwrap.dedent("""\ + project_type: webapp + language: python + framework: fastapi + database: N/A + """) + ) + + # Create template + templates_dir = specify_dir / "templates" + templates_dir.mkdir() + (templates_dir / "agent-file-template.md").write_text( + "# [PROJECT NAME] Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: [DATE]\n\n" + "## Active Technologies\n\n" + "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" + "## Project Structure\n\n" + "[ACTUAL STRUCTURE FROM PLANS]\n\n" + "## Development Commands\n\n" + "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" + "## Coding Conventions\n\n" + "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" + "## Recent Changes\n\n" + "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" + ) + + # Create initial commit + subprocess.run( + ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True + ) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create a feature branch so CURRENT_BRANCH detection works + subprocess.run( + ["git", "checkout", "-b", "001-test-feature"], + cwd=str(repo), + capture_output=True, + check=True, + ) + + # Create a spec so the script detects the feature + spec_dir = repo / "specs" / "001-test-feature" + spec_dir.mkdir(parents=True) + (spec_dir / "plan.md").write_text( + "# Test Feature Plan\n\n" + "## Technology Stack\n\n" + "- Language: Python\n" + "- Framework: FastAPI\n" + ) + + return repo + + def _run_update(self, repo, agent_type="cursor-agent"): + """Run update-agent-context.sh for a specific agent type.""" + script = os.path.abspath(SCRIPT_PATH) + result = subprocess.run( + ["bash", script, agent_type], + cwd=str(repo), + capture_output=True, + text=True, + timeout=30, + ) + return result + + def test_new_mdc_file_has_frontmatter(self, git_repo): + """Creating a new .mdc file must include YAML frontmatter.""" + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" + assert mdc_file.exists(), "Cursor .mdc file was not created" + + content = mdc_file.read_text() + lines = content.splitlines() + + # First line must be the opening --- + assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" + + # Check all frontmatter lines are present + for expected in EXPECTED_FRONTMATTER_LINES: + assert expected in content, f"Missing frontmatter line: {expected}" + + # Content after frontmatter should be the template content + assert "Development Guidelines" in content + + def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): + """Updating an existing .mdc file that lacks frontmatter must add it.""" + # First, create the file WITHOUT frontmatter (simulating pre-fix state) + cursor_dir = git_repo / ".cursor" / "rules" + cursor_dir.mkdir(parents=True, exist_ok=True) + mdc_file = cursor_dir / "specify-rules.mdc" + mdc_file.write_text( + "# repo Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" + "## Active Technologies\n\n" + "- Python + FastAPI (main)\n\n" + "## Recent Changes\n\n" + "- main: Added Python + FastAPI\n" + ) + + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + content = mdc_file.read_text() + lines = content.splitlines() + + assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" + for expected in EXPECTED_FRONTMATTER_LINES: + assert expected in content, f"Missing frontmatter line: {expected}" + + def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): + """Updating an .mdc file that already has frontmatter must not duplicate it.""" + cursor_dir = git_repo / ".cursor" / "rules" + cursor_dir.mkdir(parents=True, exist_ok=True) + mdc_file = cursor_dir / "specify-rules.mdc" + + frontmatter = ( + "---\n" + "description: Project Development Guidelines\n" + 'globs: ["**/*"]\n' + "alwaysApply: true\n" + "---\n\n" + ) + body = ( + "# repo Development Guidelines\n\n" + "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" + "## Active Technologies\n\n" + "- Python + FastAPI (main)\n\n" + "## Recent Changes\n\n" + "- main: Added Python + FastAPI\n" + ) + mdc_file.write_text(frontmatter + body) + + result = self._run_update(git_repo) + assert result.returncode == 0, f"Script failed: {result.stderr}" + + content = mdc_file.read_text() + # Count occurrences of the frontmatter delimiter + assert content.count("alwaysApply: true") == 1, ( + "Frontmatter was duplicated" + ) + + def test_non_mdc_file_has_no_frontmatter(self, git_repo): + """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" + result = self._run_update(git_repo, agent_type="claude") + assert result.returncode == 0, f"Script failed: {result.stderr}" + + claude_file = git_repo / ".claude" / "CLAUDE.md" + if claude_file.exists(): + content = claude_file.read_text() + assert not content.startswith("---"), ( + "Non-mdc file should not have frontmatter" + ) From 2c41d3627e5a2f971c09b2cfccefc97df1e92366 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:52:13 -0600 Subject: [PATCH 031/321] fix: Split release process to sync pyproject.toml version with git tags (#1732) * fix: split release process to sync pyproject.toml version with git tags (#1721) - Split release workflow into two: release-trigger.yml and release.yml - release-trigger.yml: Updates pyproject.toml, generates changelog from commits, creates tag - release.yml: Triggered by tag push, builds artifacts, creates GitHub release - Ensures git tags point to commits with correct version in pyproject.toml - Auto-generates changelog from commit messages since last tag - Supports manual version input or auto-increment patch version - Added simulate-release.sh for local testing without pushing - Added comprehensive RELEASE-PROCESS.md documentation - Updated pyproject.toml to v0.1.10 to sync with latest release This fixes the version mismatch issue where tags pointed to commits with outdated pyproject.toml versions, preventing confusion when installing from source. * Update .github/workflows/RELEASE-PROCESS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/scripts/simulate-release.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/release-trigger.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: harden release-trigger against shell injection and fix stale docs - Pass workflow_dispatch version input via env: instead of direct interpolation into shell script, preventing potential injection attacks - Validate version input against strict semver regex before use - Fix RELEASE-PROCESS.md Option 2 still referencing [Unreleased] section handling that no longer exists in the workflow --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/RELEASE-PROCESS.md | 184 ++++++++++++++++++ .github/workflows/release-trigger.yml | 141 ++++++++++++++ .github/workflows/release.yml | 52 +++-- .github/workflows/scripts/simulate-release.sh | 161 +++++++++++++++ CHANGELOG.md | 39 ++++ pyproject.toml | 2 +- 6 files changed, 548 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/RELEASE-PROCESS.md create mode 100644 .github/workflows/release-trigger.yml create mode 100755 .github/workflows/scripts/simulate-release.sh diff --git a/.github/workflows/RELEASE-PROCESS.md b/.github/workflows/RELEASE-PROCESS.md new file mode 100644 index 0000000000..6d0aaa8594 --- /dev/null +++ b/.github/workflows/RELEASE-PROCESS.md @@ -0,0 +1,184 @@ +# Release Process + +This document describes the automated release process for Spec Kit. + +## Overview + +The release process is split into two workflows to ensure version consistency: + +1. **Release Trigger Workflow** (`release-trigger.yml`) - Manages versioning and triggers release +2. **Release Workflow** (`release.yml`) - Builds and publishes artifacts + +This separation ensures that git tags always point to commits with the correct version in `pyproject.toml`. + +## Before Creating a Release + +**Important**: Write clear, descriptive commit messages! + +### How CHANGELOG.md Works + +The CHANGELOG is **automatically generated** from your git commit messages: + +1. **During Development**: Write clear, descriptive commit messages: + ```bash + git commit -m "feat: Add new authentication feature" + git commit -m "fix: Resolve timeout issue in API client (#123)" + git commit -m "docs: Update installation instructions" + ``` + +2. **When Releasing**: The release trigger workflow automatically: + - Finds all commits since the last release tag + - Formats them as changelog entries + - Inserts them into CHANGELOG.md + - Commits the updated changelog before creating the new tag + +### Commit Message Best Practices + +Good commit messages make good changelogs: +- **Be descriptive**: "Add user authentication" not "Update files" +- **Reference issues/PRs**: Include `(#123)` for automated linking +- **Use conventional commits** (optional): `feat:`, `fix:`, `docs:`, `chore:` +- **Keep it concise**: One line is ideal, details go in commit body + +**Example commits that become good changelog entries:** +``` +fix: prepend YAML frontmatter to Cursor .mdc files (#1699) +feat: add generic agent support with customizable command directories (#1639) +docs: document dual-catalog system for extensions (#1689) +``` + +## Creating a Release + +### Option 1: Auto-Increment (Recommended for patches) + +1. Go to **Actions** → **Release Trigger** +2. Click **Run workflow** +3. Leave the version field **empty** +4. Click **Run workflow** + +The workflow will: +- Auto-increment the patch version (e.g., `0.1.10` → `0.1.11`) +- Update `pyproject.toml` +- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag +- Commit changes +- Create and push git tag +- Trigger the release workflow automatically + +### Option 2: Manual Version (For major/minor bumps) + +1. Go to **Actions** → **Release Trigger** +2. Click **Run workflow** +3. Enter the desired version (e.g., `0.2.0` or `v0.2.0`) +4. Click **Run workflow** + +The workflow will: +- Use your specified version +- Update `pyproject.toml` +- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag +- Commit changes +- Create and push git tag +- Trigger the release workflow automatically + +## What Happens Next + +Once the release trigger workflow completes: + +1. The git tag is pushed to GitHub +2. The **Release Workflow** is automatically triggered +3. Release artifacts are built for all supported agents +4. A GitHub Release is created with all assets +5. Release notes are generated from PR titles + +## Workflow Details + +### Release Trigger Workflow + +**File**: `.github/workflows/release-trigger.yml` + +**Trigger**: Manual (`workflow_dispatch`) + +**Permissions Required**: `contents: write` + +**Steps**: +1. Checkout repository +2. Determine version (manual or auto-increment) +3. Check if tag already exists (prevents duplicates) +4. Update `pyproject.toml` +5. Update `CHANGELOG.md` +6. Commit changes +7. Create and push tag + +### Release Workflow + +**File**: `.github/workflows/release.yml` + +**Trigger**: Tag push (`v*`) + +**Permissions Required**: `contents: write` + +**Steps**: +1. Checkout repository at tag +2. Extract version from tag name +3. Check if release already exists +4. Build release package variants (all agents × shell/powershell) +5. Generate release notes from commits +6. Create GitHub Release with all assets + +## Version Constraints + +- Tags must follow format: `v{MAJOR}.{MINOR}.{PATCH}` +- Example valid versions: `v0.1.11`, `v0.2.0`, `v1.0.0` +- Auto-increment only bumps patch version +- Cannot create duplicate tags (workflow will fail) + +## Benefits of This Approach + +✅ **Version Consistency**: Git tags point to commits with matching `pyproject.toml` version + +✅ **Single Source of Truth**: Version set once, used everywhere + +✅ **Prevents Drift**: No more manual version synchronization needed + +✅ **Clean Separation**: Versioning logic separate from artifact building + +✅ **Flexibility**: Supports both auto-increment and manual versioning + +## Troubleshooting + +### No Commits Since Last Release + +If you run the release trigger workflow when there are no new commits since the last tag: +- The workflow will still succeed +- The CHANGELOG will show "- Initial release" if it's the first release +- Or it will be empty if there are no commits +- Consider adding meaningful commits before releasing + +**Best Practice**: Use descriptive commit messages - they become your changelog! + +### Tag Already Exists + +If you see "Error: Tag vX.Y.Z already exists!", you need to: +- Choose a different version number, or +- Delete the existing tag if it was created in error + +### Release Workflow Didn't Trigger + +Check that: +- The release trigger workflow completed successfully +- The tag was pushed (check repository tags) +- The release workflow is enabled in Actions settings + +### Version Mismatch + +If `pyproject.toml` doesn't match the latest tag: +- Run the release trigger workflow to sync versions +- Or manually update `pyproject.toml` and push changes before running the release trigger + +## Legacy Behavior (Pre-v0.1.10) + +Before this change, the release workflow: +- Created tags automatically on main branch pushes +- Updated `pyproject.toml` AFTER creating the tag +- Resulted in tags pointing to commits with outdated versions + +This has been fixed in v0.1.10+. diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml new file mode 100644 index 0000000000..dd16152c50 --- /dev/null +++ b/.github/workflows/release-trigger.yml @@ -0,0 +1,141 @@ +name: Release Trigger + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.11). Leave empty to auto-increment patch version.' + required: false + type: string + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Determine version + id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + if [[ -n "$INPUT_VERSION" ]]; then + # Manual version specified - strip optional v prefix + VERSION="${INPUT_VERSION#v}" + # Validate strict semver format to prevent injection + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format '$VERSION'. Must be X.Y.Z (e.g. 1.2.3 or v1.2.3)" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + echo "Using manual version: $VERSION" + else + # Auto-increment patch version + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + + # Extract version number and increment + VERSION=$(echo $LATEST_TAG | sed 's/v//') + IFS='.' read -ra VERSION_PARTS <<< "$VERSION" + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + # Increment patch version + PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + echo "Auto-incremented version: $NEW_VERSION" + fi + + - name: Check if tag already exists + run: | + if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "Error: Tag ${{ steps.version.outputs.tag }} already exists!" + exit 1 + fi + + - name: Update pyproject.toml + run: | + sed -i "s/version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml + echo "Updated pyproject.toml to version ${{ steps.version.outputs.version }}" + + - name: Update CHANGELOG.md + run: | + if [ -f "CHANGELOG.md" ]; then + DATE=$(date +%Y-%m-%d) + + # Get the previous tag to compare commits + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + echo "Generating changelog from commits..." + if [[ -n "$PREVIOUS_TAG" ]]; then + echo "Changes since $PREVIOUS_TAG" + + # Get commits since last tag, format as bullet points + # Extract PR numbers and format nicely + COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") + else + echo "No previous tag found - this is the first release" + COMMITS="- Initial release" + fi + + # Create new changelog entry + { + head -n 8 CHANGELOG.md + echo "" + echo "## [${{ steps.version.outputs.version }}] - $DATE" + echo "" + echo "### Changed" + echo "" + echo "$COMMITS" + echo "" + tail -n +9 CHANGELOG.md + } > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + + echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG" + else + echo "No CHANGELOG.md found" + fi + + - name: Commit version bump + run: | + if [ -f "CHANGELOG.md" ]; then + git add pyproject.toml CHANGELOG.md + else + git add pyproject.toml + fi + + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: bump version to ${{ steps.version.outputs.version }}" + echo "Changes committed" + fi + - name: Create and push tag + run: | + git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" + git push origin main + git push origin "${{ steps.version.outputs.tag }}" + echo "Tag ${{ steps.version.outputs.tag }} created and pushed" + + - name: Summary + run: | + echo "✅ Version bumped to ${{ steps.version.outputs.version }}" + echo "✅ Tag ${{ steps.version.outputs.tag }} created and pushed" + echo "🚀 Release workflow will now build artifacts automatically" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e0d8531a..2e29592cc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,68 +2,60 @@ name: Create Release on: push: - branches: [ main ] - paths: - - 'memory/**' - - 'scripts/**' - - 'src/**' - - 'templates/**' - - '.github/workflows/**' - workflow_dispatch: + tags: + - 'v*' jobs: release: runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Get latest tag - id: get_tag + + - name: Extract version from tag + id: version run: | - chmod +x .github/workflows/scripts/get-next-version.sh - .github/workflows/scripts/get-next-version.sh + VERSION=${GITHUB_REF#refs/tags/} + echo "tag=$VERSION" >> $GITHUB_OUTPUT + echo "Building release for $VERSION" + - name: Check if release already exists id: check_release run: | chmod +x .github/workflows/scripts/check-release-exists.sh - .github/workflows/scripts/check-release-exists.sh ${{ steps.get_tag.outputs.new_version }} + .github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create release package variants if: steps.check_release.outputs.exists == 'false' run: | chmod +x .github/workflows/scripts/create-release-packages.sh - .github/workflows/scripts/create-release-packages.sh ${{ steps.get_tag.outputs.new_version }} + .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' id: release_notes run: | chmod +x .github/workflows/scripts/generate-release-notes.sh - .github/workflows/scripts/generate-release-notes.sh ${{ steps.get_tag.outputs.new_version }} ${{ steps.get_tag.outputs.latest_tag }} + # Get the previous tag for changelog generation + PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "") + # Default to v0.0.0 if no previous tag is found (e.g., first release) + if [ -z "$PREVIOUS_TAG" ]; then + PREVIOUS_TAG="v0.0.0" + fi + .github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG" + - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' run: | chmod +x .github/workflows/scripts/create-github-release.sh - .github/workflows/scripts/create-github-release.sh ${{ steps.get_tag.outputs.new_version }} + .github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update version in pyproject.toml (for release artifacts only) - if: steps.check_release.outputs.exists == 'false' - run: | - chmod +x .github/workflows/scripts/update-version.sh - .github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }} - - name: Commit version bump to main - if: steps.check_release.outputs.exists == 'false' - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add pyproject.toml - git diff --cached --quiet || git commit -m "chore: bump version to ${{ steps.get_tag.outputs.new_version }} [skip ci]" - git push diff --git a/.github/workflows/scripts/simulate-release.sh b/.github/workflows/scripts/simulate-release.sh new file mode 100755 index 0000000000..a3960d0317 --- /dev/null +++ b/.github/workflows/scripts/simulate-release.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +# simulate-release.sh +# Simulate the release process locally without pushing to GitHub +# Usage: simulate-release.sh [version] +# If version is omitted, auto-increments patch version + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}" +echo "======================================" +echo "" + +# Step 1: Determine version +if [[ -n "${1:-}" ]]; then + VERSION="${1#v}" + TAG="v$VERSION" + echo -e "${GREEN}📝 Using manual version: $VERSION${NC}" +else + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}" + + VERSION=$(echo $LATEST_TAG | sed 's/v//') + IFS='.' read -ra VERSION_PARTS <<< "$VERSION" + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + PATCH=$((PATCH + 1)) + VERSION="$MAJOR.$MINOR.$PATCH" + TAG="v$VERSION" + echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}" +fi + +echo "" + +# Step 2: Check if tag exists +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}" + echo " Please use a different version or delete the tag first." + exit 1 +fi +echo -e "${GREEN}✓ Tag $TAG is available${NC}" + +# Step 3: Backup current state +echo "" +echo -e "${YELLOW}💾 Creating backup of current state...${NC}" +BACKUP_DIR=$(mktemp -d) +cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak" +cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak" +echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}" + +# Step 4: Update pyproject.toml +echo "" +echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}" +sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml +rm -f pyproject.toml.tmp +echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}" + +# Step 5: Update CHANGELOG.md +echo "" +echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}" +DATE=$(date +%Y-%m-%d) + +# Get the previous tag to compare commits +PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + +if [[ -n "$PREVIOUS_TAG" ]]; then + echo " Generating changelog from commits since $PREVIOUS_TAG" + # Get commits since last tag, format as bullet points + COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") +else + echo " No previous tag found - this is the first release" + COMMITS="- Initial release" +fi + +# Create temp file with new entry +{ + head -n 8 CHANGELOG.md + echo "" + echo "## [$VERSION] - $DATE" + echo "" + echo "### Changed" + echo "" + echo "$COMMITS" + echo "" + tail -n +9 CHANGELOG.md +} > CHANGELOG.md.tmp +mv CHANGELOG.md.tmp CHANGELOG.md +echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}" + +# Step 6: Show what would be committed +echo "" +echo -e "${YELLOW}📋 Changes that would be committed:${NC}" +git diff pyproject.toml CHANGELOG.md + +# Step 7: Create temporary tag (no push) +echo "" +echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}" +git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true +echo -e "${GREEN}✓ Tag $TAG created locally${NC}" + +# Step 8: Simulate release artifact creation +echo "" +echo -e "${YELLOW}📦 Simulating release package creation...${NC}" +echo " (High-level simulation only; packaging script is not executed)" +echo "" + +# Check if script exists and is executable +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then + echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}" + echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\"" + echo "" + echo "This simulation does not enumerate individual package files to avoid" + echo "drifting from the actual behavior of create-release-packages.sh." +else + echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}" +fi + +# Step 9: Simulate release notes generation +echo "" +echo -e "${YELLOW}📄 Simulating release notes generation...${NC}" +echo "" +PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "") +if [[ -n "$PREVIOUS_TAG" ]]; then + echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}" + git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10 + echo "" +else + echo -e "${BLUE}No previous tag found - this would be the first release${NC}" +fi + +# Step 10: Summary +echo "" +echo -e "${GREEN}🎉 Simulation Complete!${NC}" +echo "======================================" +echo "" +echo -e "${BLUE}Summary:${NC}" +echo " Version: $VERSION" +echo " Tag: $TAG" +echo " Backup: $BACKUP_DIR" +echo "" +echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}" +echo "" +echo -e "${BLUE}Next steps:${NC}" +echo " 1. Review the changes above" +echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit" +echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG" +echo " 4. To restore from backup: cp $BACKUP_DIR/* ." +echo "" +echo -e "${BLUE}To run the actual release:${NC}" +echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml" +echo " Click 'Run workflow' and enter version: $VERSION" +echo "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 741ce5d0cb..c38c0f226b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.10] - 2026-03-02 + +### Fixed + +- **Version Sync Issue (#1721)**: Fixed version mismatch between `pyproject.toml` and git release tags + - Split release process into two workflows: `release-trigger.yml` for version management and `release.yml` for artifact building + - Version bump now happens BEFORE tag creation, ensuring tags point to commits with correct version + - Supports both manual version specification and auto-increment (patch version) + - Git tags now accurately reflect the version in `pyproject.toml` at that commit + - Prevents confusion when installing from source + +## [0.1.9] - 2026-02-28 + +### Changed + +- Updated dependency: bumped astral-sh/setup-uv from 6 to 7 + +## [0.1.8] - 2026-02-28 + +### Changed + +- Updated dependency: bumped actions/setup-python from 5 to 6 + +## [0.1.7] - 2026-02-27 + +### Changed + +- Updated outdated GitHub Actions versions +- Documented dual-catalog system for extensions + +### Fixed + +- Fixed version command in documentation + +### Added + +- Added Cleanup Extension to README +- Added retrospective extension to community catalog + ## [0.1.6] - 2026-02-23 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 5f6a2eb7ab..d0fc64b03e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.6" +version = "0.1.10" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 658ab2a38c80d51be10c3d85ec0381185d40074c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:16:13 -0600 Subject: [PATCH 032/321] fix: release-trigger uses release branch + PR instead of direct push to main (#1733) * fix: use release branch + PR instead of direct push to main Bypass branch protection rules by pushing version bump to a chore/release-vX.Y.Z branch, tagging that commit, then opening an auto PR to merge back into main. The release workflow still triggers immediately from the tag push. * fix: remove --label automated from gh pr create (label does not exist) --- .github/workflows/RELEASE-PROCESS.md | 37 ++++++++++++--------- .github/workflows/release-trigger.yml | 46 +++++++++++++++++++-------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/.github/workflows/RELEASE-PROCESS.md b/.github/workflows/RELEASE-PROCESS.md index 6d0aaa8594..18fe40e858 100644 --- a/.github/workflows/RELEASE-PROCESS.md +++ b/.github/workflows/RELEASE-PROCESS.md @@ -60,9 +60,10 @@ The workflow will: - Auto-increment the patch version (e.g., `0.1.10` → `0.1.11`) - Update `pyproject.toml` - Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag -- Commit changes -- Create and push git tag -- Trigger the release workflow automatically +- Commit changes to a `chore/release-vX.Y.Z` branch +- Create and push the git tag from that branch +- Open a PR to merge the version bump into `main` +- Trigger the release workflow automatically via the tag push ### Option 2: Manual Version (For major/minor bumps) @@ -75,19 +76,23 @@ The workflow will: - Use your specified version - Update `pyproject.toml` - Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag -- Commit changes -- Create and push git tag -- Trigger the release workflow automatically +- Commit changes to a `chore/release-vX.Y.Z` branch +- Create and push the git tag from that branch +- Open a PR to merge the version bump into `main` +- Trigger the release workflow automatically via the tag push ## What Happens Next Once the release trigger workflow completes: -1. The git tag is pushed to GitHub -2. The **Release Workflow** is automatically triggered -3. Release artifacts are built for all supported agents -4. A GitHub Release is created with all assets -5. Release notes are generated from PR titles +1. A `chore/release-vX.Y.Z` branch is pushed with the version bump commit +2. The git tag is pushed, pointing to that commit +3. The **Release Workflow** is automatically triggered by the tag push +4. Release artifacts are built for all supported agents +5. A GitHub Release is created with all assets +6. A PR is opened to merge the version bump branch into `main` + +> **Note**: Merge the auto-opened PR after the release is published to keep `main` in sync. ## Workflow Details @@ -103,10 +108,12 @@ Once the release trigger workflow completes: 1. Checkout repository 2. Determine version (manual or auto-increment) 3. Check if tag already exists (prevents duplicates) -4. Update `pyproject.toml` -5. Update `CHANGELOG.md` -6. Commit changes -7. Create and push tag +4. Create `chore/release-vX.Y.Z` branch +5. Update `pyproject.toml` +6. Update `CHANGELOG.md` from git commits +7. Commit changes +8. Push branch and tag +9. Open PR to merge version bump into `main` ### Release Workflow diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index dd16152c50..bccaf6b1a0 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -45,18 +46,18 @@ jobs: # Auto-increment patch version LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") echo "Latest tag: $LATEST_TAG" - + # Extract version number and increment VERSION=$(echo $LATEST_TAG | sed 's/v//') IFS='.' read -ra VERSION_PARTS <<< "$VERSION" MAJOR=${VERSION_PARTS[0]:-0} MINOR=${VERSION_PARTS[1]:-0} PATCH=${VERSION_PARTS[2]:-0} - + # Increment patch version PATCH=$((PATCH + 1)) NEW_VERSION="$MAJOR.$MINOR.$PATCH" - + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT echo "Auto-incremented version: $NEW_VERSION" @@ -69,6 +70,12 @@ jobs: exit 1 fi + - name: Create release branch + run: | + BRANCH="chore/release-${{ steps.version.outputs.tag }}" + git checkout -b "$BRANCH" + echo "branch=$BRANCH" >> $GITHUB_ENV + - name: Update pyproject.toml run: | sed -i "s/version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml @@ -78,22 +85,19 @@ jobs: run: | if [ -f "CHANGELOG.md" ]; then DATE=$(date +%Y-%m-%d) - + # Get the previous tag to compare commits PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - + echo "Generating changelog from commits..." if [[ -n "$PREVIOUS_TAG" ]]; then echo "Changes since $PREVIOUS_TAG" - - # Get commits since last tag, format as bullet points - # Extract PR numbers and format nicely COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") else echo "No previous tag found - this is the first release" COMMITS="- Initial release" fi - + # Create new changelog entry { head -n 8 CHANGELOG.md @@ -107,7 +111,7 @@ jobs: tail -n +9 CHANGELOG.md } > CHANGELOG.md.tmp mv CHANGELOG.md.tmp CHANGELOG.md - + echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG" else echo "No CHANGELOG.md found" @@ -127,15 +131,31 @@ jobs: git commit -m "chore: bump version to ${{ steps.version.outputs.version }}" echo "Changes committed" fi + - name: Create and push tag run: | git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" - git push origin main + git push origin "${{ env.branch }}" git push origin "${{ steps.version.outputs.tag }}" - echo "Tag ${{ steps.version.outputs.tag }} created and pushed" + echo "Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed" + + - name: Open pull request + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --base main \ + --head "${{ env.branch }}" \ + --title "chore: bump version to ${{ steps.version.outputs.version }}" \ + --body "Automated version bump to ${{ steps.version.outputs.version }}. + + This PR was created by the Release Trigger workflow. The git tag \`${{ steps.version.outputs.tag }}\` has already been pushed and the release artifacts are being built. + + Merge this PR to record the version bump and changelog update on \`main\`." - name: Summary run: | echo "✅ Version bumped to ${{ steps.version.outputs.version }}" echo "✅ Tag ${{ steps.version.outputs.tag }} created and pushed" - echo "🚀 Release workflow will now build artifacts automatically" + echo "✅ PR opened to merge version bump into main" + echo "🚀 Release workflow is building artifacts from the tag" From 78ed453e38a77504a02427da97241e1e00c0a241 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:31:18 -0600 Subject: [PATCH 033/321] fix: use RELEASE_PAT so tag push triggers release workflow (#1736) --- .github/workflows/release-trigger.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index bccaf6b1a0..77b727a8c1 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.RELEASE_PAT }} - name: Configure Git run: | @@ -141,7 +141,7 @@ jobs: - name: Open pull request env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} run: | gh pr create \ --base main \ From bfaca2c44971733fde5e8d27f82c9c546e1b9f3d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:35:43 -0600 Subject: [PATCH 034/321] chore: bump version to 0.1.12 (#1737) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c38c0f226b..a8f5b88b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.12] - 2026-03-02 + +### Changed + +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [0.1.10] - 2026-03-02 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index d0fc64b03e..82e4553b73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.10" +version = "0.1.12" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From abe1b7085c660587f6e783cd1ee113efcc4458b7 Mon Sep 17 00:00:00 2001 From: Zaheer Uddin Date: Tue, 3 Mar 2026 13:46:31 +0000 Subject: [PATCH 035/321] fix(clarify): correct conflicting question limit from 10 to 5 (#1557) Resolves inconsistency where line 92 stated 'Maximum of 10 total questions' while all other references (lines 2, 91, 100, 134, 158, 178) consistently specify a maximum of 5 questions. Co-authored-by: Augment Agent --- templates/commands/clarify.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 4de842aa60..196fa2c0f7 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -89,7 +89,7 @@ Execution steps: - Information is better deferred to planning phase (note internally) 3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: - - Maximum of 10 total questions across the whole session. + - Maximum of 5 total questions across the whole session. - Each question must be answerable with EITHER: - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR - A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). From 2b92ab444debbbd2e81d4faafc95fb0304d3e687 Mon Sep 17 00:00:00 2001 From: Zaheer Uddin Date: Tue, 3 Mar 2026 13:52:02 +0000 Subject: [PATCH 036/321] fix(checklist): clarify file handling behavior for append vs create (#1556) * fix(checklist): clarify file handling behavior for append vs create Resolves contradictory instructions in checklist.md lines 97-99 where the template stated both 'append to existing file' and 'creates a NEW file'. Changes: - Clarified that if file doesn't exist, create new with CHK001 - If file exists, append new items continuing from last CHK ID - Emphasized preservation of existing content (never delete/replace) Co-authored-by: Augment Agent * fix(checklist): align report language with append behavior --------- Co-authored-by: Augment Agent --- templates/commands/checklist.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index e32a2c843b..a79131a204 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -94,9 +94,10 @@ You **MUST** consider the user input before proceeding (if not empty). - Generate unique checklist filename: - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`) - Format: `[domain].md` - - If file exists, append to existing file - - Number items sequentially starting from CHK001 - - Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists) + - File handling behavior: + - If file does NOT exist: Create new file and number items starting from CHK001 + - If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016) + - Never delete or replace existing checklist content - always preserve and append **CORE PRINCIPLE - Test the Requirements, Not the Implementation**: Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for: @@ -208,13 +209,13 @@ You **MUST** consider the user input before proceeding (if not empty). 6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### ` lines with globally incrementing IDs starting at CHK001. -7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize: +7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize: - Focus areas selected - Depth level - Actor/timing - Any explicit user-specified must-have items incorporated -**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows: +**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows: - Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`) - Simple, memorable filenames that indicate checklist purpose From bf8fb125ad7dd9a86bb0ac6d4dd98d93bd712949 Mon Sep 17 00:00:00 2001 From: Barry Gervin Date: Tue, 3 Mar 2026 09:18:18 -0500 Subject: [PATCH 037/321] Add sync extension to community catalog (#1728) * Add sync extension to community catalog - Extension ID: sync - Version: 0.1.0 - Author: bgervin - Description: Detect and resolve drift between specs and implementation * fix: use main branch in URLs per Copilot review * Reorder community extensions table alphabetically Per Copilot review feedback and EXTENSION-PUBLISHING-GUIDE.md --------- Co-authored-by: Barry Gervin --- extensions/README.md | 4 +++- extensions/catalog.community.json | 28 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index 574144a4d1..dd26f66389 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -72,8 +72,10 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | URL | |-----------|---------|-----| -| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | +| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | ## Adding Your Extension diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4ab408b7c5..db6bd87142 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-02-24T00:00:00Z", + "updated_at": "2026-03-02T05:42:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "cleanup": { @@ -55,6 +55,32 @@ "created_at": "2026-02-24T00:00:00Z", "updated_at": "2026-02-24T00:00:00Z" }, + "sync": { + "name": "Spec Sync", + "id": "sync", + "description": "Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval.", + "author": "bgervin", + "version": "0.1.0", + "download_url": "https://github.com/bgervin/spec-kit-sync/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/bgervin/spec-kit-sync", + "homepage": "https://github.com/bgervin/spec-kit-sync", + "documentation": "https://github.com/bgervin/spec-kit-sync/blob/main/README.md", + "changelog": "https://github.com/bgervin/spec-kit-sync/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 5, + "hooks": 1 + }, + "tags": ["sync", "drift", "validation", "bidirectional", "backfill"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-02T00:00:00Z", + "updated_at": "2026-03-02T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From dd8dbf63441521f5419f335f51a12b92f1772295 Mon Sep 17 00:00:00 2001 From: Zaheer Uddin Date: Tue, 3 Mar 2026 14:28:40 +0000 Subject: [PATCH 038/321] fix(implement): remove Makefile from C ignore patterns (#1558) * fix(implement): remove Makefile from C ignore patterns Makefile is typically source-controlled and should not be in .gitignore. Replaced with proper autotools-generated files (autom4te.cache/, config.status). Co-authored-by: Augment Agent * fix(implement): restore config.log in C ignore patterns --------- Co-authored-by: Augment Agent --- templates/commands/implement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 39abb1e6c8..a565f50e74 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -88,7 +88,7 @@ You **MUST** consider the user input before proceeding (if not empty). - **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*` - **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*` - **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*` - - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*` + - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` - **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/` - **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/` - **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/` From f6264d4ef44ebedb48f5178835e4cf9390c006d8 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:03:38 +0100 Subject: [PATCH 039/321] fix: correct Copilot extension command registration (#1724) * fix: correct Copilot extension command registration (#copilot) - Use .agent.md extension for commands in .github/agents/ - Generate companion .prompt.md files in .github/prompts/ - Clean up .prompt.md files on extension removal - Add tests for Copilot-specific registration behavior Bumps version to 0.1.7. * fix: test copilot prompt cleanup via ExtensionManager.remove() instead of manual unlink --------- Co-authored-by: Ismael --- CHANGELOG.md | 9 +++ pyproject.toml | 2 +- src/specify_cli/extensions.py | 32 +++++++- tests/test_extensions.py | 140 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f5b88b93..761f4aede7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.13] - 2026-03-03 + +### Fixed + +- **Copilot Extension Commands Not Visible**: Fixed extension commands not appearing in GitHub Copilot when installed via `specify extension add --dev` + - Changed Copilot file extension from `.md` to `.agent.md` in `CommandRegistrar.AGENT_CONFIGS` so Copilot recognizes agent files + - Added generation of companion `.prompt.md` files in `.github/prompts/` during extension command registration, matching the release packaging behavior + - Added cleanup of `.prompt.md` companion files when removing extensions via `specify extension remove` + ## [0.1.12] - 2026-03-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 82e4553b73..0a84bc8cf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.12" +version = "0.1.13" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b8881e7c89..77da1aca04 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -455,6 +455,12 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: 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() + if keep_config: # Preserve config files, only remove non-config files if extension_dir.exists(): @@ -597,7 +603,7 @@ class CommandRegistrar: "dir": ".github/agents", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md" + "extension": ".agent.md" }, "cursor": { "dir": ".cursor/commands", @@ -871,16 +877,40 @@ def register_commands_for_agent( dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" 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.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") + def register_commands_for_all_agents( self, manifest: ExtensionManifest, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a2c4121ed4..c45dcd00c7 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -520,6 +520,121 @@ def test_command_with_aliases(self, project_dir, temp_dir): assert (claude_dir / "speckit.alias.cmd.md").exists() assert (claude_dir / "speckit.shortcut.md").exists() + def test_register_commands_for_copilot(self, extension_dir, project_dir): + """Test registering commands for Copilot agent with .agent.md extension.""" + # Create .github/agents directory (Copilot project) + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + + registrar = CommandRegistrar() + registered = registrar.register_commands_for_agent( + "copilot", manifest, extension_dir, project_dir + ) + + assert len(registered) == 1 + assert "speckit.test.hello" in registered + + # Verify command file uses .agent.md extension + cmd_file = agents_dir / "speckit.test.hello.agent.md" + assert cmd_file.exists() + + # Verify NO plain .md file was created + plain_md_file = agents_dir / "speckit.test.hello.md" + assert not plain_md_file.exists() + + content = cmd_file.read_text() + assert "description: Test hello command" in content + assert "" in content + + def test_copilot_companion_prompt_created(self, extension_dir, project_dir): + """Test that companion .prompt.md files are created in .github/prompts/.""" + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "copilot", manifest, extension_dir, project_dir + ) + + # Verify companion .prompt.md file exists + prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md" + assert prompt_file.exists() + + # Verify content has correct agent frontmatter + content = prompt_file.read_text() + assert content == "---\nagent: speckit.test.hello\n---\n" + + def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir): + """Test that aliases also get companion .prompt.md files for Copilot.""" + import yaml + + ext_dir = temp_dir / "ext-alias-copilot" + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-alias-copilot", + "name": "Extension with Alias", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.alias-copilot.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.shortcut-copilot"], + } + ] + }, + } + + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Test\n---\n\nTest" + ) + + # Set up Copilot project + (project_dir / ".github" / "agents").mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_agent( + "copilot", manifest, ext_dir, project_dir + ) + + assert len(registered) == 2 + + # Both primary and alias get companion .prompt.md + prompts_dir = project_dir / ".github" / "prompts" + assert (prompts_dir / "speckit.alias-copilot.cmd.prompt.md").exists() + assert (prompts_dir / "speckit.shortcut-copilot.prompt.md").exists() + + def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): + """Test that non-copilot agents do NOT create .prompt.md files.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "claude", manifest, extension_dir, project_dir + ) + + # No .github/prompts directory should exist + prompts_dir = project_dir / ".github" / "prompts" + assert not prompts_dir.exists() + # ===== Utility Function Tests ===== @@ -596,6 +711,31 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): assert not cmd_file.exists() assert len(manager.list_installed()) == 0 + def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir): + """Test that removing a Copilot extension also removes .prompt.md files.""" + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=True) + + # Verify copilot was detected and registered + metadata = manager.registry.get("test-ext") + assert "copilot" in metadata["registered_commands"] + + # Verify files exist before cleanup + agent_file = agents_dir / "speckit.test.hello.agent.md" + prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md" + assert agent_file.exists() + assert prompt_file.exists() + + # Use the extension manager to remove — exercises the copilot prompt cleanup code + result = manager.remove("test-ext") + assert result is True + + assert not agent_file.exists() + assert not prompt_file.exists() + def test_multiple_extensions(self, temp_dir, project_dir): """Test installing multiple extensions.""" import yaml From 6757c90dbd03185c2a07e174c1872cb2d34f2b86 Mon Sep 17 00:00:00 2001 From: Zaheer Uddin Date: Tue, 3 Mar 2026 15:42:46 +0000 Subject: [PATCH 040/321] fix(scripts): add empty description validation and branch checkout error handling (#1559) * fix(scripts): add empty description validation and branch checkout error handling Adds two critical improvements to both PowerShell and Bash feature creation scripts: 1. Post-trim validation: Prevents creating features with whitespace-only descriptions 2. Branch checkout error handling: Provides clear error messages when branch creation fails (e.g., branch already exists) instead of silently continuing Co-authored-by: Augment Agent * fix(scripts): use consistent stderr redirection for branch checkout --------- Co-authored-by: Augment Agent --- scripts/bash/create-new-feature.sh | 18 +++++++++++++++- scripts/powershell/create-new-feature.ps1 | 26 +++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c40cfd77f0..54697024d6 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -67,6 +67,13 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then exit 1 fi +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + # Function to find the repository root by searching for existing project markers find_repo_root() { local dir="$1" @@ -272,7 +279,16 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then fi if [ "$HAS_GIT" = true ]; then - git checkout -b "$BRANCH_NAME" + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." + exit 1 + fi + fi else >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..8f88b6c568 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -35,6 +35,12 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() +# Validate description is not empty after trimming (e.g., user passed only whitespace) +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialized with --no-git. @@ -242,10 +248,26 @@ if ($branchName.Length -gt $maxBranchLength) { } if ($hasGit) { + $branchCreated = $false try { - git checkout -b $branchName | Out-Null + git checkout -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } } catch { - Write-Warning "Failed to create git branch: $branchName" + # Exception during git command + } + + if (-not $branchCreated) { + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + exit 1 + } } } else { Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" From 254e9bbdf06177637e7bfb559aa47886717c0e67 Mon Sep 17 00:00:00 2001 From: Emi <140513018+emi-dm@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:59:53 +0100 Subject: [PATCH 041/321] Add Retrospective Extension to community catalog README table (#1741) * Add retrospective extension to community catalog. Register the new retrospective extension release so users can discover and install it from the community catalog. Co-authored-by: Cursor * Add Retrospective Extension to community catalog --------- Co-authored-by: Cursor --- extensions/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/README.md b/extensions/README.md index dd26f66389..e9db58c0f6 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -77,6 +77,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | + ## Adding Your Extension ### Submission Process From 9cf33e81cc89509fa136a46549f7cc2323326eae Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:21:06 +0100 Subject: [PATCH 042/321] feat: add verify extension to community catalog (#1726) * feat: add verify extension to community catalog * Add verify to extension readme --------- Co-authored-by: Ismael --- extensions/README.md | 1 + extensions/catalog.community.json | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index e9db58c0f6..4c41d52dea 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,6 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | +| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | ## Adding Your Extension diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index db6bd87142..3c8dd0c205 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-02T05:42:00Z", + "updated_at": "2026-03-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "cleanup": { @@ -106,6 +106,32 @@ "stars": 0, "created_at": "2026-02-20T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" + }, + "verify": { + "name": "Verify Extension", + "id": "verify", + "description": "Post-implementation quality gate that validates implemented code against specification artifacts.", + "author": "ismaelJimenez", + "version": "1.0.0", + "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/ismaelJimenez/spec-kit-verify", + "homepage": "https://github.com/ismaelJimenez/spec-kit-verify", + "documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md", + "changelog": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": ["verification", "quality-gate", "implementation", "spec-adherence", "compliance"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-03-03T00:00:00Z" } } } From 32c6e7f40cc3b4fb7a5b24b27b663b3c87390b0d Mon Sep 17 00:00:00 2001 From: Medhat Galal Date: Tue, 3 Mar 2026 13:04:46 -0500 Subject: [PATCH 043/321] feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) * feat: add kiro-cli and AGENT_CONFIG consistency coverage * fix: address PR feedback for kiro-cli migration * test: assert init invocation result in --here mode test * test: capture init result in here-mode command test * chore: save local unapproved work in progress * fix: resolve remaining PR1690 review threads * Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: reduce brittleness in ai help alias assertion * fix: resolve PR1690 ruff syntax regression --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 - .devcontainer/post-create.sh | 53 +++++----- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .../scripts/create-github-release.sh | 4 +- .../scripts/create-release-packages.ps1 | 23 +++-- .../scripts/create-release-packages.sh | 11 +-- AGENTS.md | 12 +-- CHANGELOG.md | 2 +- README.md | 11 ++- pyproject.toml | 1 - scripts/bash/update-agent-context.sh | 19 ++-- scripts/powershell/update-agent-context.ps1 | 15 ++- src/specify_cli/__init__.py | 52 ++++++++-- src/specify_cli/extensions.py | 5 +- tests/test_agent_config_consistency.py | 99 +++++++++++++++++++ tests/test_ai_skills.py | 73 +++++++++++++- tests/test_extensions.py | 6 ++ 19 files changed, 306 insertions(+), 88 deletions(-) create mode 100644 tests/test_agent_config_consistency.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index caf184c1c6..88acdcd5fd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -50,8 +50,6 @@ "kilocode.Kilo-Code", // Roo Code "RooVeterinaryInc.roo-cline", - // Amazon Developer Q - "AmazonWebServices.amazon-q-vscode", // Claude Code "anthropic.claude-code" ], diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 9608a28a3d..024ff0db48 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -51,32 +51,33 @@ echo -e "\n🤖 Installing OpenCode CLI..." run_command "npm install -g opencode-ai@latest" echo "✅ Done" -echo -e "\n🤖 Installing Amazon Q CLI..." -# 👉🏾 https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-verify-download.html - -run_command "curl --proto '=https' --tlsv1.2 -sSf 'https://desktop-release.q.us-east-1.amazonaws.com/latest/q-x86_64-linux.zip' -o 'q.zip'" -run_command "curl --proto '=https' --tlsv1.2 -sSf 'https://desktop-release.q.us-east-1.amazonaws.com/latest/q-x86_64-linux.zip.sig' -o 'q.zip.sig'" -cat > amazonq-public-key.asc << 'EOF' ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEZig60RYJKwYBBAHaRw8BAQdAy/+G05U5/EOA72WlcD4WkYn5SInri8pc4Z6D -BKNNGOm0JEFtYXpvbiBRIENMSSBUZWFtIDxxLWNsaUBhbWF6b24uY29tPoiZBBMW -CgBBFiEEmvYEF+gnQskUPgPsUNx6jcJMVmcFAmYoOtECGwMFCQPCZwAFCwkIBwIC -IgIGFQoJCAsCBBYCAwECHgcCF4AACgkQUNx6jcJMVmef5QD/QWWEGG/cOnbDnp68 -SJXuFkwiNwlH2rPw9ZRIQMnfAS0A/0V6ZsGB4kOylBfc7CNfzRFGtovdBBgHqA6P -zQ/PNscGuDgEZig60RIKKwYBBAGXVQEFAQEHQC4qleONMBCq3+wJwbZSr0vbuRba -D1xr4wUPn4Avn4AnAwEIB4h+BBgWCgAmFiEEmvYEF+gnQskUPgPsUNx6jcJMVmcF -AmYoOtECGwwFCQPCZwAACgkQUNx6jcJMVmchMgEA6l3RveCM0YHAGQaSFMkguoAo -vK6FgOkDawgP0NPIP2oA/jIAO4gsAntuQgMOsPunEdDeji2t+AhV02+DQIsXZpoB -=f8yY ------END PGP PUBLIC KEY BLOCK----- -EOF -run_command "gpg --batch --import amazonq-public-key.asc" -run_command "gpg --verify q.zip.sig q.zip" -run_command "unzip -q q.zip" -run_command "chmod +x ./q/install.sh" -run_command "./q/install.sh --no-confirm" -run_command "rm -rf ./q q.zip q.zip.sig amazonq-public-key.asc" +echo -e "\n🤖 Installing Kiro CLI..." +# https://kiro.dev/docs/cli/ +KIRO_INSTALLER_URL="https://cli.kiro.dev/install" +KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb" +KIRO_INSTALLER_PATH="$(mktemp)" + +cleanup_kiro_installer() { + rm -f "$KIRO_INSTALLER_PATH" +} +trap cleanup_kiro_installer EXIT + +run_command "curl -fsSL \"$KIRO_INSTALLER_URL\" -o \"$KIRO_INSTALLER_PATH\"" +run_command "echo \"$KIRO_INSTALLER_SHA256 $KIRO_INSTALLER_PATH\" | sha256sum -c -" + +run_command "bash \"$KIRO_INSTALLER_PATH\"" + +kiro_binary="" +if command -v kiro-cli >/dev/null 2>&1; then + kiro_binary="kiro-cli" +elif command -v kiro >/dev/null 2>&1; then + kiro_binary="kiro" +else + echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' or 'kiro' in PATH.\033[0m" >&2 + exit 1 +fi + +run_command "$kiro_binary --help > /dev/null" echo "✅ Done" echo -e "\n🤖 Installing CodeBuddy CLI..." diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index a72dacda52..a6ac6c4bff 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: value: | Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. - **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Amazon Q Developer CLI, Amp, SHAI, IBM Bob, Antigravity + **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, IBM Bob, Antigravity - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index cbe1955ad4..dd09f8e02a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -75,7 +75,7 @@ body: - Roo Code - CodeBuddy - Qoder CLI - - Amazon Q Developer CLI + - Kiro CLI - Amp - SHAI - IBM Bob diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 71786ddcef..3b5889288b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -69,7 +69,7 @@ body: - Roo Code - CodeBuddy - Qoder CLI - - Amazon Q Developer CLI + - Kiro CLI - Amp - SHAI - IBM Bob diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 29f5024f8c..0418ce2b08 100644 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -46,8 +46,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-q-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-q-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \ .genreleases/spec-kit-template-agy-sh-"$VERSION".zip \ .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index ed04d9cd3f..0b33b0d19d 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qodercli, shai, agy, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, agy, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -335,9 +335,9 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".agents/commands" Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } - 'q' { - $cmdDir = Join-Path $baseDir ".amazonq/prompts" - Generate-Commands -Agent 'q' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + 'kiro-cli' { + $cmdDir = Join-Path $baseDir ".kiro/prompts" + Generate-Commands -Agent 'kiro-cli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } 'bob' { $cmdDir = Join-Path $baseDir ".bob/commands" @@ -347,10 +347,21 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".qoder/commands" Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'shai' { + $cmdDir = Join-Path $baseDir ".shai/commands" + Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } + 'agy' { + $cmdDir = Join-Path $baseDir ".agent/workflows" + Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } 'generic' { $cmdDir = Join-Path $baseDir ".speckit/commands" Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + default { + throw "Unsupported agent '$Agent'." + } } # Create zip archive @@ -360,7 +371,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qodercli', 'shai', 'agy', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'agy', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { @@ -425,4 +436,4 @@ foreach ($agent in $AgentList) { Write-Host "`nArchives in ${GenReleasesDir}:" Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object { Write-Host " $($_.Name)" -} \ No newline at end of file +} diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 1b2ced3ea3..08ff1de212 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -212,9 +212,9 @@ build_variant() { shai) mkdir -p "$base_dir/.shai/commands" generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;; - q) - mkdir -p "$base_dir/.amazonq/prompts" - generate_commands q md "\$ARGUMENTS" "$base_dir/.amazonq/prompts" "$script" ;; + kiro-cli) + mkdir -p "$base_dir/.kiro/prompts" + generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; agy) mkdir -p "$base_dir/.agent/workflows" generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;; @@ -230,7 +230,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qodercli generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic) ALL_SCRIPTS=(sh ps) norm_list() { @@ -277,4 +277,3 @@ done echo "Archives in $GENRELEASES_DIR:" ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip - diff --git a/AGENTS.md b/AGENTS.md index d8dc0f08f7..4cafa7defb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | | **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | | **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | -| **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI | +| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | @@ -86,7 +86,7 @@ This eliminates the need for special-case mappings throughout the codebase. - `folder`: Directory where agent-specific files are stored (relative to project root) - `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, q), `"command"` (opencode - singular) + - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) - This field enables `--ai-skills` to locate command templates correctly for skill generation - `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `requires_cli`: Whether the agent requires a CLI tool check during initialization @@ -96,7 +96,7 @@ This eliminates the need for special-case mappings throughout the codebase. Update the `--ai` parameter help text in the `init()` command to include the new agent: ```python -ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or q"), +ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or kiro-cli"), ``` Also update any function docstrings, examples, and error messages that list available agents. @@ -117,7 +117,7 @@ Modify `.github/workflows/scripts/create-release-packages.sh`: ##### Add to ALL_AGENTS array ```bash -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf q) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli) ``` ##### Add case statement for directory structure @@ -317,7 +317,7 @@ Require a command-line tool to be installed: - **Cursor**: `cursor-agent` CLI - **Qwen Code**: `qwen` CLI - **opencode**: `opencode` CLI -- **Amazon Q Developer CLI**: `q` CLI +- **Kiro CLI**: `kiro-cli` CLI - **CodeBuddy CLI**: `codebuddy` CLI - **Qoder CLI**: `qodercli` CLI - **Amp**: `amp` CLI @@ -335,7 +335,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Amazon Q Developer, Amp, SHAI, IBM Bob +Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob **Standard format:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 761f4aede7..812698b71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed Copilot file extension from `.md` to `.agent.md` in `CommandRegistrar.AGENT_CONFIGS` so Copilot recognizes agent files - Added generation of companion `.prompt.md` files in `.github/prompts/` during extension command registration, matching the release packaging behavior - Added cleanup of `.prompt.md` companion files when removing extensions via `specify extension remove` - +- Fixed a syntax regression in `src/specify_cli/__init__.py` in `_build_ai_assistant_help()` that broke `ruff` and `pytest` collection in CI. ## [0.1.12] - 2026-03-02 ### Changed diff --git a/README.md b/README.md index 85a9a8f1e9..5316c3a2a4 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c | Agent | Support | Notes | | ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | [Qoder CLI](https://qoder.com/cli) | ✅ | | -| [Amazon Q Developer CLI](https://aws.amazon.com/developer/learning/q-developer-cli/) | ⚠️ | Amazon Q Developer CLI [does not support](https://github.com/aws/amazon-q-developer-cli/issues/3064) custom arguments for slash commands. | +| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | | [Amp](https://ampcode.com/) | ✅ | | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | | [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | @@ -173,14 +173,14 @@ The `specify` command supports the following options: | Command | Description | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qodercli`) | +| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | @@ -210,6 +210,9 @@ specify init my-project --ai qodercli # Initialize with Windsurf support specify init my-project --ai windsurf +# Initialize with Kiro CLI support +specify init my-project --ai kiro-cli + # Initialize with Amp support specify init my-project --ai amp @@ -393,7 +396,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, or Amazon Q Developer CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, or Kiro CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/pyproject.toml b/pyproject.toml index 0a84bc8cf9..3ae57119de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,4 +51,3 @@ precision = 2 show_missing = true skip_covered = false - diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index e775707c1c..fdebac65f7 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, or Antigravity # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli # Leave empty to update all existing agent files set -e @@ -73,7 +73,7 @@ CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" AMP_FILE="$REPO_ROOT/AGENTS.md" SHAI_FILE="$REPO_ROOT/SHAI.md" -Q_FILE="$REPO_ROOT/AGENTS.md" +KIRO_FILE="$REPO_ROOT/AGENTS.md" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$REPO_ROOT/AGENTS.md" @@ -648,8 +648,8 @@ update_specific_agent() { shai) update_agent_file "$SHAI_FILE" "SHAI" ;; - q) - update_agent_file "$Q_FILE" "Amazon Q Developer CLI" + kiro-cli) + update_agent_file "$KIRO_FILE" "Kiro CLI" ;; agy) update_agent_file "$AGY_FILE" "Antigravity" @@ -662,7 +662,7 @@ update_specific_agent() { ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic" exit 1 ;; esac @@ -737,8 +737,8 @@ update_all_existing_agents() { found_agent=true fi - if [[ -f "$Q_FILE" ]]; then - update_agent_file "$Q_FILE" "Amazon Q Developer CLI" + if [[ -f "$KIRO_FILE" ]]; then + update_agent_file "$KIRO_FILE" "Kiro CLI" found_agent=true fi @@ -775,7 +775,7 @@ print_summary() { echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli]" } #============================================================================== @@ -827,4 +827,3 @@ main() { if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi - diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index ad8016a725..02ce102057 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qodercli) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qodercli','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','generic')] [string]$AgentType ) @@ -58,7 +58,7 @@ $CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' $QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' $AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md' +$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' @@ -399,11 +399,11 @@ function Update-SpecificAgent { 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } + 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic'; return $false } } } @@ -423,7 +423,7 @@ function Update-AllExistingAgents { if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } - if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true } + if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true } if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } if (-not $found) { @@ -440,7 +440,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic]' } function Main { @@ -461,4 +461,3 @@ function Main { } Main - diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5651ac7226..ad84210135 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -216,11 +216,11 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": None, # IDE-based "requires_cli": False, }, - "q": { - "name": "Amazon Q Developer CLI", - "folder": ".amazonq/", + "kiro-cli": { + "name": "Kiro CLI", + "folder": ".kiro/", "commands_subdir": "prompts", # Special: uses prompts/ not commands/ - "install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/", + "install_url": "https://kiro.dev/docs/cli/", "requires_cli": True, }, "amp": { @@ -260,6 +260,34 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) }, } +AI_ASSISTANT_ALIASES = { + "kiro": "kiro-cli", +} + +def _build_ai_assistant_help() -> str: + """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" + + non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic") + base_help = ( + f"AI assistant to use: {', '.join(non_generic_agents)}, " + "or generic (requires --ai-commands-dir)." + ) + + if not AI_ASSISTANT_ALIASES: + return base_help + + alias_phrases = [] + for alias, target in sorted(AI_ASSISTANT_ALIASES.items()): + alias_phrases.append(f"'{alias}' as an alias for '{target}'") + + if len(alias_phrases) == 1: + aliases_text = alias_phrases[0] + else: + aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] + + return base_help + " Use " + aliases_text + "." +AI_ASSISTANT_HELP = _build_ai_assistant_help() + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -534,7 +562,12 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: tracker.complete(tool, "available") return True - found = shutil.which(tool) is not None + if tool == "kiro-cli": + # Kiro currently supports both executable names. Prefer kiro-cli and + # accept kiro as a compatibility fallback. + found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None + else: + found = shutil.which(tool) is not None if tracker: if found: @@ -1214,7 +1247,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qodercli, or generic (requires --ai-commands-dir)"), + ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), @@ -1270,6 +1303,9 @@ def init( console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") raise typer.Exit(1) + if ai_assistant: + ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -1464,8 +1500,9 @@ def init( if skills_ok and not here: agent_cfg = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_cfg.get("folder", "") + commands_subdir = agent_cfg.get("commands_subdir", "commands") if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / "commands" + cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir if cmds_dir.exists(): try: shutil.rmtree(cmds_dir) @@ -2350,4 +2387,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 77da1aca04..4d5bd8083f 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -659,8 +659,8 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, - "q": { - "dir": ".amazonq/prompts", + "kiro-cli": { + "dir": ".kiro/prompts", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" @@ -1812,4 +1812,3 @@ def disable_hooks(self, extension_id: str): self.save_project_config(config) - diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py new file mode 100644 index 0000000000..cad112cf4d --- /dev/null +++ b/tests/test_agent_config_consistency.py @@ -0,0 +1,99 @@ +"""Consistency checks for agent configuration across runtime and packaging scripts.""" + +import re +from pathlib import Path + +from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP +from specify_cli.extensions import CommandRegistrar + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestAgentConfigConsistency: + """Ensure kiro-cli migration stays synchronized across key surfaces.""" + + def test_runtime_config_uses_kiro_cli_and_removes_q(self): + """AGENT_CONFIG should include kiro-cli and exclude legacy q.""" + assert "kiro-cli" in AGENT_CONFIG + assert AGENT_CONFIG["kiro-cli"]["folder"] == ".kiro/" + assert AGENT_CONFIG["kiro-cli"]["commands_subdir"] == "prompts" + assert "q" not in AGENT_CONFIG + + def test_extension_registrar_uses_kiro_cli_and_removes_q(self): + """Extension command registrar should target .kiro/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "kiro-cli" in cfg + assert cfg["kiro-cli"]["dir"] == ".kiro/prompts" + assert "q" not in cfg + + def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): + """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "kiro-cli" in sh_agents + assert "kiro-cli" in ps_agents + assert "shai" in sh_agents + assert "shai" in ps_agents + assert "agy" in sh_agents + assert "agy" in ps_agents + assert "q" not in sh_agents + assert "q" not in ps_agents + + def test_release_ps_switch_has_shai_and_agy_generation(self): + """PowerShell release builder must generate files for shai and agy agents.""" + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None + assert re.search(r"'agy'\s*\{.*?\.agent/workflows", ps_text, re.S) is not None + + def test_init_ai_help_includes_roo_and_kiro_alias(self): + """CLI help text for --ai should stay in sync with agent config and alias guidance.""" + assert "roo" in AI_ASSISTANT_HELP + for alias, target in AI_ASSISTANT_ALIASES.items(): + assert alias in AI_ASSISTANT_HELP + assert target in AI_ASSISTANT_HELP + + def test_devcontainer_kiro_installer_uses_pinned_checksum(self): + """Devcontainer installer should always verify Kiro installer via pinned SHA256.""" + post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8") + + assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text + assert "sha256sum -c -" in post_create_text + assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text + + def test_release_output_targets_kiro_prompt_dir(self): + """Packaging and release scripts should no longer emit amazonq artifacts.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert ".kiro/prompts" in sh_text + assert ".kiro/prompts" in ps_text + assert ".amazonq/prompts" not in sh_text + assert ".amazonq/prompts" not in ps_text + + assert "spec-kit-template-kiro-cli-sh-" in gh_release_text + assert "spec-kit-template-kiro-cli-ps-" in gh_release_text + assert "spec-kit-template-q-sh-" not in gh_release_text + assert "spec-kit-template-q-ps-" not in gh_release_text + + def test_agent_context_scripts_use_kiro_cli(self): + """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" + bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") + pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + assert "kiro-cli" in bash_text + assert "kiro-cli" in pwsh_text + assert "Amazon Q Developer CLI" not in bash_text + assert "Amazon Q Developer CLI" not in pwsh_text diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 3eec4a419c..59f13bd5ee 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -162,6 +162,11 @@ def test_cursor_agent_skills_dir(self, project_dir): result = _get_skills_dir(project_dir, "cursor-agent") assert result == project_dir / ".cursor" / "skills" + def test_kiro_cli_skills_dir(self, project_dir): + """Kiro CLI should use .kiro/skills/.""" + result = _get_skills_dir(project_dir, "kiro-cli") + assert result == project_dir / ".kiro" / "skills" + def test_unknown_agent_uses_default(self, project_dir): """Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" result = _get_skills_dir(project_dir, "nonexistent-agent") @@ -460,8 +465,9 @@ def _fake_extract(self, agent, project_path, **_kwargs): """Simulate template extraction: create agent commands dir.""" agent_cfg = AGENT_CONFIG.get(agent, {}) agent_folder = agent_cfg.get("folder", "") + commands_subdir = agent_cfg.get("commands_subdir", "commands") if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / "commands" + cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir cmds_dir.mkdir(parents=True, exist_ok=True) (cmds_dir / "speckit.specify.md").write_text("# spec") @@ -483,6 +489,7 @@ def fake_download(project_path, *args, **kwargs): patch("specify_cli.shutil.which", return_value="/usr/bin/git"): result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + assert result.exit_code == 0 # Skills should have been called mock_skills.assert_called_once() @@ -490,6 +497,30 @@ def fake_download(project_path, *args, **kwargs): cmds_dir = target / ".claude" / "commands" assert not cmds_dir.exists() + def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path): + """For non-standard agents, configured commands_subdir should be removed on success.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "new-kiro-proj" + + def fake_download(project_path, *args, **kwargs): + self._fake_extract("kiro-cli", project_path) + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke(app, ["init", str(target), "--ai", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"]) + + assert result.exit_code == 0 + mock_skills.assert_called_once() + + prompts_dir = target / ".kiro" / "prompts" + assert not prompts_dir.exists() + def test_commands_preserved_when_skills_fail(self, tmp_path): """If skills fail, commands should NOT be removed (safety net).""" from typer.testing import CliRunner @@ -508,6 +539,7 @@ def fake_download(project_path, *args, **kwargs): patch("specify_cli.shutil.which", return_value="/usr/bin/git"): result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + assert result.exit_code == 0 # Commands should still exist since skills failed cmds_dir = target / ".claude" / "commands" assert cmds_dir.exists() @@ -538,8 +570,9 @@ def fake_download(project_path, *args, **kwargs): patch("specify_cli.install_ai_skills", return_value=True), \ patch("specify_cli.is_git_repo", return_value=True), \ patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) + result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], input="y\n") + assert result.exit_code == 0 # Commands must remain for --here assert cmds_dir.exists() assert (cmds_dir / "speckit.specify.md").exists() @@ -631,6 +664,42 @@ def test_ai_skills_flag_appears_in_help(self): assert "--ai-skills" in plain assert "agent skills" in plain.lower() + def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): + """--ai kiro should normalize to canonical kiro-cli agent key.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "kiro-alias-proj" + + with patch("specify_cli.download_and_extract_template") as mock_download, \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke( + app, + [ + "init", + str(target), + "--ai", + "kiro", + "--ignore-agent-tools", + "--script", + "sh", + "--no-git", + ], + ) + + assert result.exit_code == 0 + assert mock_download.called + # download_and_extract_template(project_path, ai_assistant, script_type, ...) + assert mock_download.call_args.args[1] == "kiro-cli" + + def test_q_removed_from_agent_config(self): + """Amazon Q legacy key should not remain in AGENT_CONFIG.""" + assert "q" not in AGENT_CONFIG + assert "kiro-cli" in AGENT_CONFIG + class TestParameterOrderingIssue: """Test fix for GitHub issue #1641: parameter ordering issues.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c45dcd00c7..062250b633 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -399,6 +399,12 @@ def test_config_backup_on_remove(self, extension_dir, project_dir): class TestCommandRegistrar: """Test CommandRegistrar command registration.""" + def test_kiro_cli_agent_config_present(self): + """Kiro CLI should be mapped to .kiro/prompts and legacy q removed.""" + assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" + assert "q" not in CommandRegistrar.AGENT_CONFIGS + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- From 524affca798b2e9cdc49c77d5c4dd1aecaa03b4c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:08:40 -0600 Subject: [PATCH 044/321] chore: bump version to 0.1.13 (#1746) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 812698b71e..7debdfc608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.13] - 2026-03-03 + +### Changed + +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) +- chore: bump version to 0.1.12 (#1737) +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [0.1.13] - 2026-03-03 ### Fixed From c84756b7f32ceec967fbd9b00df8f157870e12a9 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Wed, 4 Mar 2026 09:08:04 -0500 Subject: [PATCH 045/321] make c ignores consistent with c++ (#1747) --- templates/commands/implement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/commands/implement.md b/templates/commands/implement.md index a565f50e74..a888b36d46 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -88,7 +88,7 @@ You **MUST** consider the user input before proceeding (if not empty). - **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*` - **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*` - **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*` - - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` + - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*` - **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/` - **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/` - **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/` From d0a112c60f837f17a0d9153a84b582f90e36f534 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:16:31 -0600 Subject: [PATCH 046/321] fix: wire after_tasks and after_implement hook events into command templates (#1702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: wire after_tasks and after_implement hook events into command templates (#1701) The HookExecutor backend in extensions.py was fully implemented but check_hooks_for_event() was never called by anything — the core command templates had no instructions to check .specify/extensions.yml. Add a final step to templates/commands/tasks.md (step 6, after_tasks) and templates/commands/implement.md (step 10, after_implement) that instructs the AI agent to: - Read .specify/extensions.yml if it exists - Filter hooks.{event} to enabled: true entries - Evaluate any condition fields and skip non-matching hooks - Output the RFC-specified hook message format, including EXECUTE_COMMAND: markers for mandatory (optional: false) hooks Bumps version to 0.1.7. * fix: clarify hook condition handling and add YAML error guidance in templates - Replace ambiguous "evaluate any condition value" instruction with explicit guidance to skip hooks with non-empty conditions, deferring evaluation to HookExecutor - Add instruction to skip hook checking silently if extensions.yml cannot be parsed or is invalid * Fix/extension hooks not triggered (#1) * feat(templates): implement before-hooks check as pre-execution phase * test(hooks): create scenario for LLMs/Agents on hooks --------- Co-authored-by: Dhilip --- templates/commands/implement.md | 63 +++++++++++++++++++++++++++++ templates/commands/tasks.md | 63 +++++++++++++++++++++++++++++ tests/hooks/.specify/extensions.yml | 34 ++++++++++++++++ tests/hooks/TESTING.md | 30 ++++++++++++++ tests/hooks/plan.md | 3 ++ tests/hooks/spec.md | 1 + tests/hooks/tasks.md | 1 + 7 files changed, 195 insertions(+) create mode 100644 tests/hooks/.specify/extensions.yml create mode 100644 tests/hooks/TESTING.md create mode 100644 tests/hooks/plan.md create mode 100644 tests/hooks/spec.md create mode 100644 tests/hooks/tasks.md diff --git a/templates/commands/implement.md b/templates/commands/implement.md index a888b36d46..da58027d06 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -13,6 +13,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before implementation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_implement` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter to only hooks where `enabled: true` +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -136,3 +170,32 @@ You **MUST** consider the user input before proceeding (if not empty). - Report final status with summary of completed work Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list. + +10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_implement` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter to only hooks where `enabled: true` + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 7320b6f305..9ad199634d 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -22,6 +22,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before tasks generation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_tasks` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter to only hooks where `enabled: true` +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -63,6 +97,35 @@ You **MUST** consider the user input before proceeding (if not empty). - Suggested MVP scope (typically just User Story 1) - Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths) +6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_tasks` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter to only hooks where `enabled: true` + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + Context for task generation: {ARGS} The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context. diff --git a/tests/hooks/.specify/extensions.yml b/tests/hooks/.specify/extensions.yml new file mode 100644 index 0000000000..0a73b3254d --- /dev/null +++ b/tests/hooks/.specify/extensions.yml @@ -0,0 +1,34 @@ +hooks: + before_implement: + - id: pre_test + enabled: true + optional: false + extension: "test-extension" + command: "pre_implement_test" + description: "Test before implement hook execution" + + after_implement: + - id: post_test + enabled: true + optional: true + extension: "test-extension" + command: "post_implement_test" + description: "Test after implement hook execution" + prompt: "Would you like to run the post-implement test?" + + before_tasks: + - id: pre_tasks_test + enabled: true + optional: false + extension: "test-extension" + command: "pre_tasks_test" + description: "Test before tasks hook execution" + + after_tasks: + - id: post_tasks_test + enabled: true + optional: true + extension: "test-extension" + command: "post_tasks_test" + description: "Test after tasks hook execution" + prompt: "Would you like to run the post-tasks test?" diff --git a/tests/hooks/TESTING.md b/tests/hooks/TESTING.md new file mode 100644 index 0000000000..6ab704442f --- /dev/null +++ b/tests/hooks/TESTING.md @@ -0,0 +1,30 @@ +# Testing Extension Hooks + +This directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`. + +## Test 1: Testing `before_tasks` and `after_tasks` + +1. Open a chat with an LLM (like GitHub Copilot) in this project. +2. Ask it to generate tasks for the current directory: + > "Please follow `/speckit.tasks` for the `./tests/hooks` directory." +3. **Expected Behavior**: + - Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`. + - It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`. + - It should then proceed to read the `.md` docs and produce a `tasks.md`. + - After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it. + +## Test 2: Testing `before_implement` and `after_implement` + +*(Requires `tasks.md` from Test 1 to exist)* + +1. In the same (or new) chat, ask the LLM to implement the tasks: + > "Please follow `/speckit.implement` for the `./tests/hooks` directory." +2. **Expected Behavior**: + - The LLM should first check for `before_implement` hooks. + - It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution. + - It should evaluate the checklists and execute the code writing tasks. + - Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block. + +## How it works + +The templates for these commands in `templates/commands/tasks.md` and `templates/commands/implement.md` contains strict ordered lists. The new `before_*` hooks are explicitly formulated in a **Pre-Execution Checks** section prior to the outline to ensure they're evaluated first without breaking template step numbers. diff --git a/tests/hooks/plan.md b/tests/hooks/plan.md new file mode 100644 index 0000000000..e2694887d1 --- /dev/null +++ b/tests/hooks/plan.md @@ -0,0 +1,3 @@ +# Test Setup for Hooks + +This feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code. diff --git a/tests/hooks/spec.md b/tests/hooks/spec.md new file mode 100644 index 0000000000..0285468a67 --- /dev/null +++ b/tests/hooks/spec.md @@ -0,0 +1 @@ +- **User Story 1:** I want a test script that prints "Hello hooks!". diff --git a/tests/hooks/tasks.md b/tests/hooks/tasks.md new file mode 100644 index 0000000000..3c22b0b2ac --- /dev/null +++ b/tests/hooks/tasks.md @@ -0,0 +1 @@ +- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py From 13dec1de05de2cb331b49cb5749163ac7f7f4b0e Mon Sep 17 00:00:00 2001 From: layla <111667698+04cb@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:29:52 +0800 Subject: [PATCH 047/321] Fix docs: update Antigravity link and add initialization example (#1748) * Fix docs: update Antigravity link and add initialization example * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5316c3a2a4..58f3bf5eef 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c | [Roo Code](https://roocode.com/) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | -| [Antigravity (agy)](https://agy.ai/) | ✅ | | +| [Antigravity (agy)](https://antigravity.google/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | ## 🔧 Specify CLI Reference @@ -222,6 +222,9 @@ specify init my-project --ai shai # Initialize with IBM Bob support specify init my-project --ai bob +# Initialize with Antigravity support +specify init my-project --ai agy + # Initialize with an unsupported agent (generic / bring your own agent) specify init my-project --ai generic --ai-commands-dir .myagent/commands/ From 8c3982d65bce1ef576bf51dbaba3ffa38b8ea540 Mon Sep 17 00:00:00 2001 From: Pragya Chaurasia <87864723+pragya247@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:41:51 +0530 Subject: [PATCH 048/321] Add Azure DevOps Integration extension to community catalog (#1734) * Add Azure DevOps work item synchronization with handoffs system * Resolving the comments * Added details of Azure DevOps extention * t status t revert --abort Revert "Add Azure DevOps work item synchronization with handoffs system" This reverts commit 39ac7e48d6f3bfb6d26536a0c7d524e9091a10cb. * Update extensions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: pragya247 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index 4c41d52dea..af03c2985c 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -72,6 +72,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | URL | |-----------|---------|-----| +| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3c8dd0c205..2f89ce243a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3,6 +3,39 @@ "updated_at": "2026-03-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { + "azure-devops": { + "name": "Azure DevOps Integration", + "id": "azure-devops", + "description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication.", + "author": "pragya247", + "version": "1.0.0", + "download_url": "https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/pragya247/spec-kit-azure-devops", + "homepage": "https://github.com/pragya247/spec-kit-azure-devops", + "documentation": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/README.md", + "changelog": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "az", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": ["azure", "devops", "project-management", "work-items", "issue-tracking"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-03-03T00:00:00Z" + }, "cleanup": { "name": "Cleanup Extension", "id": "cleanup", From ad74334a85720d0735253bb0a5a34c8f36c4aced Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Thu, 5 Mar 2026 13:21:38 +0000 Subject: [PATCH 049/321] feat(extensions): add Jira Integration to community catalog (#1764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions): add Jira Integration to community catalog Adds the spec-kit-jira extension to the community catalog. ## Extension Details - **Name**: Jira Integration - **Version**: 2.1.0 - **Repository**: https://github.com/mbachorik/spec-kit-jira ## Features - Create Jira Epics, Stories, and Issues from spec-kit artifacts - 3-level hierarchy (Epic → Stories → Tasks) or 2-level mode - Configurable custom field support - Status synchronization between local tasks and Jira ## Commands - `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks - `/speckit.jira.discover-fields` - Discover Jira custom fields - `/speckit.jira.sync-status` - Sync task completion to Jira --- This PR was prepared with the assistance of Claude (Anthropic). Co-Authored-By: Claude Opus 4.5 * fix: address PR review comments - Set created_at to catalog submission date (2026-03-05) - Add Jira Integration to Available Community Extensions table in README Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- extensions/README.md | 1 + extensions/catalog.community.json | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index af03c2985c..346f4b570f 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -74,6 +74,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|-----| | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 2f89ce243a..71a3913c6a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-03-05T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "azure-devops": { @@ -62,6 +62,32 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support.", + "author": "mbachorik", + "version": "2.1.0", + "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/mbachorik/spec-kit-jira", + "homepage": "https://github.com/mbachorik/spec-kit-jira", + "documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/README.md", + "changelog": "https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": ["issue-tracking", "jira", "atlassian", "project-management"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-05T00:00:00Z", + "updated_at": "2026-03-05T00:00:00Z" + }, "retrospective": { "name": "Retrospective Extension", "id": "retrospective", From 71e6b4da4a59073c04750f4ea86decaaa3b663c0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:55:29 -0600 Subject: [PATCH 050/321] Add Community Walkthroughs section to README (#1766) * Initial plan * Add Community Walkthroughs section to README.md Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 58f3bf5eef..8091268d83 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development) - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) +- [🚶 Community Walkthroughs](#-community-walkthroughs) - [🤖 Supported AI Agents](#-supported-ai-agents) - [🔧 Specify CLI Reference](#-specify-cli-reference) - [📚 Core Philosophy](#-core-philosophy) @@ -139,6 +140,16 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c [![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv) +## 🚶 Community Walkthroughs + +See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: + +- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. + +- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. + +- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From 8618d0a53e570cd432e7e938ba575336fa282c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Silva?= Date: Mon, 9 Mar 2026 09:37:46 -0300 Subject: [PATCH 051/321] fix: use global branch numbering instead of per-short-name detection (#1757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove per-short-name number detection from specify prompt The specify.md prompt instructed the AI to search for existing branches filtered by the exact short-name, causing every new feature to start at 001 since no branches matched the new short-name. The underlying create-new-feature.sh script already has correct global numbering logic via check_existing_branches() that searches ALL branches and spec directories. The fix removes the AI's flawed number-detection steps and tells it to NOT pass --number, letting the script auto-detect the next globally available number. Closes #1744 Closes #1468 🤖 Generated with [Claude Code](https://claude.com/code) Co-Authored-By: Claude Opus 4.6 * fix: clarify --json flag requirement per Copilot review - Rephrased step 2 to mention both --short-name and --json flags - Added explicit note to always include the JSON flag for reliable output parsing Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- templates/commands/specify.md | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 5fd4489eee..95b1e6b0e3 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -39,33 +39,14 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Check for existing branches before creating new one**: +2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories): - a. First, fetch all remote branches to ensure we have the latest information: - - ```bash - git fetch --all --prune - ``` - - b. Find the highest feature number across all sources for the short-name: - - Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-$'` - - Local branches: `git branch | grep -E '^[* ]*[0-9]+-$'` - - Specs directories: Check for directories matching `specs/[0-9]+-` - - c. Determine the next available number: - - Extract all numbers from all three sources - - Find the highest number N - - Use N+1 for the new branch number - - d. Run the script `{SCRIPT}` with the calculated number and short-name: - - Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description - - Bash example: `{SCRIPT} --json --number 5 --short-name "user-auth" "Add user authentication"` - - PowerShell example: `{SCRIPT} -Json -Number 5 -ShortName "user-auth" "Add user authentication"` + - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` + - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` **IMPORTANT**: - - Check all three sources (remote branches, local branches, specs directories) to find the highest number - - Only match branches/directories with the exact short-name pattern - - If no existing branches/directories found with this short-name, start with number 1 + - Do NOT pass `--number` — the script determines the correct next number automatically + - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - The JSON output will contain BRANCH_NAME and SPEC_FILE paths From 3ef12cae3e79c3fa094c23dca78287f4a817279d Mon Sep 17 00:00:00 2001 From: Ryo Hasegawa <49798519+ryo8000@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:38:10 +0900 Subject: [PATCH 052/321] fix: Remove duplicate options in specify.md (#1765) --- templates/commands/specify.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 95b1e6b0e3..d66f3fcca0 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -9,8 +9,8 @@ handoffs: prompt: Clarify specification requirements send: true scripts: - sh: scripts/bash/create-new-feature.sh --json "{ARGS}" - ps: scripts/powershell/create-new-feature.ps1 -Json "{ARGS}" + sh: scripts/bash/create-new-feature.sh "{ARGS}" + ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" --- ## User Input From 9d6c05ad5baa4addb0fa29b2acee49bc89b7abf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl?= Date: Mon, 9 Mar 2026 14:05:13 +0100 Subject: [PATCH 053/321] Integration of Mistral vibe support into speckit (#1725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Mistral Vibe support to Spec Kit This commit adds comprehensive support for Mistral Vibe as an AI agent in the Spec Kit project. The integration includes: - Added Mistral Vibe to AGENT_CONFIG with proper CLI tool configuration - Updated README.md with Mistral Vibe in supported agents table and examples - Modified release package scripts to generate Mistral Vibe templates - Updated both bash and PowerShell agent context update scripts - Added appropriate CLI help text and documentation Mistral Vibe is now fully supported with the same level of integration as other CLI-based agents like Claude Code, Gemini CLI, etc. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe * Add Mistral Vibe support to Spec Kit - Added Mistral Vibe (vibe) to AGENT_CONFIG with proper TOML format support - Updated CLI help text to include vibe as a valid AI assistant option - Added Mistral Vibe to release scripts with correct .vibe/agents/ directory structure - Updated agent context scripts (bash and PowerShell) with proper TOML file paths - Added Mistral Vibe to README.md supported agents table with v2.0 slash command notes - Used correct argument syntax {{args}} for Mistral Vibe TOML configurations Mistral Vibe is now fully integrated with the same level of support as other CLI-based agents like Gemini and Qwen. Users can now use specify init --ai vibe to create projects with Mistral Vibe support. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe * Add Vibe templates to GitHub release script creation of Mistral vibe zip * Add 'vibe' agent to release package script * Add 'vibe' to the list of agents in create-release-packages.sh * chore: bump version to v1.0.1 [skip ci] * Add generic spec kit templates to release script * chore: bump version to v1.0.2 [skip ci] * Update project version to 0.1.5 * Add generic spec kit templates to release script * Add 'generic' and 'qodercli' to agent list to be aligned * Update supported agents in update-agent-context.sh to be aligned * Update README with new AI assistant options to be aligned * Document --ai-commands-dir option in README to be aligned Added new option for AI commands directory in README. * Fix formatting in README.md for init arguments to be aligned * Update README with AI assistant options to be aligned Added AI options to specify init arguments in README. * Fix formatting in README.md for project-name argument * Update expected agent types in update-agent-context.sh to be aligned * Update agent types and usage in update-agent-context.ps1 to be aligned * Add support for generic AI assistant configuration to be aligned * Fix formatting in __init__.py clean space * Update AI assistant options in init command to be aligned * Add 'qodercli' to expected agent types to be aligned * Remove 'vibe' case from release package script Removed the 'vibe' case from the create-release-packages script. * Update README.md ok for this Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/scripts/create-release-packages.ps1 ok to commit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add commands_subdir key to Mistral Vibe configuration * Rename specify-agents.toml to specify-agents.md * Update scripts/bash/update-agent-context.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/scripts/create-release-packages.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix duplicate 'commands_subdir' in vibe configuration Removed duplicate 'commands_subdir' entries for 'vibe'. * Add support for 'vibe' command in release script add an mkdir and generate command * Change commands_subdir from 'commands' to 'prompts' * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update update-agent-context.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update create-release-packages.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update create-release-packages.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update agent list in update-agent-context.sh Kiro --------- Co-authored-by: Lénaïc Huard Co-authored-by: Mistral Vibe Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../workflows/scripts/create-github-release.sh | 2 ++ .../scripts/create-release-packages.ps1 | 6 +++++- .../workflows/scripts/create-release-packages.sh | 5 ++++- README.md | 8 ++++++-- scripts/bash/update-agent-context.sh | 16 ++++++++++++---- scripts/powershell/update-agent-context.ps1 | 11 +++++++---- src/specify_cli/__init__.py | 8 ++++++++ 7 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 0418ce2b08..ba074a3b13 100644 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -52,6 +52,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 0b33b0d19d..fc6e103308 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -359,6 +359,10 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".speckit/commands" Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'vibe' { + $cmdDir = Join-Path $baseDir ".vibe/prompts" + Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } default { throw "Unsupported agent '$Agent'." } @@ -371,7 +375,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'agy', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'agy', 'vibe', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 08ff1de212..3cda56c050 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -221,6 +221,9 @@ build_variant() { bob) mkdir -p "$base_dir/.bob/commands" generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; + vibe) + mkdir -p "$base_dir/.vibe/prompts" + generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; generic) mkdir -p "$base_dir/.speckit/commands" generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; @@ -230,7 +233,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob vibe qodercli generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/README.md b/README.md index 8091268d83..67f4bf475d 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | | [Roo Code](https://roocode.com/) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://antigravity.google/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | @@ -184,14 +185,14 @@ The `specify` command supports the following options: | Command | Description | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`) | +| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | @@ -230,6 +231,9 @@ specify init my-project --ai amp # Initialize with SHAI support specify init my-project --ai shai +# Initialize with Mistral Vibe support +specify init my-project --ai vibe + # Initialize with IBM Bob support specify init my-project --ai bob diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index fdebac65f7..d254baf03a 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,7 +30,7 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, or Antigravity +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, Mistral Vibe or Antigravity # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # @@ -76,6 +76,7 @@ SHAI_FILE="$REPO_ROOT/SHAI.md" KIRO_FILE="$REPO_ROOT/AGENTS.md" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$REPO_ROOT/AGENTS.md" +VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" # Template file TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" @@ -657,12 +658,15 @@ update_specific_agent() { bob) update_agent_file "$BOB_FILE" "IBM Bob" ;; + vibe) + update_agent_file "$VIBE_FILE" "Mistral Vibe" + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|vibe|qodercli|generic" exit 1 ;; esac @@ -750,6 +754,11 @@ update_all_existing_agents() { update_agent_file "$BOB_FILE" "IBM Bob" found_agent=true fi + + if [[ -f "$VIBE_FILE" ]]; then + update_agent_file "$VIBE_FILE" "Mistral Vibe" + found_agent=true + fi # If no agent files exist, create a default Claude file if [[ "$found_agent" == false ]]; then @@ -774,8 +783,7 @@ print_summary() { fi echo - - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 02ce102057..fee7ba6c1f 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli, vibe) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','vibe','generic')] [string]$AgentType ) @@ -61,6 +61,7 @@ $SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' $KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' +$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -402,8 +403,9 @@ function Update-SpecificAgent { 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } + 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe|generic'; return $false } } } @@ -426,6 +428,7 @@ function Update-AllExistingAgents { if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true } if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } + if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } if (-not $found) { Write-Info 'No existing agent files found, creating default Claude file...' if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } @@ -440,7 +443,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ad84210135..0add6d7234 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -251,6 +251,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": None, # IDE-based "requires_cli": False, }, + "vibe": { + "name": "Mistral Vibe", + "folder": ".vibe/", + "commands_subdir": "prompts", + "install_url": "https://github.com/mistralai/mistral-vibe", + "requires_cli": True, + }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir @@ -1280,6 +1287,7 @@ def init( specify init --here --ai claude # Alternative syntax for current directory specify init --here --ai codex specify init --here --ai codebuddy + specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty specify init my-project --ai claude --ai-skills # Install agent skills From a8ec87e3c2f6374204d99ee30bcf35b3c301af5f Mon Sep 17 00:00:00 2001 From: Sharath Satish <2109335+sharathsatish@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:43:36 +0530 Subject: [PATCH 054/321] Add fleet extension to community catalog (#1771) - Extension ID: fleet - Version: 1.0.0 - Author: sharathsatish - Description: Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases --- extensions/README.md | 1 + extensions/catalog.community.json | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index 346f4b570f..2ac4769eb1 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -74,6 +74,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|-----| | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 71a3913c6a..48365f7556 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-05T00:00:00Z", + "updated_at": "2026-03-06T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "azure-devops": { @@ -62,6 +62,32 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "fleet": { + "name": "Fleet Orchestrator", + "id": "fleet", + "description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.", + "author": "sharathsatish", + "version": "1.0.0", + "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/sharathsatish/spec-kit-fleet", + "homepage": "https://github.com/sharathsatish/spec-kit-fleet", + "documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md", + "changelog": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-06T00:00:00Z", + "updated_at": "2026-03-06T00:00:00Z" + }, "jira": { "name": "Jira Integration", "id": "jira", From 855ac838b805176bcd7a56e1b25fb5931c553d05 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:10:59 +0100 Subject: [PATCH 055/321] feat: add review extension to community catalog (#1775) Add spec-kit-review to catalog.community.json and the Available Community Extensions table in extensions/README.md. Co-authored-by: Ismael --- extensions/README.md | 1 + extensions/catalog.community.json | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index 2ac4769eb1..48e6aa8a12 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -77,6 +77,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | +| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 48365f7556..a67528b0c7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -140,6 +140,32 @@ "created_at": "2026-02-24T00:00:00Z", "updated_at": "2026-02-24T00:00:00Z" }, + "review": { + "name": "Review Extension", + "id": "review", + "description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.", + "author": "ismaelJimenez", + "version": "1.0.0", + "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/ismaelJimenez/spec-kit-review", + "homepage": "https://github.com/ismaelJimenez/spec-kit-review", + "documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md", + "changelog": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 7, + "hooks": 1 + }, + "tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-06T00:00:00Z", + "updated_at": "2026-03-06T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", From 2d72f857903ff6740fcd3898c458eba53e5aaf31 Mon Sep 17 00:00:00 2001 From: Lautaro Lubatti <12738908+lubatti@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:56:49 +0100 Subject: [PATCH 056/321] Update README with project initialization instructions (#1772) * Update README with project initialization instructions Added instructions for creating a new project and initializing in an existing project. * Update README.md with alternative one-time usage command for existing projects Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Added --ai option to prevent interactive AI selection Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 67f4bf475d..c974c0541b 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,13 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki Run directly without installing: ```bash +# Create new project uvx --from git+https://github.com/github/spec-kit.git specify init + +# Or initialize in existing project +uvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude +# or +uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude ``` **Benefits of persistent installation:** From 4b00078907a0910de7562f3eba20f27ae7361e4d Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Mon, 9 Mar 2026 11:01:34 -0400 Subject: [PATCH 057/321] Add ralph extension to community catalog (#1780) - Extension ID: ralph - Version: 1.0.0 - Author: Rubiss - Description: Autonomous implementation loop using AI agent CLI --- extensions/README.md | 1 + extensions/catalog.community.json | 38 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index 48e6aa8a12..4e506347bf 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,6 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | +| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index a67528b0c7..ecd7cba85e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-06T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "azure-devops": { @@ -114,6 +114,42 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "ralph": { + "name": "Ralph Loop", + "id": "ralph", + "description": "Autonomous implementation loop using AI agent CLI.", + "author": "Rubiss", + "version": "1.0.0", + "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Rubiss/spec-kit-ralph", + "homepage": "https://github.com/Rubiss/spec-kit-ralph", + "documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md", + "changelog": "https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "copilot", + "required": true + }, + { + "name": "git", + "required": true + } + ] + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": ["implementation", "automation", "loop", "copilot"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-09T00:00:00Z" + }, "retrospective": { "name": "Retrospective Extension", "id": "retrospective", From 3033834d64e358d028eed60bf6f661b6f0d3f374 Mon Sep 17 00:00:00 2001 From: LADISLAV BIHARI <51442396+Testimonial@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:18:41 +0100 Subject: [PATCH 058/321] Add Understanding extension to community catalog (#1778) * Add Understanding extension to community catalog 31 deterministic requirements quality metrics based on IEEE/ISO standards. Catches ambiguity, missing testability, and structural issues before implementation. Includes experimental energy-based ambiguity detection. Repository: https://github.com/Testimonial/understanding Commands: scan, validate, energy Hook: after_tasks validation prompt Co-Authored-By: Claude Opus 4.6 * Sort README table and catalog entries alphabetically Move Understanding extension entry between Spec Sync and V-Model to maintain alphabetical ordering in both files. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Ladislav Bihari Co-authored-by: Claude Opus 4.6 --- extensions/README.md | 1 + extensions/catalog.community.json | 96 ++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index 4e506347bf..e8f1617e98 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -80,6 +80,7 @@ The following community-contributed extensions are available in [`catalog.commun | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ecd7cba85e..759bd10d81 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -29,7 +29,13 @@ "commands": 1, "hooks": 1 }, - "tags": ["azure", "devops", "project-management", "work-items", "issue-tracking"], + "tags": [ + "azure", + "devops", + "project-management", + "work-items", + "issue-tracking" + ], "verified": false, "downloads": 0, "stars": 0, @@ -55,7 +61,13 @@ "commands": 1, "hooks": 1 }, - "tags": ["quality", "tech-debt", "review", "cleanup", "scout-rule"], + "tags": [ + "quality", + "tech-debt", + "review", + "cleanup", + "scout-rule" + ], "verified": false, "downloads": 0, "stars": 0, @@ -107,7 +119,12 @@ "commands": 3, "hooks": 1 }, - "tags": ["issue-tracking", "jira", "atlassian", "project-management"], + "tags": [ + "issue-tracking", + "jira", + "atlassian", + "project-management" + ], "verified": false, "downloads": 0, "stars": 0, @@ -169,7 +186,13 @@ "commands": 1, "hooks": 1 }, - "tags": ["retrospective", "spec-drift", "quality", "analysis", "governance"], + "tags": [ + "retrospective", + "spec-drift", + "quality", + "analysis", + "governance" + ], "verified": false, "downloads": 0, "stars": 0, @@ -221,13 +244,60 @@ "commands": 5, "hooks": 1 }, - "tags": ["sync", "drift", "validation", "bidirectional", "backfill"], + "tags": [ + "sync", + "drift", + "validation", + "bidirectional", + "backfill" + ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "understanding": { + "name": "Understanding", + "id": "understanding", + "description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", + "author": "Ladislav Bihari", + "version": "3.4.0", + "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip", + "repository": "https://github.com/Testimonial/understanding", + "homepage": "https://github.com/Testimonial/understanding", + "documentation": "https://github.com/Testimonial/understanding/blob/main/extension/README.md", + "changelog": "https://github.com/Testimonial/understanding/blob/main/extension/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "understanding", + "version": ">=3.4.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "quality", + "metrics", + "requirements", + "validation", + "readability", + "IEEE-830", + "ISO-29148" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-07T00:00:00Z", + "updated_at": "2026-03-07T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", @@ -247,7 +317,13 @@ "commands": 9, "hooks": 1 }, - "tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"], + "tags": [ + "v-model", + "traceability", + "testing", + "compliance", + "safety-critical" + ], "verified": false, "downloads": 0, "stars": 0, @@ -273,7 +349,13 @@ "commands": 1, "hooks": 1 }, - "tags": ["verification", "quality-gate", "implementation", "spec-adherence", "compliance"], + "tags": [ + "verification", + "quality-gate", + "implementation", + "spec-adherence", + "compliance" + ], "verified": false, "downloads": 0, "stars": 0, From 1df24f1953576aa15503c0eaf48d860327991026 Mon Sep 17 00:00:00 2001 From: Pavel-tabnine Date: Mon, 9 Mar 2026 21:04:02 +0200 Subject: [PATCH 059/321] Pavel/add tabnine cli support (#1503) * feat: add Tabnine CLI agent support Tabnine CLI is a Gemini fork that uses TOML commands with the .tabnine/agent/ directory structure and TABNINE.md context files. Changes: - Add 'tabnine' to AGENT_CONFIG in __init__.py - Update release scripts (bash + PowerShell) for TOML command generation - Update agent context scripts (bash + PowerShell) - Add to GitHub release packages - Update README.md and AGENTS.md documentation - Bump version to 0.1.14 - Add 8 new tests for cross-file consistency * fix: add missing generic to agent-context script usage string --- .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 10 ++- .../scripts/create-release-packages.sh | 10 ++- AGENTS.md | 4 +- CHANGELOG.md | 6 ++ README.md | 3 +- pyproject.toml | 2 +- scripts/bash/update-agent-context.sh | 17 +++-- scripts/powershell/update-agent-context.ps1 | 11 +-- src/specify_cli/__init__.py | 9 ++- src/specify_cli/extensions.py | 6 ++ tests/test_agent_config_consistency.py | 67 +++++++++++++++++++ tests/test_ai_skills.py | 5 ++ 13 files changed, 135 insertions(+), 17 deletions(-) diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index ba074a3b13..29851a1409 100644 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -46,6 +46,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \ .genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \ .genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \ .genreleases/spec-kit-template-agy-sh-"$VERSION".zip \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index fc6e103308..894ab38e7c 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, agy, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -351,6 +351,12 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".shai/commands" Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'tabnine' { + $cmdDir = Join-Path $baseDir ".tabnine/agent/commands" + Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script + $tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md' + if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') } + } 'agy' { $cmdDir = Join-Path $baseDir ".agent/workflows" Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script @@ -375,7 +381,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'agy', 'vibe', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 3cda56c050..af9880b613 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob qodercli generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -155,7 +155,7 @@ build_variant() { # NOTE: We substitute {ARGS} internally. Outward tokens differ intentionally: # * Markdown/prompt (claude, copilot, cursor-agent, opencode): $ARGUMENTS - # * TOML (gemini, qwen): {{args}} + # * TOML (gemini, qwen, tabnine): {{args}} # This keeps formats readable without extra abstraction. case $agent in @@ -212,6 +212,10 @@ build_variant() { shai) mkdir -p "$base_dir/.shai/commands" generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;; + tabnine) + mkdir -p "$base_dir/.tabnine/agent/commands" + generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script" + [[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;; kiro-cli) mkdir -p "$base_dir/.kiro/prompts" generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; @@ -233,7 +237,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai kiro-cli agy bob vibe qodercli generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index 4cafa7defb..8e7631b1d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | +| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | @@ -322,6 +323,7 @@ Require a command-line tool to be installed: - **Qoder CLI**: `qodercli` CLI - **Amp**: `amp` CLI - **SHAI**: `shai` CLI +- **Tabnine CLI**: `tabnine` CLI ### IDE-Based Agents @@ -360,7 +362,7 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Qwen +Used by: Gemini, Qwen, Tabnine ```toml description = "Command description" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7debdfc608..08452ee608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.14] - 2026-03-09 + +### Added + +- feat: add Tabnine CLI agent support + ## [0.1.13] - 2026-03-03 ### Changed diff --git a/README.md b/README.md index c974c0541b..ba84c9d4e3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | | [Roo Code](https://roocode.com/) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://antigravity.google/) | ✅ | | @@ -420,7 +421,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, or Kiro CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, or Kiro CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/pyproject.toml b/pyproject.toml index 3ae57119de..12dc0e46f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.13" +version = "0.1.14" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index d254baf03a..b64c6c8b0a 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, Mistral Vibe or Antigravity +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe or Antigravity # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic # Leave empty to update all existing agent files set -e @@ -73,6 +73,7 @@ CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" AMP_FILE="$REPO_ROOT/AGENTS.md" SHAI_FILE="$REPO_ROOT/SHAI.md" +TABNINE_FILE="$REPO_ROOT/TABNINE.md" KIRO_FILE="$REPO_ROOT/AGENTS.md" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$REPO_ROOT/AGENTS.md" @@ -649,6 +650,9 @@ update_specific_agent() { shai) update_agent_file "$SHAI_FILE" "SHAI" ;; + tabnine) + update_agent_file "$TABNINE_FILE" "Tabnine CLI" + ;; kiro-cli) update_agent_file "$KIRO_FILE" "Kiro CLI" ;; @@ -666,7 +670,7 @@ update_specific_agent() { ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|vibe|qodercli|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic" exit 1 ;; esac @@ -736,6 +740,11 @@ update_all_existing_agents() { found_agent=true fi + if [[ -f "$TABNINE_FILE" ]]; then + update_agent_file "$TABNINE_FILE" "Tabnine CLI" + found_agent=true + fi + if [[ -f "$QODER_FILE" ]]; then update_agent_file "$QODER_FILE" "Qoder CLI" found_agent=true @@ -783,7 +792,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index fee7ba6c1f..29ccac32b7 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli, vibe) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','vibe','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','generic')] [string]$AgentType ) @@ -58,6 +58,7 @@ $CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' $QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' $AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' +$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' $KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' @@ -400,12 +401,13 @@ function Update-SpecificAgent { 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } + 'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic'; return $false } } } @@ -425,6 +427,7 @@ function Update-AllExistingAgents { if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } + if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true } if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true } if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } @@ -443,7 +446,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|vibe|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0add6d7234..e76e240d14 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -237,6 +237,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://github.com/ovh/shai", "requires_cli": True, }, + "tabnine": { + "name": "Tabnine CLI", + "folder": ".tabnine/agent/", + "commands_subdir": "commands", + "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", + "requires_cli": True, + }, "agy": { "name": "Antigravity", "folder": ".agent/", @@ -1124,7 +1131,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker if not templates_dir.exists() or not any(templates_dir.glob("*.md")): # Fallback: try the repo-relative path (for running from source checkout) # This also covers agents whose extracted commands are in a different - # format (e.g. gemini uses .toml, not .md). + # format (e.g. gemini/tabnine use .toml, not .md). script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ fallback_dir = script_dir / "templates" / "commands" if fallback_dir.exists() and any(fallback_dir.glob("*.md")): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 4d5bd8083f..ad6b051403 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -677,6 +677,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "tabnine": { + "dir": ".tabnine/agent/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml" + }, "bob": { "dir": ".bob/commands", "format": "markdown", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index cad112cf4d..607d491d93 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -97,3 +97,70 @@ def test_agent_context_scripts_use_kiro_cli(self): assert "kiro-cli" in pwsh_text assert "Amazon Q Developer CLI" not in bash_text assert "Amazon Q Developer CLI" not in pwsh_text + + # --- Tabnine CLI consistency checks --- + + def test_runtime_config_includes_tabnine(self): + """AGENT_CONFIG should include tabnine with correct folder and subdir.""" + assert "tabnine" in AGENT_CONFIG + assert AGENT_CONFIG["tabnine"]["folder"] == ".tabnine/agent/" + assert AGENT_CONFIG["tabnine"]["commands_subdir"] == "commands" + assert AGENT_CONFIG["tabnine"]["requires_cli"] is True + assert AGENT_CONFIG["tabnine"]["install_url"] is not None + + def test_extension_registrar_includes_tabnine(self): + """CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config.""" + from specify_cli.extensions import CommandRegistrar + + assert "tabnine" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["tabnine"] + assert cfg["dir"] == ".tabnine/agent/commands" + assert cfg["format"] == "toml" + assert cfg["args"] == "{{args}}" + assert cfg["extension"] == ".toml" + + def test_release_agent_lists_include_tabnine(self): + """Bash and PowerShell release scripts should include tabnine in agent lists.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "tabnine" in sh_agents + assert "tabnine" in ps_agents + + def test_release_scripts_generate_tabnine_toml_commands(self): + """Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert ".tabnine/agent/commands" in sh_text + assert ".tabnine/agent/commands" in ps_text + assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None + + def test_github_release_includes_tabnine_packages(self): + """GitHub release script should include tabnine template packages.""" + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert "spec-kit-template-tabnine-sh-" in gh_release_text + assert "spec-kit-template-tabnine-ps-" in gh_release_text + + def test_agent_context_scripts_include_tabnine(self): + """Agent context scripts should support tabnine agent type.""" + bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") + pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + assert "tabnine" in bash_text + assert "TABNINE_FILE" in bash_text + assert "tabnine" in pwsh_text + assert "TABNINE_FILE" in pwsh_text + + def test_ai_help_includes_tabnine(self): + """CLI help text for --ai should include tabnine.""" + assert "tabnine" in AI_ASSISTANT_HELP diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 59f13bd5ee..a040b4bd01 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -147,6 +147,11 @@ def test_gemini_skills_dir(self, project_dir): result = _get_skills_dir(project_dir, "gemini") assert result == project_dir / ".gemini" / "skills" + def test_tabnine_skills_dir(self, project_dir): + """Tabnine should use .tabnine/agent/skills/.""" + result = _get_skills_dir(project_dir, "tabnine") + assert result == project_dir / ".tabnine" / "agent" / "skills" + def test_copilot_skills_dir(self, project_dir): """Copilot should use .github/skills/.""" result = _get_skills_dir(project_dir, "copilot") From ee922cbde96e0f5aae490a5923a4f5c14f1761c5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:30:27 -0500 Subject: [PATCH 060/321] feat(extensions): support multiple active catalogs simultaneously (#1720) * Initial plan * feat(extensions): implement multi-catalog stack support - Add CatalogEntry dataclass to represent catalog entries - Add get_active_catalogs() reading SPECKIT_CATALOG_URL, project config, user config, or built-in default stack (org-approved + community) - Add _load_catalog_config() to parse .specify/extension-catalogs.yml - Add _validate_catalog_url() HTTPS validation helper - Add _fetch_single_catalog() with per-URL caching, backward-compat for DEFAULT_CATALOG_URL - Add _get_merged_extensions() that merges all catalogs (priority wins on conflict) - Update search() and get_extension_info() to use merged results annotated with _catalog_name and _install_allowed - Update clear_cache() to also remove per-URL hash cache files - Add extension_catalogs CLI command to list active catalogs - Add catalog add/remove sub-commands for .specify/extension-catalogs.yml - Update extension_add to enforce install_allowed=false policy - Update extension_search to show source catalog per result - Update extension_info to show source catalog with install_allowed status - Add 13 new tests covering catalog stack, merge conflict resolution, install_allowed enforcement, and catalog metadata Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs: update RFC, user guide, and API reference for multi-catalog support - RFC: replace FUTURE FEATURE section with full implementation docs, add catalog stack resolution order, config file examples, merge conflict resolution, and install_allowed behavior - EXTENSION-USER-GUIDE.md: add multi-catalog section with CLI examples for catalogs/catalog-add/catalog-remove, update catalog config docs - EXTENSION-API-REFERENCE.md: add CatalogEntry class docs, update ExtensionCatalog docs with new methods and result annotations, add catalog CLI commands (catalogs, catalog add, catalog remove) Also fix extension_catalogs command to correctly show "Using built-in default catalog stack" when config file exists but has empty catalogs Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: remove extraneous f-string prefixes (ruff F541) Remove f-prefix from strings with no placeholders in catalog_remove and extension_search commands. Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: address PR review feedback for multi-catalog support - Rename 'org-approved' catalog to 'default' - Move 'catalogs' command to 'catalog list' for consistency - Add 'description' field to CatalogEntry dataclass - Add --description option to 'catalog add' CLI command - Align install_allowed default to False in _load_catalog_config - Add user-level config detection in catalog list footer - Fix _load_catalog_config docstring (document ValidationError) - Fix test isolation for test_search_by_tag, test_search_by_query, test_search_verified_only, test_get_extension_info - Update version to 0.1.14 and CHANGELOG - Update all docs (RFC, User Guide, API Reference) * fix: wrap _load_catalog_config() calls in catalog_list with try/except - Check SPECKIT_CATALOG_URL first (matching get_active_catalogs() resolution order) - Wrap both _load_catalog_config() calls in try/except ValidationError so a malformed config file cannot crash `specify extension catalog list` after the active catalogs have already been printed successfully Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 + extensions/EXTENSION-API-REFERENCE.md | 118 ++++++- extensions/EXTENSION-USER-GUIDE.md | 105 +++++- extensions/RFC-EXTENSION-SYSTEM.md | 106 ++++-- src/specify_cli/__init__.py | 232 ++++++++++++- src/specify_cli/extensions.py | 329 +++++++++++++++--- tests/test_extensions.py | 465 ++++++++++++++++++++++++++ 7 files changed, 1280 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08452ee608..4539e6864b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - feat: add Tabnine CLI agent support +- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack + - New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status + - New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management + - Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box + - `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog + - `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly + - Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence + - `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog) + - All catalog URLs require HTTPS (HTTP allowed for localhost development) + - New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation + - Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog + - Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs) + - 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement + - Updated RFC, Extension User Guide, and Extension API Reference documentation ## [0.1.13] - 2026-03-03 diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 9764ca8315..bd25d4bb49 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -243,6 +243,34 @@ manager.check_compatibility( ) # Raises: CompatibilityError if incompatible ``` +### CatalogEntry + +**Module**: `specify_cli.extensions` + +Represents a single catalog in the active catalog stack. + +```python +from specify_cli.extensions import CatalogEntry + +entry = CatalogEntry( + url="https://example.com/catalog.json", + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable extensions", +) +``` + +**Fields**: + +| Field | Type | Description | +|-------|------|-------------| +| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) | +| `name` | `str` | Human-readable catalog name | +| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) | +| `install_allowed` | `bool` | Whether extensions from this catalog can be installed | +| `description` | `str` | Optional human-readable description of the catalog (default: empty) | + ### ExtensionCatalog **Module**: `specify_cli.extensions` @@ -253,30 +281,67 @@ from specify_cli.extensions import ExtensionCatalog catalog = ExtensionCatalog(project_root) ``` +**Class attributes**: + +```python +ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL +ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL +``` + **Methods**: ```python -# Fetch catalog +# Get the ordered list of active catalogs +entries = catalog.get_active_catalogs() # List[CatalogEntry] + +# Fetch catalog (primary catalog, backward compat) catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict -# Search extensions +# Search extensions across all active catalogs +# Each result includes _catalog_name and _install_allowed results = catalog.search( query: Optional[str] = None, tag: Optional[str] = None, author: Optional[str] = None, verified_only: bool = False -) # Returns: List[Dict] +) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed -# Get extension info +# Get extension info (searches all active catalogs) +# Returns None if not found; includes _catalog_name and _install_allowed ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict] -# Check cache validity +# Check cache validity (primary catalog) is_valid = catalog.is_cache_valid() # bool -# Clear cache +# Clear all catalog caches catalog.clear_cache() ``` +**Result annotation fields**: + +Each extension dict returned by `search()` and `get_extension_info()` includes: + +| Field | Type | Description | +|-------|------|-------------| +| `_catalog_name` | `str` | Name of the source catalog | +| `_install_allowed` | `bool` | Whether installation is allowed from this catalog | + +**Catalog config file** (`.specify/extension-catalogs.yml`): + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 + install_allowed: true + description: "Built-in catalog of installable extensions" + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 2 + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + ### HookExecutor **Module**: `specify_cli.extensions` @@ -543,6 +608,39 @@ EXECUTE_COMMAND: {command} **Output**: List of installed extensions with metadata +### extension catalog list + +**Usage**: `specify extension catalog list` + +Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status. + +### extension catalog add + +**Usage**: `specify extension catalog add URL [OPTIONS]` + +**Options**: + +- `--name NAME` - Catalog name (required) +- `--priority INT` - Priority (lower = higher priority, default: 10) +- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false) +- `--description TEXT` - Optional description of the catalog + +**Arguments**: + +- `URL` - Catalog URL (must use HTTPS) + +Adds a catalog entry to `.specify/extension-catalogs.yml`. + +### extension catalog remove + +**Usage**: `specify extension catalog remove NAME` + +**Arguments**: + +- `NAME` - Catalog name to remove + +Removes a catalog entry from `.specify/extension-catalogs.yml`. + ### extension add **Usage**: `specify extension add EXTENSION [OPTIONS]` @@ -551,13 +649,13 @@ EXECUTE_COMMAND: {command} - `--from URL` - Install from custom URL - `--dev PATH` - Install from local directory -- `--version VERSION` - Install specific version -- `--no-register` - Skip command registration **Arguments**: - `EXTENSION` - Extension name or URL +**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command. + ### extension remove **Usage**: `specify extension remove EXTENSION [OPTIONS]` @@ -575,6 +673,8 @@ EXECUTE_COMMAND: {command} **Usage**: `specify extension search [QUERY] [OPTIONS]` +Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status. + **Options**: - `--tag TAG` - Filter by tag @@ -589,6 +689,8 @@ EXECUTE_COMMAND: {command} **Usage**: `specify extension info EXTENSION` +Shows source catalog and install_allowed status. + **Arguments**: - `EXTENSION` - Extension ID diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index f5b5befaf5..e551809ef1 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -76,7 +76,7 @@ vim .specify/extensions/jira/jira-config.yml ## Finding Extensions -**Note**: By default, `specify extension search` uses your organization's catalog (`catalog.json`). If the catalog is empty, you won't see any results. See [Extension Catalogs](#extension-catalogs) to learn how to populate your catalog from the community reference catalog. +`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status. ### Browse All Extensions @@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml specify extension search ``` -Shows all extensions in your organization's catalog. +Shows all extensions across all active catalogs (default and community by default). ### Search by Keyword @@ -402,13 +402,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), | Variable | Description | Default | |----------|-------------|---------| -| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog | +| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack | | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None | #### Example: Using a custom catalog for testing ```bash -# Point to a local or alternative catalog +# Point to a local or alternative catalog (replaces the full stack) export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" # Or use a staging catalog @@ -419,13 +419,76 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" ## Extension Catalogs -For information about how Spec Kit's dual-catalog system works (`catalog.json` vs `catalog.community.json`), see the main [Extensions README](README.md#extension-catalogs). +Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active: + +| Priority | Catalog | Install Allowed | Purpose | +|----------|---------|-----------------|---------| +| 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation | +| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions | + +### Listing Active Catalogs + +```bash +specify extension catalog list +``` + +### Adding a Catalog (Project-scoped) + +```bash +# Add an internal catalog that allows installs +specify extension catalog add \ + --name "internal" \ + --priority 2 \ + --install-allowed \ + https://internal.company.com/spec-kit/catalog.json + +# Add a discovery-only catalog +specify extension catalog add \ + --name "partner" \ + --priority 5 \ + https://partner.example.com/spec-kit/catalog.json +``` + +This creates or updates `.specify/extension-catalogs.yml`. + +### Removing a Catalog + +```bash +specify extension catalog remove internal +``` + +### Manual Config File + +You can also edit `.specify/extension-catalogs.yml` directly: + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 + install_allowed: true + description: "Built-in catalog of installable extensions" + + - name: "internal" + url: "https://internal.company.com/spec-kit/catalog.json" + priority: 2 + install_allowed: true + description: "Internal company extensions" + + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 3 + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + +A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when it contains one or more catalog entries. An empty `catalogs: []` list falls back to built-in defaults. ## Organization Catalog Customization ### Why Customize Your Catalog -Organizations customize their `catalog.json` to: +Organizations customize their catalogs to: - **Control available extensions** - Curate which extensions your team can install - **Host private extensions** - Internal tools that shouldn't be public @@ -503,24 +566,40 @@ Options for hosting your catalog: #### 3. Configure Your Environment -##### Option A: Environment variable (recommended for CI/CD) +##### Option A: Catalog stack config file (recommended) -```bash -# In ~/.bashrc, ~/.zshrc, or CI pipeline -export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" +Add to `.specify/extension-catalogs.yml` in your project: + +```yaml +catalogs: + - name: "my-org" + url: "https://your-org.com/spec-kit/catalog.json" + priority: 1 + install_allowed: true ``` -##### Option B: Per-project configuration +Or use the CLI: + +```bash +specify extension catalog add \ + --name "my-org" \ + --install-allowed \ + https://your-org.com/spec-kit/catalog.json +``` -Create `.env` or set in your shell before running spec-kit commands: +##### Option B: Environment variable (recommended for CI/CD, single-catalog) ```bash -SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search +# In ~/.bashrc, ~/.zshrc, or CI pipeline +export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" ``` #### 4. Verify Configuration ```bash +# List active catalogs +specify extension catalog list + # Search should now show your catalog's extensions specify extension search diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index 248e6275aa..c6469c48d6 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -868,7 +868,7 @@ Spec Kit uses two catalog files with different purposes: - **Purpose**: Organization's curated catalog of approved extensions - **Default State**: Empty by design - users populate with extensions they trust -- **Usage**: Default catalog used by `specify extension` CLI commands +- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack - **Control**: Organizations maintain their own fork/version for their teams #### Community Reference Catalog (`catalog.community.json`) @@ -879,16 +879,16 @@ Spec Kit uses two catalog files with different purposes: - **Verification**: Community extensions may have `verified: false` initially - **Status**: Active - open for community contributions - **Submission**: Via Pull Request following the Extension Publishing Guide -- **Usage**: Browse to discover extensions, then copy to your `catalog.json` +- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only -**How It Works:** +**How It Works (default stack):** -1. **Discover**: Browse `catalog.community.json` to find available extensions -2. **Review**: Evaluate extensions for security, quality, and organizational fit -3. **Curate**: Copy approved extension entries from community catalog to your `catalog.json` -4. **Install**: Use `specify extension add ` (pulls from your curated catalog) +1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically +2. **Review**: Evaluate community extensions for security, quality, and organizational fit +3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true` +4. **Install**: Use `specify extension add ` — only allowed from `install_allowed: true` catalogs -This approach gives organizations full control over which extensions are available to their teams while maintaining a shared community resource for discovery. +This approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box. ### Catalog Format @@ -961,30 +961,92 @@ specify extension info jira ### Custom Catalogs -**⚠️ FUTURE FEATURE - NOT YET IMPLEMENTED** +Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to maintain their own org-approved extensions alongside an internal catalog and community discovery, all at once. -The following catalog management commands are proposed design concepts but are not yet available in the current implementation: +#### Catalog Stack Resolution + +The active catalog stack is resolved in this order (first match wins): + +1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat) +2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project +3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults +4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false) + +#### Default Built-in Stack + +When no config file exists, the CLI uses: + +| Priority | Catalog | install_allowed | Purpose | +|----------|---------|-----------------|---------| +| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation | +| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install | + +This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to entries from catalogs with `install_allowed: true`. + +#### `.specify/extension-catalogs.yml` Config File + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 # Highest — only approved entries can be installed + install_allowed: true + description: "Built-in catalog of installable extensions" + + - name: "internal" + url: "https://internal.company.com/spec-kit/catalog.json" + priority: 2 + install_allowed: true + description: "Internal company extensions" + + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 3 # Lowest — discovery only, not installable + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + +A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present with one or more catalog entries, it takes full control and the built-in defaults are not applied. An empty `catalogs: []` list is treated the same as no config file, falling back to defaults. + +#### Catalog CLI Commands ```bash -# Add custom catalog (FUTURE - NOT AVAILABLE) -specify extension add-catalog https://internal.company.com/spec-kit/catalog.json +# List active catalogs with name, URL, priority, and install_allowed +specify extension catalog list + +# Add a catalog (project-scoped) +specify extension catalog add --name "internal" --install-allowed \ + https://internal.company.com/spec-kit/catalog.json + +# Add a discovery-only catalog +specify extension catalog add --name "community" \ + https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json -# Set as default (FUTURE - NOT AVAILABLE) -specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json +# Remove a catalog +specify extension catalog remove internal -# List catalogs (FUTURE - NOT AVAILABLE) -specify extension catalogs +# Show which catalog an extension came from +specify extension info jira +# → Source catalog: default ``` -**Proposed catalog priority** (future design): +#### Merge Conflict Resolution + +When the same extension `id` appears in multiple catalogs, the higher-priority (lower priority number) catalog wins. Extensions from lower-priority catalogs with the same `id` are ignored. -1. Project-specific catalog (`.specify/extension-catalogs.yml`) - *not implemented* -2. User-level catalog (`~/.specify/extension-catalogs.yml`) - *not implemented* -3. Default GitHub catalog +#### `install_allowed: false` Behavior + +Extensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly: + +``` +⚠ 'linear' is available in the 'community' catalog but installation is not allowed from that catalog. + +To enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml. +``` -#### Current Implementation: SPECKIT_CATALOG_URL +#### `SPECKIT_CATALOG_URL` (Backward Compatibility) -**The currently available method** for using custom catalogs is the `SPECKIT_CATALOG_URL` environment variable: +The `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility: ```bash # Point to your organization's catalog diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e76e240d14..209632a709 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1772,6 +1772,13 @@ def version(): ) app.add_typer(extension_app, name="extension") +catalog_app = typer.Typer( + name="catalog", + help="Manage extension catalogs", + add_completion=False, +) +extension_app.add_typer(catalog_app, name="catalog") + def get_speckit_version() -> str: """Get current spec-kit version.""" @@ -1837,6 +1844,181 @@ def extension_list( console.print(" [cyan]specify extension add [/cyan]") +@catalog_app.command("list") +def catalog_list(): + """List all active extension catalogs.""" + from .extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "extension-catalogs.yml" + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + if os.environ.get("SPECKIT_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except ValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except ValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" + ) + + +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow extensions from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/extension-catalogs.yml.""" + from .extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify extension catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@catalog_app.command("remove") +def catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/extension-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + console.print(f"[green]✓[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + @extension_app.command("add") def extension_add( extension: str = typer.Argument(help="Extension name or path"), @@ -1925,6 +2107,19 @@ def extension_add( console.print(" specify extension search") raise typer.Exit(1) + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + console.print( + f"[red]Error:[/red] '{extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{extension}' to an approved catalog " + f"(install_allowed: true) in .specify/extension-catalogs.yml." + ) + raise typer.Exit(1) + # Download extension ZIP console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") zip_path = catalog.download_extension(extension) @@ -2069,6 +2264,15 @@ def extension_search( tags_str = ", ".join(ext['tags']) console.print(f" [dim]Tags:[/dim] {tags_str}") + # Source catalog + catalog_name = ext.get("_catalog_name", "") + install_allowed = ext.get("_install_allowed", True) + if catalog_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {catalog_name}") + else: + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") + # Stats stats = [] if ext.get('downloads') is not None: @@ -2082,8 +2286,15 @@ def extension_search( if ext.get('repository'): console.print(f" [dim]Repository:[/dim] {ext['repository']}") - # Install command - console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + # Install command (show warning if not installable) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + else: + console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") + console.print( + f" Add to an approved catalog with install_allowed: true, " + f"or install from a ZIP URL: specify extension add {ext['id']} --from " + ) console.print() except ExtensionError as e: @@ -2132,6 +2343,12 @@ def extension_info( # Author and License console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") console.print() # Requirements @@ -2188,12 +2405,21 @@ def extension_info( # Installation status and command is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) if is_installed: console.print("[green]✓ Installed[/green]") console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - else: + elif install_allowed: console.print("[yellow]Not installed[/yellow]") console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) except ExtensionError as e: console.print(f"\n[red]Error:[/red] {e}") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index ad6b051403..b1045e3cec 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -8,9 +8,11 @@ import json import hashlib +import os import tempfile import zipfile import shutil +from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, List, Any from datetime import datetime, timezone @@ -36,6 +38,16 @@ class CompatibilityError(ExtensionError): pass +@dataclass +class CatalogEntry: + """Represents a single catalog entry in the catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + class ExtensionManifest: """Represents and validates an extension manifest (extension.yml).""" @@ -976,6 +988,7 @@ class ExtensionCatalog: """Manages extension catalog fetching, caching, and searching.""" DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" CACHE_DURATION = 3600 # 1 hour in seconds def __init__(self, project_root: Path): @@ -990,43 +1003,109 @@ def __init__(self, project_root: Path): self.cache_file = self.cache_dir / "catalog.json" self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" - def get_catalog_url(self) -> str: - """Get catalog URL from config or use default. - - Checks in order: - 1. SPECKIT_CATALOG_URL environment variable - 2. Default catalog URL + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). - Returns: - URL to fetch catalog from + Args: + url: URL to validate Raises: - ValidationError: If custom URL is invalid (non-HTTPS) + ValidationError: If URL is invalid or uses non-HTTPS scheme """ - import os - import sys from urllib.parse import urlparse - # Environment variable override (useful for testing) - if env_value := os.environ.get("SPECKIT_CATALOG_URL"): - catalog_url = env_value.strip() - parsed = urlparse(catalog_url) + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise ValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise ValidationError("Catalog URL must be a valid URL with a host.") + + def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to extension-catalogs.yml - # Require HTTPS for security (prevent man-in-the-middle attacks) - # Allow http://localhost for local development/testing - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + Returns: + Ordered list of CatalogEntry objects, or None if file doesn't exist + or contains no valid catalog entries. + + Raises: + ValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, or a priority value is invalid. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text()) or {} + except (yaml.YAMLError, OSError) as e: + raise ValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + return None + if not isinstance(catalogs_data, list): + raise ValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + entries: List[CatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): raise ValidationError( - f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). " - "HTTP is only allowed for localhost." + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" ) - - if not parsed.netloc: + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): raise ValidationError( - "Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host." + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append(CatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + return entries if entries else None + + def get_active_catalogs(self) -> List[CatalogEntry]: + """Get the ordered list of active catalogs. + + Resolution order: + 1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/extension-catalogs.yml + 3. User-level ~/.specify/extension-catalogs.yml + 4. Built-in default stack (default + community) - # Warn users when using a non-default catalog (once per instance) + Returns: + List of CatalogEntry objects sorted by priority (ascending) + + Raises: + ValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat + if env_value := os.environ.get("SPECKIT_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) if catalog_url != self.DEFAULT_CATALOG_URL: if not getattr(self, "_non_default_catalog_warning_shown", False): print( @@ -1035,11 +1114,163 @@ def get_catalog_url(self) -> str: file=sys.stderr, ) self._non_default_catalog_warning_shown = True + return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "extension-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"), + CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + + Raises: + ValidationError: If a catalog URL is invalid + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file / + self.cache_metadata_file) for backward compatibility. For all other URLs, + uses URL-hash-based cache files in self.cache_dir. + + Args: + entry: CatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + ExtensionError: If catalog cannot be fetched or has invalid format + """ + import urllib.request + import urllib.error + + # Determine cache file paths (backward compat for default catalog) + if entry.url == self.DEFAULT_CATALOG_URL: + cache_file = self.cache_file + cache_meta_file = self.cache_metadata_file + is_valid = not force_refresh and self.is_cache_valid() + else: + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta_file = self.cache_dir / f"catalog-{url_hash}-metadata.json" + is_valid = False + if not force_refresh and cache_file.exists() and cache_meta_file.exists(): + try: + metadata = json.loads(cache_meta_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + is_valid = age < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + # If metadata is invalid or missing expected fields, treat cache as invalid + pass + + # Use cache if valid + if is_valid: + try: + return json.loads(cache_file.read_text()) + except json.JSONDecodeError: + pass - return catalog_url + # Fetch from network + try: + with urllib.request.urlopen(entry.url, timeout=10) as response: + catalog_data = json.loads(response.read()) - # TODO: Support custom catalogs from .specify/extension-catalogs.yml - return self.DEFAULT_CATALOG_URL + if "schema_version" not in catalog_data or "extensions" not in catalog_data: + raise ExtensionError(f"Invalid catalog format from {entry.url}") + + # Save to cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2)) + cache_meta_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, indent=2)) + + return catalog_data + + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}") + except json.JSONDecodeError as e: + raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}") + + def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]: + """Fetch and merge extensions from all active catalogs. + + Higher-priority (lower priority number) catalogs win on conflicts + (same extension id in two catalogs). Each extension dict is annotated with: + - _catalog_name: name of the source catalog + - _install_allowed: whether installation is allowed from this catalog + + Catalogs that fail to fetch are skipped. Raises ExtensionError only if + ALL catalogs fail. + + Args: + force_refresh: If True, bypass all caches + + Returns: + List of merged extension dicts + + Raises: + ExtensionError: If all catalogs fail to fetch + """ + import sys + + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for catalog_entry in active_catalogs: + try: + catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh) + any_success = True + except ExtensionError as e: + print( + f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", + file=sys.stderr, + ) + continue + + for ext_id, ext_data in catalog_data.get("extensions", {}).items(): + if ext_id not in merged: # Higher-priority catalog wins + merged[ext_id] = { + **ext_data, + "id": ext_id, + "_catalog_name": catalog_entry.name, + "_install_allowed": catalog_entry.install_allowed, + } + + if not any_success and active_catalogs: + raise ExtensionError("Failed to fetch any extension catalog") + + return list(merged.values()) def is_cache_valid(self) -> bool: """Check if cached catalog is still valid. @@ -1053,9 +1284,11 @@ def is_cache_valid(self) -> bool: try: metadata = json.loads(self.cache_metadata_file.read_text()) cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() return age_seconds < self.CACHE_DURATION - except (json.JSONDecodeError, ValueError, KeyError): + except (json.JSONDecodeError, ValueError, KeyError, TypeError): return False def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: @@ -1116,7 +1349,7 @@ def search( author: Optional[str] = None, verified_only: bool = False, ) -> List[Dict[str, Any]]: - """Search catalog for extensions. + """Search catalog for extensions across all active catalogs. Args: query: Search query (searches name, description, tags) @@ -1125,14 +1358,16 @@ def search( verified_only: If True, show only verified extensions Returns: - List of matching extension metadata + List of matching extension metadata, each annotated with + ``_catalog_name`` and ``_install_allowed`` from its source catalog. """ - catalog = self.fetch_catalog() - extensions = catalog.get("extensions", {}) + all_extensions = self._get_merged_extensions() results = [] - for ext_id, ext_data in extensions.items(): + for ext_data in all_extensions: + ext_id = ext_data["id"] + # Apply filters if verified_only and not ext_data.get("verified", False): continue @@ -1158,25 +1393,26 @@ def search( if query_lower not in searchable_text: continue - results.append({"id": ext_id, **ext_data}) + results.append(ext_data) return results def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: """Get detailed information about a specific extension. + Searches all active catalogs in priority order. + Args: extension_id: ID of the extension Returns: - Extension metadata or None if not found + Extension metadata (annotated with ``_catalog_name`` and + ``_install_allowed``) or None if not found. """ - catalog = self.fetch_catalog() - extensions = catalog.get("extensions", {}) - - if extension_id in extensions: - return {"id": extension_id, **extensions[extension_id]} - + all_extensions = self._get_merged_extensions() + for ext_data in all_extensions: + if ext_data["id"] == extension_id: + return ext_data return None def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path: @@ -1236,11 +1472,18 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non raise ExtensionError(f"Failed to save extension ZIP: {e}") def clear_cache(self): - """Clear the catalog cache.""" + """Clear the catalog cache (both legacy and URL-hash-based files).""" if self.cache_file.exists(): self.cache_file.unlink() if self.cache_metadata_file.exists(): self.cache_metadata_file.unlink() + # Also clear any per-URL hash-based cache files + if self.cache_dir.exists(): + for extra_cache in self.cache_dir.glob("catalog-*.json"): + if extra_cache != self.cache_file: + extra_cache.unlink(missing_ok=True) + for extra_meta in self.cache_dir.glob("catalog-*-metadata.json"): + extra_meta.unlink(missing_ok=True) class ConfigManager: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 062250b633..9ef9cb7390 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6,6 +6,7 @@ - Extension registry operations - Extension manager installation/removal - Command registration +- Catalog stack (multi-catalog support) """ import pytest @@ -16,6 +17,7 @@ from datetime import datetime, timezone from specify_cli.extensions import ( + CatalogEntry, ExtensionManifest, ExtensionRegistry, ExtensionManager, @@ -880,10 +882,29 @@ def test_cache_expiration(self, temp_dir): def test_search_all_extensions(self, temp_dir): """Test searching all extensions without filters.""" + import yaml as yaml_module + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() + # Use a single-catalog config so community extensions don't interfere + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + catalog = ExtensionCatalog(project_dir) # Create mock catalog @@ -929,10 +950,29 @@ def test_search_all_extensions(self, temp_dir): def test_search_by_query(self, temp_dir): """Test searching by query text.""" + import yaml as yaml_module + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() + # Use a single-catalog config so community extensions don't interfere + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + catalog = ExtensionCatalog(project_dir) # Create mock catalog @@ -974,10 +1014,29 @@ def test_search_by_query(self, temp_dir): def test_search_by_tag(self, temp_dir): """Test searching by tag.""" + import yaml as yaml_module + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() + # Use a single-catalog config so community extensions don't interfere + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + catalog = ExtensionCatalog(project_dir) # Create mock catalog @@ -1026,10 +1085,29 @@ def test_search_by_tag(self, temp_dir): def test_search_verified_only(self, temp_dir): """Test searching verified extensions only.""" + import yaml as yaml_module + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() + # Use a single-catalog config so community extensions don't interfere + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + catalog = ExtensionCatalog(project_dir) # Create mock catalog @@ -1071,10 +1149,29 @@ def test_search_verified_only(self, temp_dir): def test_get_extension_info(self, temp_dir): """Test getting specific extension info.""" + import yaml as yaml_module + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() + # Use a single-catalog config so community extensions don't interfere + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + catalog = ExtensionCatalog(project_dir) # Create mock catalog @@ -1133,3 +1230,371 @@ def test_clear_cache(self, temp_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() + + +# ===== CatalogEntry Tests ===== + +class TestCatalogEntry: + """Test CatalogEntry dataclass.""" + + def test_catalog_entry_creation(self): + """Test creating a CatalogEntry.""" + entry = CatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + + +# ===== Catalog Stack Tests ===== + +class TestCatalogStack: + """Test multi-catalog stack support.""" + + def _make_project(self, temp_dir: Path) -> Path: + """Create a minimal spec-kit project directory.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + return project_dir + + def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None: + """Write extension-catalogs.yml to project .specify dir.""" + import yaml as yaml_module + + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump({"catalogs": catalogs}, f) + + def _write_valid_cache( + self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com" + ) -> None: + """Populate the primary cache file with mock extension data.""" + catalog_data = {"schema_version": "1.0", "extensions": extensions} + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": url, + } + ) + ) + + # --- get_active_catalogs --- + + def test_default_stack(self, temp_dir): + """Default stack includes default and community catalogs.""" + project_dir = self._make_project(temp_dir) + catalog = ExtensionCatalog(project_dir) + + entries = catalog.get_active_catalogs() + + assert len(entries) == 2 + assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL + assert entries[0].name == "default" + assert entries[0].priority == 1 + assert entries[0].install_allowed is True + assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL + assert entries[1].name == "community" + assert entries[1].priority == 2 + assert entries[1].install_allowed is False + + def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch): + """SPECKIT_CATALOG_URL replaces the entire default stack.""" + project_dir = self._make_project(temp_dir) + custom_url = "https://example.com/catalog.json" + monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url) + + catalog = ExtensionCatalog(project_dir) + entries = catalog.get_active_catalogs() + + assert len(entries) == 1 + assert entries[0].url == custom_url + assert entries[0].install_allowed is True + + def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch): + """SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError.""" + project_dir = self._make_project(temp_dir) + monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json") + + catalog = ExtensionCatalog(project_dir) + with pytest.raises(ValidationError, match="HTTPS"): + catalog.get_active_catalogs() + + def test_project_config_overrides_defaults(self, temp_dir): + """Project-level extension-catalogs.yml overrides default stack.""" + project_dir = self._make_project(temp_dir) + self._write_catalog_config( + project_dir, + [ + { + "name": "custom", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": True, + } + ], + ) + + catalog = ExtensionCatalog(project_dir) + entries = catalog.get_active_catalogs() + + assert len(entries) == 1 + assert entries[0].url == "https://example.com/catalog.json" + assert entries[0].name == "custom" + + def test_project_config_sorted_by_priority(self, temp_dir): + """Catalog entries are sorted by priority (ascending).""" + project_dir = self._make_project(temp_dir) + self._write_catalog_config( + project_dir, + [ + { + "name": "secondary", + "url": "https://example.com/secondary.json", + "priority": 5, + "install_allowed": False, + }, + { + "name": "primary", + "url": "https://example.com/primary.json", + "priority": 1, + "install_allowed": True, + }, + ], + ) + + catalog = ExtensionCatalog(project_dir) + entries = catalog.get_active_catalogs() + + assert len(entries) == 2 + assert entries[0].name == "primary" + assert entries[1].name == "secondary" + + def test_project_config_invalid_url_raises(self, temp_dir): + """Project config with HTTP (non-localhost) URL raises ValidationError.""" + project_dir = self._make_project(temp_dir) + self._write_catalog_config( + project_dir, + [ + { + "name": "bad", + "url": "http://example.com/catalog.json", + "priority": 1, + "install_allowed": True, + } + ], + ) + + catalog = ExtensionCatalog(project_dir) + with pytest.raises(ValidationError, match="HTTPS"): + catalog.get_active_catalogs() + + def test_empty_project_config_falls_back_to_defaults(self, temp_dir): + """Empty catalogs list in config falls back to default stack.""" + import yaml as yaml_module + + project_dir = self._make_project(temp_dir) + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump({"catalogs": []}, f) + + catalog = ExtensionCatalog(project_dir) + entries = catalog.get_active_catalogs() + + # Falls back to default stack + assert len(entries) == 2 + assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL + + # --- _load_catalog_config --- + + def test_load_catalog_config_missing_file(self, temp_dir): + """Returns None when config file doesn't exist.""" + project_dir = self._make_project(temp_dir) + catalog = ExtensionCatalog(project_dir) + + result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml") + assert result is None + + def test_load_catalog_config_localhost_allowed(self, temp_dir): + """Localhost HTTP URLs are allowed in config.""" + project_dir = self._make_project(temp_dir) + self._write_catalog_config( + project_dir, + [ + { + "name": "local", + "url": "http://localhost:8000/catalog.json", + "priority": 1, + "install_allowed": True, + } + ], + ) + + catalog = ExtensionCatalog(project_dir) + entries = catalog.get_active_catalogs() + + assert len(entries) == 1 + assert entries[0].url == "http://localhost:8000/catalog.json" + + # --- Merge conflict resolution --- + + def test_merge_conflict_higher_priority_wins(self, temp_dir): + """When same extension id is in two catalogs, higher priority wins.""" + project_dir = self._make_project(temp_dir) + + # Write project config with two catalogs + self._write_catalog_config( + project_dir, + [ + { + "name": "primary", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + }, + { + "name": "secondary", + "url": ExtensionCatalog.COMMUNITY_CATALOG_URL, + "priority": 2, + "install_allowed": False, + }, + ], + ) + + catalog = ExtensionCatalog(project_dir) + + # Write primary cache with jira v2.0.0 + primary_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "2.0.0", + "description": "Primary Jira", + } + }, + } + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text(json.dumps(primary_data)) + catalog.cache_metadata_file.write_text( + json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"}) + ) + + # Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose) + import hashlib + + url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16] + secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json" + secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json" + secondary_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira Integration Community", + "id": "jira", + "version": "1.0.0", + "description": "Community Jira", + }, + "linear": { + "name": "Linear", + "id": "linear", + "version": "0.9.0", + "description": "Linear from secondary", + }, + }, + } + secondary_cache.write_text(json.dumps(secondary_data)) + secondary_meta.write_text( + json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL}) + ) + + results = catalog.search() + jira_results = [r for r in results if r["id"] == "jira"] + assert len(jira_results) == 1 + # Primary catalog wins + assert jira_results[0]["version"] == "2.0.0" + assert jira_results[0]["_catalog_name"] == "primary" + assert jira_results[0]["_install_allowed"] is True + + # linear comes from secondary + linear_results = [r for r in results if r["id"] == "linear"] + assert len(linear_results) == 1 + assert linear_results[0]["_catalog_name"] == "secondary" + assert linear_results[0]["_install_allowed"] is False + + def test_install_allowed_false_from_get_extension_info(self, temp_dir): + """get_extension_info includes _install_allowed from source catalog.""" + project_dir = self._make_project(temp_dir) + + # Single catalog that is install_allowed=False + self._write_catalog_config( + project_dir, + [ + { + "name": "discovery", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": False, + } + ], + ) + + catalog = ExtensionCatalog(project_dir) + self._write_valid_cache( + catalog, + { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "1.0.0", + "description": "Jira integration", + } + }, + ) + + info = catalog.get_extension_info("jira") + assert info is not None + assert info["_install_allowed"] is False + assert info["_catalog_name"] == "discovery" + + def test_search_results_include_catalog_metadata(self, temp_dir): + """Search results include _catalog_name and _install_allowed.""" + project_dir = self._make_project(temp_dir) + self._write_catalog_config( + project_dir, + [ + { + "name": "org", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ], + ) + + catalog = ExtensionCatalog(project_dir) + self._write_valid_cache( + catalog, + { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "1.0.0", + "description": "Jira integration", + } + }, + ) + + results = catalog.search() + assert len(results) == 1 + assert results[0]["_catalog_name"] == "org" + assert results[0]["_install_allowed"] is True From d92798d5b053497e90ca36492e17ca7e6f25824e Mon Sep 17 00:00:00 2001 From: Pavel-tabnine Date: Mon, 9 Mar 2026 21:34:54 +0200 Subject: [PATCH 061/321] fix: sync agent list comments with actual supported agents (#1785) Several comment and documentation strings were not updated when Mistral Vibe support was added, leaving them out of sync with the code. This fixes: - update-agent-context.sh: add Generic to Supports header comment - update-agent-context.ps1: add generic to Multi-Agent header comment - README.md: add Mistral Vibe to CLI tool-check text --- README.md | 2 +- scripts/bash/update-agent-context.sh | 2 +- scripts/powershell/update-agent-context.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba84c9d4e3..c3afd18460 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, or Kiro CLI installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b64c6c8b0a..f67e2db990 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,7 +30,7 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe or Antigravity +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 29ccac32b7..816d4d5867 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). From 5c0bedb410891650ffb2d651b151eea7cf90f429 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:54:08 -0500 Subject: [PATCH 062/321] chore: bump version to 0.2.0 (#1786) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4539e6864b..fe0fa5d954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-03-09 + +### Changed + +- fix: sync agent list comments with actual supported agents (#1785) +- feat(extensions): support multiple active catalogs simultaneously (#1720) +- Pavel/add tabnine cli support (#1503) +- Add Understanding extension to community catalog (#1778) +- Add ralph extension to community catalog (#1780) +- Update README with project initialization instructions (#1772) +- feat: add review extension to community catalog (#1775) +- Add fleet extension to community catalog (#1771) +- Integration of Mistral vibe support into speckit (#1725) +- fix: Remove duplicate options in specify.md (#1765) +- fix: use global branch numbering instead of per-short-name detection (#1757) +- Add Community Walkthroughs section to README (#1766) +- feat(extensions): add Jira Integration to community catalog (#1764) +- Add Azure DevOps Integration extension to community catalog (#1734) +- Fix docs: update Antigravity link and add initialization example (#1748) +- fix: wire after_tasks and after_implement hook events into command templates (#1702) +- make c ignores consistent with c++ (#1747) +- chore: bump version to 0.1.13 (#1746) +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) +- chore: bump version to 0.1.12 (#1737) +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [0.1.14] - 2026-03-09 ### Added diff --git a/pyproject.toml b/pyproject.toml index 12dc0e46f5..e78069592a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.14" +version = "0.2.0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 4ab91fbadfa3d75e792341479c3ad98f02276e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Luj=C3=A1n=20Mu=C3=B1oz?= Date: Tue, 10 Mar 2026 15:50:42 +0100 Subject: [PATCH 063/321] feat: add Codex support for extension command registration (#1767) * feat: add Codex support for extension command registration * test: add codex command registrar mapping check * test: add codex consistency check to test_agent_config_consistency Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 6 ++++++ tests/test_agent_config_consistency.py | 7 +++++++ tests/test_extensions.py | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b1045e3cec..64300b5354 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -635,6 +635,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "codex": { + "dir": ".codex/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, "windsurf": { "dir": ".windsurf/workflows", "format": "markdown", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 607d491d93..3615dbc787 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -28,6 +28,13 @@ def test_extension_registrar_uses_kiro_cli_and_removes_q(self): assert cfg["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in cfg + def test_extension_registrar_includes_codex(self): + """Extension command registrar should include codex targeting .codex/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "codex" in cfg + assert cfg["codex"]["dir"] == ".codex/prompts" + def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9ef9cb7390..545363225c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -407,6 +407,11 @@ def test_kiro_cli_agent_config_present(self): assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in CommandRegistrar.AGENT_CONFIGS + def test_codex_agent_config_present(self): + """Codex should be mapped to .codex/prompts.""" + assert "codex" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- From 2632a0f52d1b68c493774ad4a3f6da46c9572747 Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Tue, 10 Mar 2026 13:02:04 -0400 Subject: [PATCH 064/321] feat(extensions): support .extensionignore to exclude files during install (#1781) * feat(extensions): support .extensionignore to exclude files during install Add .extensionignore support so extension authors can exclude files and folders from being copied when users run 'specify extension add'. The file uses glob-style patterns (one per line), supports comments (#), blank lines, trailing-slash directory patterns, and relative path matching. The .extensionignore file itself is always excluded from the copy. - Add _load_extensionignore() to ExtensionManager - Integrate ignore function into shutil.copytree in install_from_directory - Document .extensionignore in EXTENSION-DEVELOPMENT-GUIDE.md - Add 6 tests covering all pattern matching scenarios - Bump version to 0.1.14 * fix(extensions): use pathspec for gitignore-compatible .extensionignore matching Replace fnmatch with pathspec.GitIgnoreSpec to get proper .gitignore semantics where * does not cross directory boundaries. This addresses review feedback on #1781. Changes: - Switch from fnmatch to pathspec>=0.12.0 (GitIgnoreSpec.from_lines) - Normalize backslashes in patterns for cross-platform compatibility - Distinguish directories from files for trailing-slash patterns - Update docs to accurately describe supported pattern semantics - Add edge-case tests: .., absolute paths, empty file, backslashes, * vs ** boundary behavior, and ! negation - Move changelog entry to [Unreleased] section --- CHANGELOG.md | 7 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 61 ++++ pyproject.toml | 1 + src/specify_cli/extensions.py | 71 ++++- tests/test_extensions.py | 340 ++++++++++++++++++++++ 5 files changed, 477 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0fa5d954..45b662160b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) + ## [0.2.0] - 2026-03-09 ### Changed @@ -43,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) - ## [0.1.14] - 2026-03-09 ### Added diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index f86beb62bb..feea7b2782 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -332,6 +332,67 @@ echo "$config" --- +## Excluding Files with `.extensionignore` + +Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy. + +### Format + +The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library: + +- Blank lines are ignored +- Lines starting with `#` are comments +- `*` matches anything **except** `/` (does not cross directory boundaries) +- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`) +- `?` matches any single character except `/` +- A trailing `/` restricts a pattern to directories only +- Patterns containing `/` (other than a trailing slash) are anchored to the extension root +- Patterns without `/` match at any depth in the tree +- `!` negates a previously excluded pattern (re-includes a file) +- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility +- The `.extensionignore` file itself is always excluded automatically + +### Example + +```gitignore +# .extensionignore + +# Development files +tests/ +.github/ +.gitignore + +# Build artifacts +__pycache__/ +*.pyc +dist/ + +# Documentation source (keep only the built README) +docs/ +CONTRIBUTING.md +``` + +### Pattern Matching + +| Pattern | Matches | Does NOT match | +|---------|---------|----------------| +| `*.pyc` | Any `.pyc` file in any directory | — | +| `tests/` | The `tests` directory (and all its contents) | A file named `tests` | +| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) | +| `.env` | The `.env` file at any level | — | +| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — | +| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — | + +### Unsupported Features + +The following `.gitignore` features are **not applicable** in this context: + +- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories) +- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here +- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`). + +--- + ## Validation Rules ### Extension ID diff --git a/pyproject.toml b/pyproject.toml index e78069592a..0bb55ceaf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "truststore>=0.10.4", "pyyaml>=6.0", "packaging>=23.0", + "pathspec>=0.12.0", ] [project.scripts] diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 64300b5354..53777bd6b2 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -14,10 +14,12 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, List, Any, Callable, Set from datetime import datetime, timezone import re +import pathspec + import yaml from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier @@ -280,6 +282,70 @@ def __init__(self, project_root: Path): self.extensions_dir = project_root / ".specify" / "extensions" self.registry = ExtensionRegistry(self.extensions_dir) + @staticmethod + def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: + """Load .extensionignore and return an ignore function for shutil.copytree. + + The .extensionignore file uses .gitignore-compatible patterns (one per line). + Lines starting with '#' are comments. Blank lines are ignored. + The .extensionignore file itself is always excluded. + + Pattern semantics mirror .gitignore: + - '*' matches anything except '/' + - '**' matches zero or more directories + - '?' matches any single character except '/' + - Trailing '/' restricts a pattern to directories only + - Patterns with '/' (other than trailing) are anchored to the root + - '!' negates a previously excluded pattern + + Args: + source_dir: Path to the extension source directory + + Returns: + An ignore function compatible with shutil.copytree, or None + if no .extensionignore file exists. + """ + ignore_file = source_dir / ".extensionignore" + if not ignore_file.exists(): + return None + + lines: List[str] = ignore_file.read_text().splitlines() + + # Normalise backslashes in patterns so Windows-authored files work + normalised: List[str] = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + normalised.append(stripped.replace("\\", "/")) + else: + # Preserve blanks/comments so pathspec line numbers stay stable + normalised.append(line) + + # Always ignore the .extensionignore file itself + normalised.append(".extensionignore") + + spec = pathspec.GitIgnoreSpec.from_lines(normalised) + + def _ignore(directory: str, entries: List[str]) -> Set[str]: + ignored: Set[str] = set() + rel_dir = Path(directory).relative_to(source_dir) + for entry in entries: + rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry + # Normalise to forward slashes for consistent matching + rel_path_fwd = rel_path.replace("\\", "/") + + entry_full = Path(directory) / entry + if entry_full.is_dir(): + # Append '/' so directory-only patterns (e.g. tests/) match + if spec.match_file(rel_path_fwd + "/"): + ignored.add(entry) + else: + if spec.match_file(rel_path_fwd): + ignored.add(entry) + return ignored + + return _ignore + def check_compatibility( self, manifest: ExtensionManifest, @@ -353,7 +419,8 @@ def install_from_directory( if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + ignore_fn = self._load_extensionignore(source_dir) + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) # Register commands with AI agents registered_commands = {} diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 545363225c..ba52d03439 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1603,3 +1603,343 @@ def test_search_results_include_catalog_metadata(self, temp_dir): assert len(results) == 1 assert results[0]["_catalog_name"] == "org" assert results[0]["_install_allowed"] is True + + +class TestExtensionIgnore: + """Test .extensionignore support during extension installation.""" + + def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None): + """Helper to create an extension directory with optional extra files and .extensionignore.""" + import yaml + + ext_dir = temp_dir / "ignored-ext" + ext_dir.mkdir() + + # Write manifest + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(valid_manifest_data, f) + + # Create commands directory with a command file + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + (commands_dir / "hello.md").write_text( + "---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n" + ) + + # Create any extra files/dirs + if extra_files: + for rel_path, content in extra_files.items(): + p = ext_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + if content is None: + # Create directory + p.mkdir(parents=True, exist_ok=True) + else: + p.write_text(content) + + # Write .extensionignore + if ignore_content is not None: + (ext_dir / ".extensionignore").write_text(ignore_content) + + return ext_dir + + def test_no_extensionignore(self, temp_dir, valid_manifest_data): + """Without .extensionignore, all files are copied.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"}, + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "tests" / "test_foo.py").exists() + + def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data): + """Files matching .extensionignore patterns are excluded.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "tests/test_foo.py": "pass", + "tests/test_bar.py": "pass", + ".github/workflows/ci.yml": "on: push", + }, + ignore_content="tests/\n.github/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Included + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + # Excluded + assert not (dest / "tests").exists() + assert not (dest / ".github").exists() + + def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data): + """Glob patterns like *.pyc are respected.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "README.md": "# Hello", + "helpers.pyc": b"\x00".decode("latin-1"), + "commands/cache.pyc": b"\x00".decode("latin-1"), + }, + ignore_content="*.pyc\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "helpers.pyc").exists() + assert not (dest / "commands" / "cache.pyc").exists() + + def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data): + """Comments and blank lines in .extensionignore are ignored.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "some notes"}, + ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert not (dest / "notes.txt").exists() + + def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data): + """.extensionignore is never copied to the destination.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + ignore_content="# nothing special here\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "extension.yml").exists() + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data): + """Patterns matching relative paths work correctly.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal/draft.md": "draft", + "README.md": "# Hello", + }, + ignore_content="docs/internal/draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Patterns with '..' should not escape the extension root.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello"}, + ignore_content="../sibling/\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Everything should still be copied — the '..' pattern matches nothing inside + assert (dest / "README.md").exists() + assert (dest / "extension.yml").exists() + assert (dest / "commands" / "hello.md").exists() + + def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data): + """Absolute path patterns should not match anything.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "passwd": "sensitive"}, + ignore_content="/etc/passwd\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir + assert (dest / "README.md").exists() + assert (dest / "passwd").exists() + + def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data): + """An empty .extensionignore should exclude only itself.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={"README.md": "# Hello", "notes.txt": "notes"}, + ignore_content="", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "README.md").exists() + assert (dest / "notes.txt").exists() + assert (dest / "extension.yml").exists() + # .extensionignore itself is still excluded + assert not (dest / ".extensionignore").exists() + + def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data): + """Backslash patterns (Windows-style) are normalised to forward slashes.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/internal/draft.md": "draft", + "docs/guide.md": "# Guide", + }, + ignore_content="docs\\internal\\draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal" / "draft.md").exists() + + def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data): + """'*' should NOT match across directory boundaries (gitignore semantics).""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + }, + ignore_content="docs/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.draft.md should only match directly inside docs/, NOT subdirs + assert not (dest / "docs" / "api.draft.md").exists() + assert (dest / "docs" / "sub" / "api.draft.md").exists() + + def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data): + """'**' should match across directory boundaries.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/api.draft.md": "draft", + "docs/sub/api.draft.md": "nested draft", + "docs/guide.md": "guide", + }, + ignore_content="docs/**/*.draft.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + assert not (dest / "docs" / "api.draft.md").exists() + assert not (dest / "docs" / "sub" / "api.draft.md").exists() + assert (dest / "docs" / "guide.md").exists() + + def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): + """'!' negation re-includes a previously excluded file.""" + ext_dir = self._make_extension( + temp_dir, + valid_manifest_data, + extra_files={ + "docs/guide.md": "# Guide", + "docs/internal.md": "internal", + "docs/api.md": "api", + }, + ignore_content="docs/*.md\n!docs/api.md\n", + ) + + proj_dir = temp_dir / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + + manager = ExtensionManager(proj_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + dest = proj_dir / ".specify" / "extensions" / "test-ext" + # docs/*.md excludes all .md in docs, but !docs/api.md re-includes it + assert not (dest / "docs" / "guide.md").exists() + assert not (dest / "docs" / "internal.md").exists() + assert (dest / "docs" / "api.md").exists() From 56095f06d2b4b6f29b92dc3f4421da59f66a840b Mon Sep 17 00:00:00 2001 From: LifeIsAnAbstraction <87593980+B-SRIKRISHNAN@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:11:27 +0530 Subject: [PATCH 065/321] fix: use quiet checkout to avoid exception on git checkout (#1792) --- scripts/powershell/create-new-feature.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 8f88b6c568..172b5bc7dc 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -250,7 +250,7 @@ if ($branchName.Length -gt $maxBranchLength) { if ($hasGit) { $branchCreated = $false try { - git checkout -b $branchName 2>$null | Out-Null + git checkout -q -b $branchName 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { $branchCreated = $true } From 929fab5d98e726556187fc7dc2a8c55b5be2d04d Mon Sep 17 00:00:00 2001 From: Dhilip Date: Wed, 11 Mar 2026 07:54:07 -0400 Subject: [PATCH 066/321] docs: add catalog cli help documentation (#1793) (#1794) * docs: add catalog cli help documentation (#1793) * Fix code block formatting in user guide Corrected code block syntax for CLI commands in user guide. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/EXTENSION-USER-GUIDE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index e551809ef1..ae77860fe5 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -432,6 +432,26 @@ Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simul specify extension catalog list ``` +### Managing Catalogs via CLI + +You can view the main catalog management commands using `--help`: + +```text +specify extension catalog --help + + Usage: specify extension catalog [OPTIONS] COMMAND [ARGS]... + + Manage extension catalogs +╭─ Options ────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────╮ +│ list List all active extension catalogs. │ +│ add Add a catalog to .specify/extension-catalogs.yml. │ +│ remove Remove a catalog from .specify/extension-catalogs.yml. │ +╰──────────────────────────────────────────────────────────────────────────────────╯ +``` + ### Adding a Catalog (Project-scoped) ```bash From 33e853e9c9fb8f123f2e02278359f5c1ae553271 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Wed, 11 Mar 2026 08:51:04 -0400 Subject: [PATCH 067/321] docs: fix broken links in quickstart guide (#1759) (#1797) --- docs/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 37d431dbad..4b2c3c8807 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -173,6 +173,6 @@ Finally, implement the solution: ## Next Steps -- Read the [complete methodology](../spec-driven.md) for in-depth guidance -- Check out [more examples](../templates) in the repository +- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance +- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository - Explore the [source code on GitHub](https://github.com/github/spec-kit) From e56d37db8ccf4e5bf4fc69933aff08c92863a9e5 Mon Sep 17 00:00:00 2001 From: Fanch Daniel <65220854+Digi-Bo@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:57:18 +0100 Subject: [PATCH 068/321] feat: add Kimi Code CLI agent support (#1790) * feat: add Kimi Code (kimi) CLI agent support - Register kimi in AGENT_CONFIG with folder `.kimi/`, markdown format, requires_cli=True - Register kimi in CommandRegistrar.AGENT_CONFIGS - Add kimi to supported agents table in AGENTS.md and README.md - Add kimi to release packaging scripts (bash and PowerShell) - Add kimi CLI installation to devcontainer post-create script - Add kimi support to update-agent-context scripts (bash and PowerShell) - Add 4 consistency tests covering all kimi integration surfaces - Bump version to 0.1.14 and update CHANGELOG * fix: include .specify/templates/ and real command files in release ZIPs - Copy real command files from templates/commands/ (with speckit. prefix) instead of generating stubs, so slash commands have actual content - Add .specify/templates/ to every ZIP so ensure_constitution_from_template can find constitution-template.md on init - Add .vscode/settings.json to every ZIP - Having 3 top-level dirs prevents the extraction flatten heuristic from incorrectly stripping the agent config folder (.kimi/, .claude/, etc.) - Bump version to 0.1.14.1 Co-Authored-By: Claude Sonnet 4.6 * fix(kimi): use .kimi/skills//SKILL.md structure for Kimi Code CLI Kimi Code CLI uses a skills system, not flat command files: - Skills live in .kimi/skills//SKILL.md (project-level) - Invoked with /skill: (e.g. /skill:speckit.specify) - Each skill is a directory containing SKILL.md with YAML frontmatter Changes: - AGENT_CONFIG["kimi"]["commands_subdir"] = "skills" (was "commands") - create-release-packages.sh: new create_kimi_skills() function creates skill directories with SKILL.md from real template content - Bump version to 0.1.14.2 Co-Authored-By: Claude Sonnet 4.6 * fix(test): align kimi commands_subdir assertion with skills structure * fix: use forward slashes for tabnine path in create-release-packages.ps1 * fix: align kimi to .kimi/skills convention and fix ARGUMENTS unbound variable * fix: address PR review comments for kimi agent support - Fix VERSION_NO_V undefined variable in create-github-release.sh - Restore version $1 argument handling in create-release-packages.sh - Fix tabnine/vibe/generic cases calling undefined generate_commands - Align roo path .roo/rules -> .roo/commands with AGENT_CONFIG - Fix kimi extension to use per-skill SKILL.md directory structure - Add parent mkdir before dest_file.write_text for nested paths - Restore devcontainer tools removed by regression + add Kimi CLI - Strengthen test_kimi_in_powershell_validate_set assertion * fix: restore release scripts and address all PR review comments - Restore create-release-packages.sh to original with full generate_commands/ rewrite_paths logic; add kimi case using create_kimi_skills function - Restore create-release-packages.ps1 to original with full Generate-Commands/ Rewrite-Paths logic; add kimi case using New-KimiSkills function - Restore create-github-release.sh to original with proper $1 argument handling and VERSION_NO_V; add kimi zip entries - Add test_ai_help_includes_kimi for consistency with other agents - Strengthen test_kimi_in_powershell_validate_set to check ValidateSet * fix: address second round of PR review comments - Add __AGENT__ and {AGENT_SCRIPT} substitutions in create_kimi_skills (bash) - Add __AGENT__ and {AGENT_SCRIPT} substitutions in New-KimiSkills (PowerShell) - Replace curl|bash Kimi installer with pipx install kimi-cli in post-create.sh * fix: align kimi skill naming and add extension registrar test - Fix install_ai_skills() to use speckit. naming for kimi (dot separator) instead of speckit-, matching /skill:speckit. invocation convention and packaging scripts - Add test_kimi_in_extension_registrar to verify CommandRegistrar.AGENT_CONFIGS includes kimi with correct dir and SKILL.md extension * fix(test): align kimi skill name assertion with dot-separator convention test_skills_install_for_all_agents now expects speckit.specify (dot) for kimi and speckit-specify (hyphen) for all other agents, matching the install_ai_skills() implementation added in the previous commit. --------- Co-authored-by: Claude Sonnet 4.6 --- .devcontainer/post-create.sh | 13 +- .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 190 +++++++++++++----- .../scripts/create-release-packages.sh | 120 ++++++++--- AGENTS.md | 4 +- CHANGELOG.md | 1 + README.md | 5 +- scripts/bash/update-agent-context.sh | 17 +- scripts/powershell/update-agent-context.ps1 | 9 +- src/specify_cli/__init__.py | 14 +- src/specify_cli/extensions.py | 10 +- tests/test_agent_config_consistency.py | 55 +++++ tests/test_ai_skills.py | 7 +- 13 files changed, 349 insertions(+), 98 deletions(-) mode change 100644 => 100755 .github/workflows/scripts/create-github-release.sh diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 024ff0db48..d7d3da5884 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -8,15 +8,15 @@ run_command() { local command_to_run="$*" local output local exit_code - + # Capture all output (stdout and stderr) output=$(eval "$command_to_run" 2>&1) || exit_code=$? exit_code=${exit_code:-0} - + if [ $exit_code -ne 0 ]; then echo -e "\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\033[0m" >&2 echo -e "\033[0;31m$output\033[0m" >&2 - + exit $exit_code fi } @@ -53,7 +53,7 @@ echo "✅ Done" echo -e "\n🤖 Installing Kiro CLI..." # https://kiro.dev/docs/cli/ -KIRO_INSTALLER_URL="https://cli.kiro.dev/install" +KIRO_INSTALLER_URL="https://kiro.dev/install.sh" KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb" KIRO_INSTALLER_PATH="$(mktemp)" @@ -80,6 +80,11 @@ fi run_command "$kiro_binary --help > /dev/null" echo "✅ Done" +echo -e "\n🤖 Installing Kimi CLI..." +# https://code.kimi.com +run_command "pipx install kimi-cli" +echo "✅ Done" + echo -e "\n🤖 Installing CodeBuddy CLI..." run_command "npm install -g @tencent-ai/codebuddy-code@latest" echo "✅ Done" diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh old mode 100644 new mode 100755 index 29851a1409..864e0011ba --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -56,6 +56,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ .genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \ .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 894ab38e7c..8c7d4078f1 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -8,13 +8,13 @@ .DESCRIPTION create-release-packages.ps1 (workflow-local) Build Spec Kit template release archives for each supported AI assistant and script type. - + .PARAMETER Version Version string with leading 'v' (e.g., v0.2.0) .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -33,10 +33,10 @@ param( [Parameter(Mandatory=$true, Position=0)] [string]$Version, - + [Parameter(Mandatory=$false)] [string]$Agents = "", - + [Parameter(Mandatory=$false)] [string]$Scripts = "" ) @@ -60,7 +60,7 @@ New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null function Rewrite-Paths { param([string]$Content) - + $Content = $Content -replace '(/?)\bmemory/', '.specify/memory/' $Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/' $Content = $Content -replace '(/?)\btemplates/', '.specify/templates/' @@ -75,55 +75,55 @@ function Generate-Commands { [string]$OutputDir, [string]$ScriptVariant ) - + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - + $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - + foreach ($template in $templates) { $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - + # Read file content and normalize line endings $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - + # Extract description from YAML frontmatter $description = "" if ($fileContent -match '(?m)^description:\s*(.+)$') { $description = $matches[1] } - + # Extract script command from YAML frontmatter $scriptCommand = "" if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { $scriptCommand = $matches[1] } - + if ([string]::IsNullOrEmpty($scriptCommand)) { Write-Warning "No script command found for $ScriptVariant in $($template.Name)" $scriptCommand = "(Missing script command for $ScriptVariant)" } - + # Extract agent_script command from YAML frontmatter if present $agentScriptCommand = "" if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { $agentScriptCommand = $matches[1].Trim() } - + # Replace {SCRIPT} placeholder with the script command $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - + # Replace {AGENT_SCRIPT} placeholder with the agent script command if found if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand } - + # Remove the scripts: and agent_scripts: sections from frontmatter $lines = $body -split "`n" $outputLines = @() $inFrontmatter = $false $skipScripts = $false $dashCount = 0 - + foreach ($line in $lines) { if ($line -match '^---$') { $outputLines += $line @@ -135,7 +135,7 @@ function Generate-Commands { } continue } - + if ($inFrontmatter) { if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true @@ -148,20 +148,20 @@ function Generate-Commands { continue } } - + $outputLines += $line } - + $body = $outputLines -join "`n" - + # Apply other substitutions $body = $body -replace '\{ARGS\}', $ArgFormat $body = $body -replace '__AGENT__', $Agent $body = Rewrite-Paths -Content $body - + # Generate output file based on extension $outputFile = Join-Path $OutputDir "speckit.$name.$Extension" - + switch ($Extension) { 'toml' { $body = $body -replace '\\', '\\' @@ -183,15 +183,15 @@ function Generate-CopilotPrompts { [string]$AgentsDir, [string]$PromptsDir ) - + New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null - + $agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue - + foreach ($agentFile in $agentFiles) { $basename = $agentFile.Name -replace '\.agent\.md$', '' $promptFile = Join-Path $PromptsDir "$basename.prompt.md" - + $content = @" --- agent: $basename @@ -201,31 +201,118 @@ agent: $basename } } +# Create Kimi Code skills in .kimi/skills//SKILL.md format. +# Kimi CLI discovers skills as directories containing a SKILL.md file, +# invoked with /skill: (e.g. /skill:speckit.specify). +function New-KimiSkills { + param( + [string]$SkillsDir, + [string]$ScriptVariant + ) + + $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue + + foreach ($template in $templates) { + $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) + $skillName = "speckit.$name" + $skillDir = Join-Path $SkillsDir $skillName + New-Item -ItemType Directory -Force -Path $skillDir | Out-Null + + $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" + + # Extract description + $description = "Spec Kit: $name workflow" + if ($fileContent -match '(?m)^description:\s*(.+)$') { + $description = $matches[1] + } + + # Extract script command + $scriptCommand = "(Missing script command for $ScriptVariant)" + if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { + $scriptCommand = $matches[1] + } + + # Extract agent_script command from frontmatter if present + $agentScriptCommand = "" + if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { + $agentScriptCommand = $matches[1].Trim() + } + + # Replace {SCRIPT}, strip scripts sections, rewrite paths + $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand + if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { + $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand + } + + $lines = $body -split "`n" + $outputLines = @() + $inFrontmatter = $false + $skipScripts = $false + $dashCount = 0 + + foreach ($line in $lines) { + if ($line -match '^---$') { + $outputLines += $line + $dashCount++ + $inFrontmatter = ($dashCount -eq 1) + continue + } + if ($inFrontmatter) { + if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true; continue } + if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { $skipScripts = $false } + if ($skipScripts -and $line -match '^\s+') { continue } + } + $outputLines += $line + } + + $body = $outputLines -join "`n" + $body = $body -replace '\{ARGS\}', '$ARGUMENTS' + $body = $body -replace '__AGENT__', 'kimi' + $body = Rewrite-Paths -Content $body + + # Strip existing frontmatter, keep only body + $templateBody = "" + $fmCount = 0 + $inBody = $false + foreach ($line in ($body -split "`n")) { + if ($line -match '^---$') { + $fmCount++ + if ($fmCount -eq 2) { $inBody = $true } + continue + } + if ($inBody) { $templateBody += "$line`n" } + } + + $skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`n---`n`n$templateBody" + Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline + } +} + function Build-Variant { param( [string]$Agent, [string]$Script ) - + $baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}" Write-Host "Building $Agent ($Script) package..." New-Item -ItemType Directory -Path $baseDir -Force | Out-Null - + # Copy base structure but filter scripts by variant $specDir = Join-Path $baseDir ".specify" New-Item -ItemType Directory -Path $specDir -Force | Out-Null - + # Copy memory directory if (Test-Path "memory") { Copy-Item -Path "memory" -Destination $specDir -Recurse -Force Write-Host "Copied memory -> .specify" } - + # Only copy the relevant script variant directory if (Test-Path "scripts") { $scriptsDestDir = Join-Path $specDir "scripts" New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null - + switch ($Script) { 'sh' { if (Test-Path "scripts/bash") { @@ -240,18 +327,17 @@ function Build-Variant { } } } - - # Copy any script files that aren't in variant-specific directories + Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force } } - + # Copy templates (excluding commands directory and vscode-settings.json) if (Test-Path "templates") { $templatesDestDir = Join-Path $specDir "templates" New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null - + Get-ChildItem -Path "templates" -Recurse -File | Where-Object { $_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json' } | ForEach-Object { @@ -263,7 +349,7 @@ function Build-Variant { } Write-Host "Copied templates -> .specify/templates" } - + # Generate agent-specific command files switch ($Agent) { 'claude' { @@ -280,12 +366,10 @@ function Build-Variant { 'copilot' { $agentsDir = Join-Path $baseDir ".github/agents" Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script - - # Generate companion prompt files + $promptsDir = Join-Path $baseDir ".github/prompts" Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir - - # Create VS Code workspace settings + $vscodeDir = Join-Path $baseDir ".vscode" New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null if (Test-Path "templates/vscode-settings.json") { @@ -361,19 +445,24 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".agent/workflows" Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } - 'generic' { - $cmdDir = Join-Path $baseDir ".speckit/commands" - Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } 'vibe' { $cmdDir = Join-Path $baseDir ".vibe/prompts" Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'kimi' { + $skillsDir = Join-Path $baseDir ".kimi/skills" + New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null + New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script + } + 'generic' { + $cmdDir = Join-Path $baseDir ".speckit/commands" + Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } default { throw "Unsupported agent '$Agent'." } } - + # Create zip archive $zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip" Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force @@ -381,17 +470,16 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { param([string]$Input) - + if ([string]::IsNullOrEmpty($Input)) { return @() } - - # Split by comma or space and remove duplicates while preserving order + $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique return $items } @@ -402,7 +490,7 @@ function Validate-Subset { [string[]]$Allowed, [string[]]$Items ) - + $ok = $true foreach ($item in $Items) { if ($item -notin $Allowed) { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index af9880b613..8be5a0549d 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -45,19 +45,19 @@ generate_commands() { [[ -f "$template" ]] || continue local name description script_command agent_script_command body name=$(basename "$template" .md) - + # Normalize line endings file_content=$(tr -d '\r' < "$template") - + # Extract description and script command from YAML frontmatter description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - + if [[ -z $script_command ]]; then echo "Warning: no script command found for $script_variant in $template" >&2 script_command="(Missing script command for $script_variant)" fi - + # Extract agent_script command from YAML frontmatter if present agent_script_command=$(printf '%s\n' "$file_content" | awk ' /^agent_scripts:$/ { in_agent_scripts=1; next } @@ -68,15 +68,15 @@ generate_commands() { } in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } ') - + # Replace {SCRIPT} placeholder with the script command body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - + # Replace {AGENT_SCRIPT} placeholder with the agent script command if found if [[ -n $agent_script_command ]]; then body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") fi - + # Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure body=$(printf '%s\n' "$body" | awk ' /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } @@ -86,10 +86,10 @@ generate_commands() { in_frontmatter && skip_scripts && /^[[:space:]]/ { next } { print } ') - + # Apply other substitutions body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths) - + case $ext in toml) body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g') @@ -105,15 +105,14 @@ generate_commands() { generate_copilot_prompts() { local agents_dir=$1 prompts_dir=$2 mkdir -p "$prompts_dir" - + # Generate a .prompt.md file for each .agent.md file for agent_file in "$agents_dir"/speckit.*.agent.md; do [[ -f "$agent_file" ]] || continue - + local basename=$(basename "$agent_file" .agent.md) local prompt_file="$prompts_dir/${basename}.prompt.md" - - # Create prompt file with agent frontmatter + cat > "$prompt_file" </SKILL.md format. +# Kimi CLI discovers skills as directories containing a SKILL.md file, +# invoked with /skill: (e.g. /skill:speckit.specify). +create_kimi_skills() { + local skills_dir="$1" + local script_variant="$2" + + for template in templates/commands/*.md; do + [[ -f "$template" ]] || continue + local name + name=$(basename "$template" .md) + local skill_name="speckit.${name}" + local skill_dir="${skills_dir}/${skill_name}" + mkdir -p "$skill_dir" + + local file_content + file_content=$(tr -d '\r' < "$template") + + # Extract description from frontmatter + local description + description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') + [[ -z "$description" ]] && description="Spec Kit: ${name} workflow" + + # Extract script command + local script_command + script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') + [[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)" + + # Extract agent_script command from frontmatter if present + local agent_script_command + agent_script_command=$(printf '%s\n' "$file_content" | awk ' + /^agent_scripts:$/ { in_agent_scripts=1; next } + in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { + sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") + print + exit + } + in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } + ') + + # Build body: replace placeholders, strip scripts sections, rewrite paths + local body + body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") + if [[ -n $agent_script_command ]]; then + body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") + fi + body=$(printf '%s\n' "$body" | awk ' + /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } + in_frontmatter && /^scripts:$/ { skip_scripts=1; next } + in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } + in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } + in_frontmatter && skip_scripts && /^[[:space:]]/ { next } + { print } + ') + body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths) + + # Strip existing frontmatter and prepend Kimi frontmatter + local template_body + template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found') + + { + printf -- '---\n' + printf 'name: "%s"\n' "$skill_name" + printf 'description: "%s"\n' "$description" + printf -- '---\n\n' + printf '%s\n' "$template_body" + } > "$skill_dir/SKILL.md" + done +} + build_variant() { local agent=$1 script=$2 local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}" echo "Building $agent ($script) package..." mkdir -p "$base_dir" - + # Copy base structure but filter scripts by variant SPEC_DIR="$base_dir/.specify" mkdir -p "$SPEC_DIR" - + [[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; } - + # Only copy the relevant script variant directory if [[ -d scripts ]]; then mkdir -p "$SPEC_DIR/scripts" case $script in sh) [[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; } - # Copy any script files that aren't in variant-specific directories find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true ;; ps) [[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; } - # Copy any script files that aren't in variant-specific directories find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true ;; esac fi - + [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" -exec cp --parents {} "$SPEC_DIR"/ \; ; echo "Copied templates -> .specify/templates"; } - - # NOTE: We substitute {ARGS} internally. Outward tokens differ intentionally: - # * Markdown/prompt (claude, copilot, cursor-agent, opencode): $ARGUMENTS - # * TOML (gemini, qwen, tabnine): {{args}} - # This keeps formats readable without extra abstraction. case $agent in claude) @@ -169,9 +231,7 @@ build_variant() { copilot) mkdir -p "$base_dir/.github/agents" generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script" - # Generate companion prompt files generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts" - # Create VS Code workspace settings mkdir -p "$base_dir/.vscode" [[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json" ;; @@ -228,6 +288,9 @@ build_variant() { vibe) mkdir -p "$base_dir/.vibe/prompts" generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; + kimi) + mkdir -p "$base_dir/.kimi/skills" + create_kimi_skills "$base_dir/.kimi/skills" "$script" ;; generic) mkdir -p "$base_dir/.speckit/commands" generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; @@ -237,11 +300,10 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic) ALL_SCRIPTS=(sh ps) norm_list() { - # convert comma+space separated -> line separated unique while preserving order of first occurrence tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?"\n":"") $i);out=1}}}END{printf("\n")}' } diff --git a/AGENTS.md b/AGENTS.md index 8e7631b1d5..aa3730222d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | +| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | @@ -324,6 +325,7 @@ Require a command-line tool to be installed: - **Amp**: `amp` CLI - **SHAI**: `shai` CLI - **Tabnine CLI**: `tabnine` CLI +- **Kimi Code**: `kimi` CLI ### IDE-Based Agents @@ -337,7 +339,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob +Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code **Standard format:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b662160b..ac96960d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- feat: add Kimi Code CLI agent support - fix: sync agent list comments with actual supported agents (#1785) - feat(extensions): support multiple active catalogs simultaneously (#1720) - Pavel/add tabnine cli support (#1503) diff --git a/README.md b/README.md index c3afd18460..5aca4901cf 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | +| [Kimi Code](https://code.kimi.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://antigravity.google/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | @@ -192,14 +193,14 @@ The `specify` command supports the following options: | Command | Description | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`) | +| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index f67e2db990..b0022fd45e 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic # Leave empty to update all existing agent files set -e @@ -78,6 +78,7 @@ KIRO_FILE="$REPO_ROOT/AGENTS.md" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$REPO_ROOT/AGENTS.md" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" +KIMI_FILE="$REPO_ROOT/KIMI.md" # Template file TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" @@ -665,12 +666,15 @@ update_specific_agent() { vibe) update_agent_file "$VIBE_FILE" "Mistral Vibe" ;; + kimi) + update_agent_file "$KIMI_FILE" "Kimi Code" + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic" exit 1 ;; esac @@ -769,6 +773,11 @@ update_all_existing_agents() { found_agent=true fi + if [[ -f "$KIMI_FILE" ]]; then + update_agent_file "$KIMI_FILE" "Kimi Code" + found_agent=true + fi + # If no agent files exist, create a default Claude file if [[ "$found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." @@ -792,7 +801,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 816d4d5867..95e636ba1d 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')] [string]$AgentType ) @@ -63,6 +63,7 @@ $KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' +$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -406,8 +407,9 @@ function Update-SpecificAgent { 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } + 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false } } } @@ -432,6 +434,7 @@ function Update-AllExistingAgents { if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } + if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true } if (-not $found) { Write-Info 'No existing agent files found, creating default Claude file...' if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 209632a709..dac7eaa54a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -265,6 +265,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://github.com/mistralai/mistral-vibe", "requires_cli": True, }, + "kimi": { + "name": "Kimi Code", + "folder": ".kimi/", + "commands_subdir": "skills", # Kimi uses /skill: with .kimi/skills//SKILL.md + "install_url": "https://code.kimi.com/", + "requires_cli": True, + }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir @@ -1188,7 +1195,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker # SKILL_DESCRIPTIONS lookups work. if command_name.startswith("speckit."): command_name = command_name[len("speckit."):] - skill_name = f"speckit-{command_name}" + # Kimi CLI discovers skills by directory name and invokes them as + # /skill: — use dot separator to match packaging convention. + if selected_ai == "kimi": + skill_name = f"speckit.{command_name}" + else: + skill_name = f"speckit-{command_name}" # Create skill directory (additive — never removes existing content) skill_dir = skills_dir / skill_name diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 53777bd6b2..383ed21549 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -727,7 +727,7 @@ class CommandRegistrar: "extension": ".md" }, "roo": { - "dir": ".roo/rules", + "dir": ".roo/commands", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" @@ -773,6 +773,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" + }, + "kimi": { + "dir": ".kimi/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md" } } @@ -966,6 +972,7 @@ def register_commands_for_agent( # 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 @@ -977,6 +984,7 @@ def register_commands_for_agent( # 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": diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 3615dbc787..d77c6f10d1 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -171,3 +171,58 @@ def test_agent_context_scripts_include_tabnine(self): def test_ai_help_includes_tabnine(self): """CLI help text for --ai should include tabnine.""" assert "tabnine" in AI_ASSISTANT_HELP + + # --- Kimi Code CLI consistency checks --- + + def test_kimi_in_agent_config(self): + """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" + assert "kimi" in AGENT_CONFIG + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" + assert AGENT_CONFIG["kimi"]["requires_cli"] is True + + def test_kimi_in_extension_registrar(self): + """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "kimi" in cfg + kimi_cfg = cfg["kimi"] + assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["extension"] == "/SKILL.md" + + def test_kimi_in_release_agent_lists(self): + """Bash and PowerShell release scripts should include kimi in agent lists.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "kimi" in sh_agents + assert "kimi" in ps_agents + + def test_kimi_in_powershell_validate_set(self): + """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" + ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) + assert validate_set_match is not None + validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) + + assert "kimi" in validate_set_values + + def test_kimi_in_github_release_output(self): + """GitHub release script should include kimi template packages.""" + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert "spec-kit-template-kimi-sh-" in gh_release_text + assert "spec-kit-template-kimi-ps-" in gh_release_text + + def test_ai_help_includes_kimi(self): + """CLI help text for --ai should include kimi.""" + assert "kimi" in AI_ASSISTANT_HELP diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index a040b4bd01..e9bd71d057 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -410,8 +410,11 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key): skills_dir = _get_skills_dir(proj, agent_key) assert skills_dir.exists() skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert "speckit-specify" in skill_dirs - assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + # Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation; + # all other agents use hyphen-separator (speckit-specify). + expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify" + assert expected_skill_name in skill_dirs + assert (skills_dir / expected_skill_name / "SKILL.md").exists() From ec60c5b2fe546f363047fcb069cb9242763ae532 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:33:30 -0500 Subject: [PATCH 069/321] Added February 2026 newsletter (#1812) --- newsletters/2026-February.md | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 newsletters/2026-February.md diff --git a/newsletters/2026-February.md b/newsletters/2026-February.md new file mode 100644 index 0000000000..18a112a7f5 --- /dev/null +++ b/newsletters/2026-February.md @@ -0,0 +1,54 @@ +# Spec Kit - February 2026 Newsletter + +This edition covers Spec Kit activity in February 2026. Versions v0.1.7 through v0.1.13 shipped during the month, addressing bugs and adding features including a dual-catalog extension system and additional agent integrations. Community activity included blog posts, tutorials, and meetup sessions. A category summary is in the table below, followed by details. + +| **Spec Kit Core (Feb 2026)** | **Community & Content** | **Roadmap & Next** | +| --- | --- | --- | +| Versions **v0.1.7** through **v0.1.13** shipped with bug fixes and features, including a **dual-catalog extension system** and new agent integrations. Over 300 issues were closed (of ~800 filed). The repo reached 71k stars and 6.4k forks. [\[github.com\]](https://github.com/github/spec-kit/releases) [\[github.com\]](https://github.com/github/spec-kit/issues) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit) | Eduardo Luz published a LinkedIn article on SDD and Spec Kit [\[linkedin.com\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en). Erick Matsen blogged a walkthrough of building a bioinformatics pipeline with Spec Kit [\[matsen.fredhutch.org\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html). Microsoft MVP [Eric Boyd](https://ericboyd.com/) (not the Microsoft AI Platform VP of the same name) presented at the Cleveland .NET User Group [\[ericboyd.com\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit). | **v0.2.0** was released in early March, consolidating February's work. It added extensions for Jira and Azure DevOps, community plugin support, and agents for Tabnine CLI and Kiro CLI [\[github.com\]](https://github.com/github/spec-kit/releases). Future work includes spec lifecycle management and progress toward a stable 1.0 release [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html). | + +*** + +## Spec Kit Project Updates + +Spec Kit released versions **v0.1.7** through **v0.1.13** during February. Version 0.1.7 (early February) updated documentation for the newly introduced **dual-catalog extension system**, which allows both core and community extension catalogs to coexist. Subsequent patches (0.1.8, 0.1.9, etc.) bumped dependencies such as GitHub Actions versions and resolved minor issues. **v0.1.10** fixed YAML front-matter handling in generated files. By late February, **v0.1.12** and **v0.1.13** shipped with additional fixes in preparation for the next version bump. [\[github.com\]](https://github.com/github/spec-kit/releases) + +The main architectural addition was the **modular extension system** with separate "core" and "community" extension catalogs for third-party add-ons. Multiple community-contributed extensions were merged during the month, including a **Jira extension** for issue tracker integration, an **Azure DevOps extension**, and utility extensions for code review, retrospective documentation, and CI/CD sync. The pending 0.2.0 release changelog lists over a dozen changes from February, including the extension additions and support for **multiple agent catalogs concurrently**. [\[github.com\]](https://github.com/github/spec-kit/releases) + +By end of February, **over 330 issues/feature requests had been closed on GitHub** (out of ~870 filed to date). External contributors submitted pull requests including the **Tabnine CLI support**, which was merged in late February. The repository reached ~71k stars and crossed 6,000 forks. [\[github.com\]](https://github.com/github/spec-kit/issues) [\[github.com\]](https://github.com/github/spec-kit/releases) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit) + +On the stability side, February's work focused on tightening core workflows and fixing edge-case bugs in the specification, planning, and task-generation commands. The team addressed file-handling issues (e.g., clarifying how output files are created/appended) and improved the reliability of the automated release pipeline. The project also added **Kiro CLI** to the supported agent list and updated integration scripts for Cursor and Code Interpreter, bringing the total number of supported AI coding assistants to over 20. [\[github.com\]](https://github.com/github/spec-kit/releases) [\[github.com\]](https://github.com/github/spec-kit) + +## Community & Content + +**Eduardo Luz** published a LinkedIn article on Feb 15 titled *"Specification Driven Development (SDD) and the GitHub Spec Kit: Elevating Software Engineering."* The article draws on his experience as a senior engineer to describe common causes of technical debt and inconsistent designs, and how SDD addresses them. It walks through Spec Kit's **four-layer approach** (Constitution, Design, Tasks, Implementation) and discusses treating specifications as a source of truth. The post generated discussion among software architects on LinkedIn about reducing misunderstandings and rework through spec-driven workflows. [\[linkedin.com\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en) + +**Erick Matsen** (Fred Hutchinson Cancer Center) posted a detailed walkthrough on Feb 10 titled *"Spec-Driven Development with spec-kit."* He describes building a **bioinformatics pipeline** in a single day using Spec Kit's workflow (from `speckit.constitution` to `speckit.implement`). The post includes command outputs and notes on decisions made along the way, such as refining the spec to add domain-specific requirements. He writes: "I really recommend this approach. This feels like the way software development should be." [\[matsen.fredhutch.org\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html) [\[github.com\]](https://github.com/mnriem/spec-kit-dotnet-cli-demo) + +Several other tutorials and guides appeared during the month. An article on *IntuitionLabs* (updated Feb 21) provided a guide to Spec Kit covering the philosophy behind SDD and a walkthrough of the four-phase workflow with examples. A piece by Ry Walker (Feb 22) summarized key aspects of Spec Kit, noting its agent-agnostic design and 71k-star count. Microsoft's Developer Blog post from late 2025 (*"Diving Into Spec-Driven Development with GitHub Spec Kit"* by Den Delimarsky) continued to circulate among new users. [\[intuitionlabs.ai\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\[rywalker.com\]](https://rywalker.com/research/github-spec-kit) + +On **Feb 25**, the Cleveland C# .NET User Group hosted a session titled *"Spec Driven Development with GitHub Spec Kit."* The talk was delivered by Microsoft MVP **[Eric Boyd](https://ericboyd.com/)** (Cleveland-based .NET developer; not to be confused with the Microsoft AI Platform VP of the same name). Boyd covered how specs change an AI coding assistant's output, patterns for iterating and refining specs over multiple cycles, and moving from ad-hoc prompting to a repeatable spec-driven workflow. Other groups, including GDG Madison, also listed sessions on spec-driven development in late February and early March. [\[ericboyd.com\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit) + +On GitHub, the **Spec Kit Discussions forum** saw activity around installation troubleshooting, handling multi-feature projects with Spec Kit's branching model, and feature suggestions. One thread discussed how Spec Kit treats each spec as a short-lived artifact tied to a feature branch, which led to discussion about future support for long-running "spec of record" use cases. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html) + +## SDD Ecosystem + +Other spec-driven development tools also saw activity in February. + +AWS **Kiro** released version 0.10 on Feb 18 with two new spec workflows: a **Design-First** mode (starting from architecture/pseudocode to derive requirements) and a **Bugfix** mode (structured root-cause analysis producing a `bugfix.md` spec file). Kiro also added hunk-level code review for AI-generated changes and pre/post task hooks for custom automation. AWS expanded Kiro to GovCloud regions on Feb 17 for government compliance use cases. [\[kiro.dev\]](https://kiro.dev/changelog/) + +**OpenSpec** (by Fission AI), a lightweight SDD framework, reached ~29.3k stars and nearly 2k forks. Its community published guides and comparisons during the month, including *"Spec-Driven Development Made Easy: A Practical Guide with OpenSpec."* OpenSpec emphasizes simplicity and flexibility, integrating with multiple AI coding assistants via YAML configs. + +**Tessl** remained in private beta. As described by Thoughtworks writer Birgitta Boeckeler, Tessl pursues a **spec-as-source** model where specifications are maintained long-term and directly generate code files one-to-one, with generated code labeled as "do not edit." This contrasts with Spec Kit's current approach of creating specs per feature/branch. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html) + +An **arXiv preprint** (January 2026) categorized SDD implementations into three levels: *spec-first*, *spec-anchored*, and *spec-as-source*. Spec Kit was identified as primarily spec-first with elements of spec-anchored. Tech media published reviews including a *Vibe Coding* "GitHub Spec Kit Review (2026)" and a blog post titled *"Putting Spec Kit Through Its Paces: Radical Idea or Reinvented Waterfall?"* which concluded that SDD with AI assistance is more iterative than traditional Waterfall. [\[intuitionlabs.ai\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html) + +## Roadmap + +**v0.2.0** was released on March 10, 2026, consolidating the month's work. It includes new extensions (Jira, Azure DevOps, review, sync), support for multiple extension catalogs and community plugins, and additional agent integrations (Tabnine CLI, Kiro CLI). [\[github.com\]](https://github.com/github/spec-kit/releases) + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** -- supporting longer-lived specifications that can evolve across multiple iterations, rather than being tied to a single feature branch. Users have raised this in GitHub Discussions, and the concept of "spec-anchored" development is under consideration. [\[martinfowler.com\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html) +- **CI/CD integration** -- incorporating Spec Kit verification (e.g., `speckit.checklist` or `speckit.verify`) into pull request workflows and project management tools. February's Jira and Azure DevOps extensions are a step in this direction. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Continued agent support** -- adding integrations as new AI coding assistants emerge. The project currently supports over 20 agents and has been adding new ones (Kiro CLI, Tabnine CLI) as they become available. [\[github.com\]](https://github.com/github/spec-kit) +- **Community ecosystem** -- the open extension model allows external contributors to add functionality directly. February's Jira and Azure DevOps plugins were community-contributed. The Spec Kit README now links to community walkthrough demos for .NET, Spring Boot, and other stacks. [\[github.com\]](https://github.com/github/spec-kit) From 0f1cbd74fee4b6eb157b28fe53909d21a15d77a0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:41:40 -0500 Subject: [PATCH 070/321] chore: bump version to 0.2.1 (#1813) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac96960d76..536b808473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2026-03-11 + +### Changed + +- Added February 2026 newsletter (#1812) +- feat: add Kimi Code CLI agent support (#1790) +- docs: fix broken links in quickstart guide (#1759) (#1797) +- docs: add catalog cli help documentation (#1793) (#1794) +- fix: use quiet checkout to avoid exception on git checkout (#1792) +- feat(extensions): support .extensionignore to exclude files during install (#1781) +- feat: add Codex support for extension command registration (#1767) +- chore: bump version to 0.2.0 (#1786) +- fix: sync agent list comments with actual supported agents (#1785) +- feat(extensions): support multiple active catalogs simultaneously (#1720) +- Pavel/add tabnine cli support (#1503) +- Add Understanding extension to community catalog (#1778) +- Add ralph extension to community catalog (#1780) +- Update README with project initialization instructions (#1772) +- feat: add review extension to community catalog (#1775) +- Add fleet extension to community catalog (#1771) +- Integration of Mistral vibe support into speckit (#1725) +- fix: Remove duplicate options in specify.md (#1765) +- fix: use global branch numbering instead of per-short-name detection (#1757) +- Add Community Walkthroughs section to README (#1766) +- feat(extensions): add Jira Integration to community catalog (#1764) +- Add Azure DevOps Integration extension to community catalog (#1734) +- Fix docs: update Antigravity link and add initialization example (#1748) +- fix: wire after_tasks and after_implement hook events into command templates (#1702) +- make c ignores consistent with c++ (#1747) +- chore: bump version to 0.1.13 (#1746) +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) +- chore: bump version to 0.1.12 (#1737) +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [Unreleased] ### Added diff --git a/pyproject.toml b/pyproject.toml index 0bb55ceaf2..04a6791ad9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.2.0" +version = "0.2.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 82f8a13f83cf9a7dc78c85c62e20258382ec60de Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:02:02 -0500 Subject: [PATCH 071/321] Add Java brownfield walkthrough to community walkthroughs (#1820) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5aca4901cf..7bda2b9ce6 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,9 @@ See Spec-Driven Development in action across different scenarios with these comm - **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. -- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. +- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. + +- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. ## 🤖 Supported AI Agents From 58ce653908a3770c84a26b5dad66821afdf7a7c0 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Fri, 13 Mar 2026 13:23:37 +0100 Subject: [PATCH 072/321] feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776) * feat(extensions): implement automatic updates with atomic backup/restore - Implement automatic extension updates with download from catalog - Add comprehensive backup/restore mechanism for failed updates: - Backup registry entry before update - Backup extension directory - Backup command files for all AI agents - Backup hooks from extensions.yml - Add extension ID verification after install - Add KeyboardInterrupt handling to allow clean cancellation - Fix enable/disable to preserve installed_at timestamp by using direct registry manipulation instead of registry.add() - Add rollback on any update failure with command file, hook, and registry restoration Co-Authored-By: Claude Opus 4.5 * fix(extensions): comprehensive name resolution and error handling improvements - Add shared _resolve_installed_extension helper for ID/display name resolution with proper ambiguous name handling (shows table of matches) - Add _resolve_catalog_extension helper for catalog lookups by ID or display name - Update enable/disable/update/remove commands to use name resolution helpers - Fix extension_info to handle catalog errors gracefully: - Fallback to local installed info when catalog unavailable - Distinguish "catalog unavailable" from "not found in catalog" - Support display name lookup for both installed and catalog extensions - Use resolved display names in all status messages for consistency - Extract _print_extension_info helper for DRY catalog info printing Addresses reviewer feedback: - Ambiguous name handling in enable/disable/update - Catalog error fallback for installed extensions - UX message clarity (catalog unavailable vs not found) - Resolved ID in status messages Co-Authored-By: Claude Opus 4.5 * fix(extensions): properly detect ambiguous names in extension_info The extension_info command was breaking on the first name match without checking for ambiguity. This fix separates ID matching from name matching and checks for ambiguity before selecting a match, consistent with the _resolve_installed_extension() helper used by other commands. Co-Authored-By: Claude Opus 4.5 * refactor(extensions): add public update() method to ExtensionRegistry Add a proper public API for updating registry metadata while preserving installed_at timestamp, instead of directly mutating internal registry data and calling private _save() method. Changes: - Add ExtensionRegistry.update() method that preserves installed_at - Update enable/disable commands to use registry.update() - Update rollback logic to use registry.update() This decouples the CLI from registry internals and maintains proper encapsulation. Co-Authored-By: Claude Opus 4.5 * fix(extensions): safely access optional author field in extension_info ExtensionManifest doesn't expose an author property - the author field is optional in extension.yml and stored in data["extension"]["author"]. Use safe dict access to avoid AttributeError. Co-Authored-By: Claude Opus 4.5 * fix(extensions): address multiple reviewer comments - ExtensionRegistry.update() now preserves original installed_at timestamp - Add ExtensionRegistry.restore() for rollback (entry was removed) - Clean up wrongly installed extension on ID mismatch before rollback - Remove unused catalog_error parameter from _print_extension_info() Co-Authored-By: Claude Opus 4.5 * fix(extensions): check _install_allowed for updates, preserve backup on failed rollback - Skip automatic updates for extensions from catalogs with install_allowed=false - Only delete backup directory on successful rollback, preserve it on failure for manual recovery Co-Authored-By: Claude Opus 4.5 * fix(extensions): address reviewer feedback on update/rollback logic - Hook rollback: handle empty backup_hooks by checking `is not None` instead of truthiness (falsy empty dict would skip hook cleanup) - extension_info: use resolved_installed_id for catalog lookup when extension was found by display name (prevents wrong catalog match) - Rollback: always remove extension dir first, then restore if backup exists (handles case when no original dir existed before update) - Validate extension ID from ZIP before installing, not after (avoids side effects of installing wrong extension before rollback) - Preserve enabled state during updates: re-apply disabled state and hook enabled flags after successful update - Optimize _resolve_catalog_extension: pass query to catalog.search() instead of fetching all extensions - update() now merges metadata with existing entry instead of replacing (preserves fields like registered_commands when only updating enabled) - Add tests for ExtensionRegistry.update() and restore() methods: - test_update_preserves_installed_at - test_update_merges_with_existing - test_update_raises_for_missing_extension - test_restore_overwrites_completely - test_restore_can_recreate_removed_entry Co-Authored-By: Claude Opus 4.5 * docs(extensions): update RFC to reflect implemented status - Change status from "Draft" to "Implemented" - Update all Implementation Phases to show completed items - Add new features implemented beyond original RFC: - Display name resolution for all commands - Ambiguous name handling with tables - Atomic update with rollback - Pre-install ID validation - Enabled state preservation - Registry update/restore methods - Catalog error fallback - _install_allowed flag - Cache invalidation - Convert Open Questions to Resolved Questions with decisions - Add remaining Open Questions (sandboxing, signatures) as future work - Fix table of contents links Co-Authored-By: Claude Opus 4.5 * fix(extensions): address third round of PR review comments - Refactor extension_info to use _resolve_installed_extension() helper with new allow_not_found parameter instead of duplicating resolution logic - Fix rollback hook restoration to not create empty hooks: {} in config when original config had no hooks section - Fix ZIP pre-validation to handle nested extension.yml files (GitHub auto-generated ZIPs have structure like repo-name-branch/extension.yml) - Replace unused installed_manifest variable with _ placeholder - Add display name to update status messages for better UX Co-Authored-By: Claude Opus 4.5 * fix(extensions): address fourth round of PR review comments Rollback fixes: - Preserve installed_at timestamp after successful update (was reset by install_from_zip calling registry.add) - Fix rollback to only delete extension_dir if backup exists (avoids destroying valid installation when failure happens before modification) - Fix rollback to remove NEW command files created by failed install (files that weren't in original backup are now cleaned up) - Fix rollback to delete hooks key entirely when backup_hooks is None (original config had no hooks key, so restore should remove it) Cross-command consistency fix: - Add display name resolution to `extension add` command using _resolve_catalog_extension() helper (was only in `extension info`) - Use resolved extension ID for download_extension() call, not original argument which may be a display name Security fix (fail-closed): - Malformed catalog config (empty/missing URLs) now raises ValidationError instead of silently falling back to built-in catalogs Co-Authored-By: Claude Opus 4.5 * fix(lint): address ruff linting errors and registry.update() semantics - Remove unused import ExtensionError in extension_info - Remove extraneous f-prefix from strings without placeholders - Use registry.restore() instead of registry.update() for installed_at preservation (update() always preserves existing installed_at, ignoring our override) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- extensions/RFC-EXTENSION-SYSTEM.md | 245 +++++---- src/specify_cli/__init__.py | 780 +++++++++++++++++++++++------ src/specify_cli/extensions.py | 90 +++- tests/test_extensions.py | 397 ++++++++++++++- 4 files changed, 1231 insertions(+), 281 deletions(-) diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index c6469c48d6..a0f6034e5c 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -1,9 +1,9 @@ # RFC: Spec Kit Extension System -**Status**: Draft +**Status**: Implemented **Author**: Stats Perform Engineering **Created**: 2026-01-28 -**Updated**: 2026-01-28 +**Updated**: 2026-03-11 --- @@ -24,8 +24,9 @@ 13. [Security Considerations](#security-considerations) 14. [Migration Strategy](#migration-strategy) 15. [Implementation Phases](#implementation-phases) -16. [Open Questions](#open-questions) -17. [Appendices](#appendices) +16. [Resolved Questions](#resolved-questions) +17. [Open Questions (Remaining)](#open-questions-remaining) +18. [Appendices](#appendices) --- @@ -1504,203 +1505,225 @@ AI agent registers both names, so old scripts work. ## Implementation Phases -### Phase 1: Core Extension System (Week 1-2) +### Phase 1: Core Extension System ✅ COMPLETED **Goal**: Basic extension infrastructure **Deliverables**: -- [ ] Extension manifest schema (`extension.yml`) -- [ ] Extension directory structure -- [ ] CLI commands: - - [ ] `specify extension list` - - [ ] `specify extension add` (from URL) - - [ ] `specify extension remove` -- [ ] Extension registry (`.specify/extensions/.registry`) -- [ ] Command registration (Claude only initially) -- [ ] Basic validation (manifest schema, compatibility) -- [ ] Documentation (extension development guide) +- [x] Extension manifest schema (`extension.yml`) +- [x] Extension directory structure +- [x] CLI commands: + - [x] `specify extension list` + - [x] `specify extension add` (from URL and local `--dev`) + - [x] `specify extension remove` +- [x] Extension registry (`.specify/extensions/.registry`) +- [x] Command registration (Claude and 15+ other agents) +- [x] Basic validation (manifest schema, compatibility) +- [x] Documentation (extension development guide) **Testing**: -- [ ] Unit tests for manifest parsing -- [ ] Integration test: Install dummy extension -- [ ] Integration test: Register commands with Claude +- [x] Unit tests for manifest parsing +- [x] Integration test: Install dummy extension +- [x] Integration test: Register commands with Claude -### Phase 2: Jira Extension (Week 3) +### Phase 2: Jira Extension ✅ COMPLETED **Goal**: First production extension **Deliverables**: -- [ ] Create `spec-kit-jira` repository -- [ ] Port Jira functionality to extension -- [ ] Create `jira-config.yml` template -- [ ] Commands: - - [ ] `specstoissues.md` - - [ ] `discover-fields.md` - - [ ] `sync-status.md` -- [ ] Helper scripts -- [ ] Documentation (README, configuration guide, examples) -- [ ] Release v1.0.0 +- [x] Create `spec-kit-jira` repository +- [x] Port Jira functionality to extension +- [x] Create `jira-config.yml` template +- [x] Commands: + - [x] `specstoissues.md` + - [x] `discover-fields.md` + - [x] `sync-status.md` +- [x] Helper scripts +- [x] Documentation (README, configuration guide, examples) +- [x] Release v3.0.0 **Testing**: -- [ ] Test on `eng-msa-ts` project -- [ ] Verify spec→Epic, phase→Story, task→Issue mapping -- [ ] Test configuration loading and validation -- [ ] Test custom field application +- [x] Test on `eng-msa-ts` project +- [x] Verify spec→Epic, phase→Story, task→Issue mapping +- [x] Test configuration loading and validation +- [x] Test custom field application -### Phase 3: Extension Catalog (Week 4) +### Phase 3: Extension Catalog ✅ COMPLETED **Goal**: Discovery and distribution **Deliverables**: -- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo) -- [ ] Catalog fetch and parsing -- [ ] CLI commands: - - [ ] `specify extension search` - - [ ] `specify extension info` -- [ ] Catalog publishing process (GitHub Action) -- [ ] Documentation (how to publish extensions) +- [x] Central catalog (`extensions/catalog.json` in spec-kit repo) +- [x] Community catalog (`extensions/catalog.community.json`) +- [x] Catalog fetch and parsing with multi-catalog support +- [x] CLI commands: + - [x] `specify extension search` + - [x] `specify extension info` + - [x] `specify extension catalog list` + - [x] `specify extension catalog add` + - [x] `specify extension catalog remove` +- [x] Documentation (how to publish extensions) **Testing**: -- [ ] Test catalog fetch -- [ ] Test extension search/filtering -- [ ] Test catalog caching +- [x] Test catalog fetch +- [x] Test extension search/filtering +- [x] Test catalog caching +- [x] Test multi-catalog merge with priority -### Phase 4: Advanced Features (Week 5-6) +### Phase 4: Advanced Features ✅ COMPLETED **Goal**: Hooks, updates, multi-agent support **Deliverables**: -- [ ] Hook system (`hooks` in extension.yml) -- [ ] Hook registration and execution -- [ ] Project extensions config (`.specify/extensions.yml`) -- [ ] CLI commands: - - [ ] `specify extension update` - - [ ] `specify extension enable/disable` -- [ ] Command registration for multiple agents (Gemini, Copilot) -- [ ] Extension update notifications -- [ ] Configuration layer resolution (project, local, env) +- [x] Hook system (`hooks` in extension.yml) +- [x] Hook registration and execution +- [x] Project extensions config (`.specify/extensions.yml`) +- [x] CLI commands: + - [x] `specify extension update` (with atomic backup/restore) + - [x] `specify extension enable/disable` +- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.) +- [x] Extension update notifications (version comparison) +- [x] Configuration layer resolution (project, local, env) + +**Additional features implemented beyond original RFC**: + +- [x] **Display name resolution**: All commands accept extension display names in addition to IDs +- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name +- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure +- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security) +- [x] **Enabled state preservation**: Disabled extensions stay disabled after update +- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations +- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable +- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation +- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes **Testing**: -- [ ] Test hooks in core commands -- [ ] Test extension updates (preserve config) -- [ ] Test multi-agent registration +- [x] Test hooks in core commands +- [x] Test extension updates (preserve config) +- [x] Test multi-agent registration +- [x] Test atomic rollback on update failure +- [x] Test enabled state preservation +- [x] Test display name resolution -### Phase 5: Polish & Documentation (Week 7) +### Phase 5: Polish & Documentation ✅ COMPLETED **Goal**: Production ready **Deliverables**: -- [ ] Comprehensive documentation: - - [ ] User guide (installing/using extensions) - - [ ] Extension development guide - - [ ] Extension API reference - - [ ] Migration guide (core → extension) -- [ ] Error messages and validation improvements -- [ ] CLI help text updates -- [ ] Example extension template (cookiecutter) -- [ ] Blog post / announcement -- [ ] Video tutorial +- [x] Comprehensive documentation: + - [x] User guide (EXTENSION-USER-GUIDE.md) + - [x] Extension development guide (EXTENSION-DEV-GUIDE.md) + - [x] Extension API reference (EXTENSION-API-REFERENCE.md) +- [x] Error messages and validation improvements +- [x] CLI help text updates **Testing**: -- [ ] End-to-end testing on multiple projects -- [ ] Community beta testing -- [ ] Performance testing (large projects) +- [x] End-to-end testing on multiple projects +- [x] 163 unit tests passing --- -## Open Questions +## Resolved Questions -### 1. Extension Namespace +The following questions from the original RFC have been resolved during implementation: -**Question**: Should extension commands use namespace prefix? +### 1. Extension Namespace ✅ RESOLVED -**Options**: +**Question**: Should extension commands use namespace prefix? -- A) Prefixed: `/speckit.jira.specstoissues` (explicit, avoids conflicts) -- B) Short alias: `/jira.specstoissues` (shorter, less verbose) -- C) Both: Register both names, prefer prefixed in docs +**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest. -**Recommendation**: C (both), prefixed is canonical +**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names. --- -### 2. Config File Location +### 2. Config File Location ✅ RESOLVED **Question**: Where should extension configs live? -**Options**: - -- A) Extension directory: `.specify/extensions/jira/jira-config.yml` (encapsulated) -- B) Root level: `.specify/jira-config.yml` (more visible) -- C) Unified: `.specify/extensions.yml` (all extension configs in one file) +**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage. -**Recommendation**: A (extension directory), cleaner separation +**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars). --- -### 3. Command File Format +### 3. Command File Format ✅ RESOLVED **Question**: Should extensions use universal format or agent-specific? -**Options**: - -- A) Universal Markdown: Extensions write once, CLI converts per-agent -- B) Agent-specific: Extensions provide separate files for each agent -- C) Hybrid: Universal default, agent-specific overrides +**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration. -**Recommendation**: A (universal), reduces duplication +**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.). --- -### 4. Hook Execution Model +### 4. Hook Execution Model ✅ RESOLVED **Question**: How should hooks execute? -**Options**: - -- A) AI agent interprets: Core commands output `EXECUTE_COMMAND: name` -- B) CLI executes: Core commands call `specify extension hook after_tasks` -- C) Agent built-in: Extension system built into AI agent (Claude SDK) +**Decision**: **Option A** - Hooks are registered in `.specify/extensions.yml` and executed by the AI agent when it sees the hook trigger. Hook state (enabled/disabled) is managed per-extension. -**Recommendation**: A initially (simpler), move to C long-term +**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`. --- -### 5. Extension Distribution +### 5. Extension Distribution ✅ RESOLVED **Question**: How should extensions be packaged? +**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path. + +**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation. + +--- + +### 6. Multi-Version Support ✅ RESOLVED + +**Question**: Can multiple versions of same extension coexist? + +**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure. + +**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates. + +--- + +## Open Questions (Remaining) + +### 1. Sandboxing / Permissions (Future) + +**Question**: Should extensions declare required permissions? + **Options**: -- A) ZIP archives: Downloaded from GitHub releases -- B) Git repos: Cloned directly (`git clone`) -- C) Python packages: Installable via `uv tool install` +- A) No sandboxing (current): Extensions run with same privileges as AI agent +- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc. +- C) Opt-in sandboxing: Organizations can enable permission enforcement -**Recommendation**: A (ZIP), simpler for non-Python extensions in future +**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors. --- -### 6. Multi-Version Support +### 2. Package Signatures (Future) -**Question**: Can multiple versions of same extension coexist? +**Question**: Should extensions be cryptographically signed? **Options**: -- A) Single version: Only one version installed at a time -- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`) -- C) Per-branch: Different branches use different versions +- A) No signatures (current): Trust based on catalog source +- B) GPG/Sigstore signatures: Verify package integrity +- C) Catalog-level verification: Catalog maintainers verify packages -**Recommendation**: A initially (simpler), consider B in future if needed +**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced. --- diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dac7eaa54a..55e97ea9fb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1813,6 +1813,126 @@ def get_speckit_version() -> str: return "unknown" +def _resolve_installed_extension( + argument: str, + installed_extensions: list, + command_name: str = "command", + allow_not_found: bool = False, +) -> tuple[Optional[str], Optional[str]]: + """Resolve an extension argument (ID or display name) to an installed extension. + + Args: + argument: Extension ID or display name provided by user + installed_extensions: List of installed extension dicts from manager.list_installed() + command_name: Name of the command for error messages (e.g., "enable", "disable") + allow_not_found: If True, return (None, None) when not found instead of raising + + Returns: + Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found + + Raises: + typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous + """ + from rich.table import Table + + # First, try exact ID match + for ext in installed_extensions: + if ext["id"] == argument: + return (ext["id"], ext["name"]) + + # If not found by ID, try display name match + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + # Unique display-name match + return (name_matches[0]["id"], name_matches[0]["name"]) + elif len(name_matches) > 1: + # Ambiguous display-name match + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple installed extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + for ext in name_matches: + table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + else: + # No match by ID or display name + if allow_not_found: + return (None, None) + console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") + raise typer.Exit(1) + + +def _resolve_catalog_extension( + argument: str, + catalog, + command_name: str = "info", +) -> tuple[Optional[dict], Optional[Exception]]: + """Resolve an extension argument (ID or display name) from the catalog. + + Args: + argument: Extension ID or display name provided by user + catalog: ExtensionCatalog instance + command_name: Name of the command for error messages + + Returns: + Tuple of (extension_info, catalog_error) + - If found: (ext_info_dict, None) + - If catalog error: (None, error) + - If not found: (None, None) + """ + from rich.table import Table + from .extensions import ExtensionError + + try: + # First try by ID + ext_info = catalog.get_extension_info(argument) + if ext_info: + return (ext_info, None) + + # Try by display name - search using argument as query, then filter for exact match + search_results = catalog.search(query=argument) + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + return (name_matches[0], None) + elif len(name_matches) > 1: + # Ambiguous display-name match in catalog + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple catalog extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + table.add_column("Catalog", style="dim") + for ext in name_matches: + table.add_row( + ext.get("id", ""), + ext.get("name", ""), + str(ext.get("version", "")), + ext.get("_catalog_name", ""), + ) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + + # Not found + return (None, None) + + except ExtensionError as e: + return (None, e) + + @extension_app.command("list") def extension_list( available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), @@ -2111,8 +2231,11 @@ def extension_add( # Install from catalog catalog = ExtensionCatalog(project_root) - # Check if extension exists in catalog - ext_info = catalog.get_extension_info(extension) + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) if not ext_info: console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") console.print("\nSearch available extensions:") @@ -2132,9 +2255,10 @@ def extension_add( ) raise typer.Exit(1) - # Download extension ZIP + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension) + zip_path = catalog.download_extension(extension_id) try: # Install from downloaded ZIP @@ -2167,7 +2291,7 @@ def extension_add( @extension_app.command("remove") def extension_remove( - extension: str = typer.Argument(help="Extension ID to remove"), + extension: str = typer.Argument(help="Extension ID or name to remove"), keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), force: bool = typer.Option(False, "--force", help="Skip confirmation"), ): @@ -2185,25 +2309,19 @@ def extension_remove( manager = ExtensionManager(project_root) - # Check if extension is installed - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - # Get extension info - ext_manifest = manager.get_extension(extension) - if ext_manifest: - ext_name = ext_manifest.name - cmd_count = len(ext_manifest.commands) - else: - ext_name = extension - cmd_count = 0 + # Get extension info for command count + ext_manifest = manager.get_extension(extension_id) + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") - console.print(f" • Extension directory: .specify/extensions/{extension}/") + console.print(f" • Extension directory: .specify/extensions/{extension_id}/") if not keep_config: console.print(" • Config files (will be backed up)") console.print() @@ -2214,15 +2332,15 @@ def extension_remove( raise typer.Exit(0) # Remove extension - success = manager.remove(extension, keep_config=keep_config) + success = manager.remove(extension_id, keep_config=keep_config) if success: - console.print(f"\n[green]✓[/green] Extension '{ext_name}' removed successfully") + console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension}/") + console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") - console.print(f"\nTo reinstall: specify extension add {extension}") + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") + console.print(f"\nTo reinstall: specify extension add {extension_id}") else: console.print("[red]Error:[/red] Failed to remove extension") raise typer.Exit(1) @@ -2320,7 +2438,7 @@ def extension_info( extension: str = typer.Argument(help="Extension ID or name"), ): """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError + from .extensions import ExtensionCatalog, ExtensionManager project_root = Path.cwd() @@ -2333,118 +2451,181 @@ def extension_info( catalog = ExtensionCatalog(project_root) manager = ExtensionManager(project_root) + installed = manager.list_installed() - try: - ext_info = catalog.get_extension_info(extension) + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nTry: specify extension search") - raise typer.Exit(1) + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") - # Header - verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" - console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") - console.print(f"ID: {ext_info['id']}") - console.print() + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return - # Description - console.print(f"{ext_info['description']}") + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})") + console.print(f"ID: {resolved_installed_id}") console.print() - # Author and License - console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") - console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + if ext_manifest: + console.print(f"{ext_manifest.description}") + console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + console.print(f"[dim]Author:[/dim] {author}") + console.print() + + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {cmd['name']}: {cmd.get('description', '')}") + console.print() + + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") + console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") + else: + console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") - # Source catalog - if ext_info.get("_catalog_name"): - install_allowed = ext_info.get("_install_allowed", True) - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") console.print() + console.print("[green]✓ Installed[/green]") + console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") + return - # Requirements - if ext_info.get('requires'): - console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): - console.print(f" • Spec Kit: {reqs['speckit_version']}") - if reqs.get('tools'): - for tool in reqs['tools']: - tool_name = tool['name'] - tool_version = tool.get('version', 'any') - required = " (required)" if tool.get('required') else " (optional)" - console.print(f" • {tool_name}: {tool_version}{required}") - console.print() + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{extension}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) - # Provides - if ext_info.get('provides'): - console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): - console.print(f" • Commands: {provides['commands']}") - if provides.get('hooks'): - console.print(f" • Hooks: {provides['hooks']}") - console.print() - # Tags - if ext_info.get('tags'): - tags_str = ", ".join(ext_info['tags']) - console.print(f"[bold]Tags:[/bold] {tags_str}") - console.print() +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") + console.print(f"ID: {ext_info['id']}") + console.print() - # Statistics - stats = [] - if ext_info.get('downloads') is not None: - stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get('stars') is not None: - stats.append(f"Stars: {ext_info['stars']}") - if stats: - console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") - console.print() + # Description + console.print(f"{ext_info['description']}") + console.print() - # Links - console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): - console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get('homepage'): - console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get('documentation'): - console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get('changelog'): - console.print(f" • Changelog: {ext_info['changelog']}") - console.print() + # Author and License + console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") + console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") - # Installation status and command - is_installed = manager.registry.is_installed(ext_info['id']) + # Source catalog + if ext_info.get("_catalog_name"): install_allowed = ext_info.get("_install_allowed", True) - if is_installed: - console.print("[green]✓ Installed[/green]") - console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - elif install_allowed: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") - else: - catalog_name = ext_info.get("_catalog_name", "community") - console.print("[yellow]Not installed[/yellow]") - console.print( - f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " - f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " - f"with install_allowed: true to enable installation." - ) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") + console.print() - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) + # Requirements + if ext_info.get('requires'): + console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + console.print(f" • Spec Kit: {reqs['speckit_version']}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = tool['name'] + tool_version = tool.get('version', 'any') + required = " (required)" if tool.get('required') else " (optional)" + console.print(f" • {tool_name}: {tool_version}{required}") + console.print() + + # Provides + if ext_info.get('provides'): + console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + console.print(f" • Commands: {provides['commands']}") + if provides.get('hooks'): + console.print(f" • Hooks: {provides['hooks']}") + console.print() + + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {tags_str}") + console.print() + + # Statistics + stats = [] + if ext_info.get('downloads') is not None: + stats.append(f"Downloads: {ext_info['downloads']:,}") + if ext_info.get('stars') is not None: + stats.append(f"Stars: {ext_info['stars']}") + if stats: + console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + console.print() + + # Links + console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + console.print(f" • Repository: {ext_info['repository']}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {ext_info['homepage']}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {ext_info['documentation']}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {ext_info['changelog']}") + console.print() + + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) + if is_installed: + console.print("[green]✓ Installed[/green]") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) @extension_app.command("update") def extension_update( - extension: str = typer.Argument(None, help="Extension ID to update (or all)"), + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), ): """Update extension(s) to latest version.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError + from .extensions import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + ) from packaging import version as pkg_version + import shutil project_root = Path.cwd() @@ -2457,18 +2638,17 @@ def extension_update( manager = ExtensionManager(project_root) catalog = ExtensionCatalog(project_root) + speckit_version = get_speckit_version() try: # Get list of extensions to update + installed = manager.list_installed() if extension: - # Update specific extension - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) - extensions_to_update = [extension] + # Update specific extension - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] else: # Update all extensions - installed = manager.list_installed() extensions_to_update = [ext["id"] for ext in installed] if not extensions_to_update: @@ -2482,7 +2662,16 @@ def extension_update( for ext_id in extensions_to_update: # Get installed version metadata = manager.registry.get(ext_id) - installed_version = pkg_version.Version(metadata["version"]) + if metadata is None or "version" not in metadata: + console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" + ) + continue # Get catalog info ext_info = catalog.get_extension_info(ext_id) @@ -2490,12 +2679,24 @@ def extension_update( console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") continue - catalog_version = pkg_version.Version(ext_info["version"]) + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" + ) + continue if catalog_version > installed_version: updates_available.append( { "id": ext_id, + "name": ext_info.get("name", ext_id), # Display name for status messages "installed": str(installed_version), "available": str(catalog_version), "download_url": ext_info.get("download_url"), @@ -2521,25 +2722,288 @@ def extension_update( console.print("Cancelled") raise typer.Exit(0) - # Perform updates + # Perform updates with atomic backup/restore console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + for update in updates_available: - ext_id = update["id"] - console.print(f"📦 Updating {ext_id}...") + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + console.print(f"📦 Updating {ext_name}...") - # TODO: Implement download and reinstall from URL - # For now, just show message - console.print( - "[yellow]Note:[/yellow] Automatic update not yet implemented. " - "Please update manually:" - ) - console.print(f" specify extension remove {ext_id} --keep-config") - console.print(f" specify extension add {ext_id}") + # Backup paths + backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_config_dir = backup_base / "config" - console.print( - "\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version" - ) + # Store backup state + backup_registry_entry = None + backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backed_up_command_files = {} + + try: + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) + + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) + + # 3. Backup command files for all agents + registered_commands = backup_registry_entry.get("registered_commands", {}) + 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 = 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(): + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name + backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cmd_file, backup_cmd_path) + backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) + + # Also backup copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name + backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(prompt_file, backup_prompt_path) + backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) + + # 4. Backup hooks from extensions.yml + # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) + # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension + config = hook_executor.get_project_config() + if "hooks" in config: + backup_hooks = {} # Config has hooks key - preserve this fact + for hook_name, hook_list in config["hooks"].items(): + ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + + # 5. Download new version + zip_path = catalog.download_extension(extension_id) + try: + # 6. Validate extension ID from ZIP BEFORE modifying installation + # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) + with zipfile.ZipFile(zip_path, "r") as zf: + import yaml + manifest_data = None + namelist = zf.namelist() + + # First try root-level extension.yml + if "extension.yml" in namelist: + with zf.open("extension.yml") as f: + manifest_data = yaml.safe_load(f) or {} + else: + # Look for extension.yml in a single top-level subdirectory + # (e.g., "repo-name-branch/extension.yml") + manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] + if len(manifest_paths) == 1: + with zf.open(manifest_paths[0]) as f: + manifest_data = yaml.safe_load(f) or {} + + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") + + zip_extension_id = manifest_data.get("extension", {}).get("id") + if zip_extension_id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" + ) + + # 7. Remove old extension (handles command file cleanup and registry removal) + manager.remove(extension_id, keep_config=True) + + # 8. Install new version + _ = manager.install_from_zip(zip_path, speckit_version) + + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) + + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry: + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None: + raise RuntimeError( + f"Registry entry for '{extension_id}' missing after install — update incomplete" + ) + new_metadata = dict(current_metadata) + + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] + + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False + + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + # 10. Clean up backup on success + if backup_base.exists(): + shutil.rmtree(backup_base) + + console.print(f" [green]✓[/green] Updated to v{update['available']}") + updated_extensions.append(ext_name) + + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]✗[/red] Failed: {e}") + failed_updates.append((ext_name, str(e))) + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") + + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) + + # Remove any NEW command files created by failed install + # (files that weren't in the original backup) + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None: + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up + + # Restore backed up command files + for original_path, backup_path in backed_up_command_files.items(): + backup_file = Path(backup_path) + if backup_file.exists(): + original_file = Path(original_path) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) + + # Restore hooks in extensions.yml + # - backup_hooks=None means original config had no "hooks" key + # - backup_hooks={} or {...} means config had hooks key + config = hook_executor.get_project_config() + if "hooks" in config: + modified = False + + if backup_hooks is None: + # Original config had no "hooks" key; remove it entirely + del config["hooks"] + modified = True + else: + # Remove any hooks for this extension added by failed install + for hook_name, hooks_list in config["hooks"].items(): + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"]: + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + if modified: + hook_executor.save_project_config(config) + + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) + + console.print(" [green]✓[/green] Rollback successful") + # Clean up backup directory only on successful rollback + if backup_base.exists(): + shutil.rmtree(backup_base) + except Exception as rollback_error: + console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") + console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") + + # Summary + console.print() + if updated_extensions: + console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {ext_name}: {error}") + raise typer.Exit(1) + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) except ExtensionError as e: console.print(f"\n[red]Error:[/red] {e}") raise typer.Exit(1) @@ -2547,7 +3011,7 @@ def extension_update( @extension_app.command("enable") def extension_enable( - extension: str = typer.Argument(help="Extension ID to enable"), + extension: str = typer.Argument(help="Extension ID or name to enable"), ): """Enable a disabled extension.""" from .extensions import ExtensionManager, HookExecutor @@ -2564,34 +3028,38 @@ def extension_enable( manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") # Update registry - metadata = manager.registry.get(extension) + metadata = manager.registry.get(extension_id) + if metadata is None: + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]") + console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") raise typer.Exit(0) metadata["enabled"] = True - manager.registry.add(extension, metadata) + manager.registry.update(extension_id, metadata) # Enable hooks in extensions.yml config = hook_executor.get_project_config() if "hooks" in config: for hook_name in config["hooks"]: for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension: + if hook.get("extension") == extension_id: hook["enabled"] = True hook_executor.save_project_config(config) - console.print(f"[green]✓[/green] Extension '{extension}' enabled") + console.print(f"[green]✓[/green] Extension '{display_name}' enabled") @extension_app.command("disable") def extension_disable( - extension: str = typer.Argument(help="Extension ID to disable"), + extension: str = typer.Argument(help="Extension ID or name to disable"), ): """Disable an extension without removing it.""" from .extensions import ExtensionManager, HookExecutor @@ -2608,31 +3076,35 @@ def extension_disable( manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") # Update registry - metadata = manager.registry.get(extension) + metadata = manager.registry.get(extension_id) + if metadata is None: + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]") + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") raise typer.Exit(0) metadata["enabled"] = False - manager.registry.add(extension, metadata) + manager.registry.update(extension_id, metadata) # Disable hooks in extensions.yml config = hook_executor.get_project_config() if "hooks" in config: for hook_name in config["hooks"]: for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension: + if hook.get("extension") == extension_id: hook["enabled"] = False hook_executor.save_project_config(config) - console.print(f"[green]✓[/green] Extension '{extension}' disabled") + console.print(f"[green]✓[/green] Extension '{display_name}' disabled") console.print("\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {extension}") + console.print(f"To re-enable: specify extension enable {extension_id}") def main(): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 383ed21549..fa9766b003 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -12,6 +12,7 @@ import tempfile import zipfile import shutil +import copy from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, List, Any, Callable, Set @@ -228,6 +229,54 @@ def add(self, extension_id: str, metadata: dict): } self._save() + def update(self, extension_id: str, metadata: dict): + """Update extension metadata in registry, merging with existing entry. + + Merges the provided metadata with the existing entry, preserving any + fields not specified in the new metadata. The installed_at timestamp + is always preserved from the original entry. + + Use this method instead of add() when updating existing extension + metadata (e.g., enabling/disabling) to preserve the original + installation timestamp and other existing fields. + + Args: + extension_id: Extension ID + metadata: Extension metadata fields to update (merged with existing) + + Raises: + KeyError: If extension is not installed + """ + if extension_id not in self.data["extensions"]: + raise KeyError(f"Extension '{extension_id}' is not installed") + # Merge new metadata with existing, preserving original installed_at + existing = self.data["extensions"][extension_id] + # Merge: existing fields preserved, new fields override + merged = {**existing, **metadata} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + self.data["extensions"][extension_id] = merged + self._save() + + def restore(self, extension_id: str, metadata: dict): + """Restore extension metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + extension_id: Extension ID + metadata: Complete extension metadata including installed_at + """ + self.data["extensions"][extension_id] = dict(metadata) + self._save() + def remove(self, extension_id: str): """Remove extension from registry. @@ -241,21 +290,28 @@ def remove(self, extension_id: str): def get(self, extension_id: str) -> Optional[dict]: """Get extension metadata from registry. + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + Args: extension_id: Extension ID Returns: - Extension metadata or None if not found + Deep copy of extension metadata, or None if not found """ - return self.data["extensions"].get(extension_id) + entry = self.data["extensions"].get(extension_id) + return copy.deepcopy(entry) if entry is not None else None def list(self) -> Dict[str, dict]: """Get all installed extensions. + Returns a deep copy of the extensions mapping to prevent callers + from accidentally mutating nested internal registry state. + Returns: - Dictionary of extension_id -> metadata + Dictionary of extension_id -> metadata (deep copies) """ - return self.data["extensions"] + return copy.deepcopy(self.data["extensions"]) def is_installed(self, extension_id: str) -> bool: """Check if extension is installed. @@ -600,7 +656,7 @@ def list_installed(self) -> List[Dict[str, Any]]: result.append({ "id": ext_id, "name": manifest.name, - "version": metadata["version"], + "version": metadata.get("version", "unknown"), "description": manifest.description, "enabled": metadata.get("enabled", True), "installed_at": metadata.get("installed_at"), @@ -1112,12 +1168,13 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry] config_path: Path to extension-catalogs.yml Returns: - Ordered list of CatalogEntry objects, or None if file doesn't exist - or contains no valid catalog entries. + Ordered list of CatalogEntry objects, or None if file doesn't exist. Raises: ValidationError: If any catalog entry has an invalid URL, - the file cannot be parsed, or a priority value is invalid. + the file cannot be parsed, a priority value is invalid, + or the file exists but contains no valid catalog entries + (fail-closed for security). """ if not config_path.exists(): return None @@ -1129,12 +1186,17 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry] ) catalogs_data = data.get("catalogs", []) if not catalogs_data: - return None + # File exists but has no catalogs key or empty list - fail closed + raise ValidationError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) if not isinstance(catalogs_data, list): raise ValidationError( f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" ) entries: List[CatalogEntry] = [] + skipped_entries: List[int] = [] for idx, item in enumerate(catalogs_data): if not isinstance(item, dict): raise ValidationError( @@ -1142,6 +1204,7 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry] ) url = str(item.get("url", "")).strip() if not url: + skipped_entries.append(idx) continue self._validate_catalog_url(url) try: @@ -1164,7 +1227,14 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry] description=str(item.get("description", "")), )) entries.sort(key=lambda e: e.priority) - return entries if entries else None + if not entries: + # All entries were invalid (missing URLs) - fail closed for security + raise ValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs " + f"(entries at indices {skipped_entries} were skipped). " + f"Each catalog entry must have a 'url' field." + ) + return entries def get_active_catalogs(self) -> List[CatalogEntry]: """Get the ordered list of active catalogs. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ba52d03439..4c098c255e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -277,6 +277,135 @@ def test_registry_persistence(self, temp_dir): assert registry2.is_installed("test-ext") assert registry2.get("test-ext")["version"] == "1.0.0" + def test_update_preserves_installed_at(self, temp_dir): + """Test that update() preserves the original installed_at timestamp.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "enabled": True}) + + # Get original installed_at + original_data = registry.get("test-ext") + original_installed_at = original_data["installed_at"] + + # Update with new metadata + registry.update("test-ext", {"version": "2.0.0", "enabled": False}) + + # Verify installed_at is preserved + updated_data = registry.get("test-ext") + assert updated_data["installed_at"] == original_installed_at + assert updated_data["version"] == "2.0.0" + assert updated_data["enabled"] is False + + def test_update_merges_with_existing(self, temp_dir): + """Test that update() merges new metadata with existing fields.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"claude": ["cmd1", "cmd2"]}, + }) + + # Update with partial metadata (only enabled field) + registry.update("test-ext", {"enabled": False}) + + # Verify existing fields are preserved + updated_data = registry.get("test-ext") + assert updated_data["enabled"] is False + assert updated_data["version"] == "1.0.0" # Preserved + assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved + + def test_update_raises_for_missing_extension(self, temp_dir): + """Test that update() raises KeyError for non-installed extension.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(KeyError, match="not installed"): + registry.update("nonexistent-ext", {"enabled": False}) + + def test_restore_overwrites_completely(self, temp_dir): + """Test that restore() overwrites the registry entry completely.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "2.0.0", "enabled": True}) + + # Restore with complete backup data + backup_data = { + "version": "1.0.0", + "enabled": False, + "installed_at": "2024-01-01T00:00:00+00:00", + "registered_commands": {"claude": ["old-cmd"]}, + } + registry.restore("test-ext", backup_data) + + # Verify entry is exactly as restored + restored_data = registry.get("test-ext") + assert restored_data == backup_data + + def test_restore_can_recreate_removed_entry(self, temp_dir): + """Test that restore() can recreate an entry after remove().""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0"}) + + # Save backup and remove + backup = registry.get("test-ext").copy() + registry.remove("test-ext") + assert not registry.is_installed("test-ext") + + # Restore should recreate the entry + registry.restore("test-ext", backup) + assert registry.is_installed("test-ext") + assert registry.get("test-ext")["version"] == "1.0.0" + + def test_get_returns_deep_copy(self, temp_dir): + """Test that get() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + fetched = registry.get("test-ext") + fetched["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + + def test_list_returns_deep_copy(self, temp_dir): + """Test that list() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + listed = registry.list() + listed["test-ext"]["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + # ===== ExtensionManager Tests ===== @@ -1402,8 +1531,8 @@ def test_project_config_invalid_url_raises(self, temp_dir): with pytest.raises(ValidationError, match="HTTPS"): catalog.get_active_catalogs() - def test_empty_project_config_falls_back_to_defaults(self, temp_dir): - """Empty catalogs list in config falls back to default stack.""" + def test_empty_project_config_raises_error(self, temp_dir): + """Empty catalogs list in config raises ValidationError (fail-closed for security).""" import yaml as yaml_module project_dir = self._make_project(temp_dir) @@ -1412,11 +1541,32 @@ def test_empty_project_config_falls_back_to_defaults(self, temp_dir): yaml_module.dump({"catalogs": []}, f) catalog = ExtensionCatalog(project_dir) - entries = catalog.get_active_catalogs() - # Falls back to default stack - assert len(entries) == 2 - assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL + # Fail-closed: empty config should raise, not fall back to defaults + with pytest.raises(ValidationError) as exc_info: + catalog.get_active_catalogs() + assert "contains no 'catalogs' entries" in str(exc_info.value) + + def test_catalog_entries_without_urls_raises_error(self, temp_dir): + """Catalog entries without URLs raise ValidationError (fail-closed for security).""" + import yaml as yaml_module + + project_dir = self._make_project(temp_dir) + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump({ + "catalogs": [ + {"name": "no-url-catalog", "priority": 1}, + {"name": "another-no-url", "description": "Also missing URL"}, + ] + }, f) + + catalog = ExtensionCatalog(project_dir) + + # Fail-closed: entries without URLs should raise, not fall back to defaults + with pytest.raises(ValidationError) as exc_info: + catalog.get_active_catalogs() + assert "none have valid URLs" in str(exc_info.value) # --- _load_catalog_config --- @@ -1943,3 +2093,238 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): assert not (dest / "docs" / "guide.md").exists() assert not (dest / "docs" / "internal.md").exists() assert (dest / "docs" / "api.md").exists() + + +class TestExtensionAddCLI: + """CLI integration tests for extension add command.""" + + def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path): + """extension add by display name should use resolved ID for download_extension().""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns extension by display name + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = None # ID lookup fails + mock_catalog.search.return_value = [ + { + "id": "acme-jira-integration", + "name": "Jira Integration", + "version": "1.0.0", + "description": "Jira integration extension", + "_install_allowed": True, + } + ] + + # Track what ID was passed to download_extension + download_called_with = [] + def mock_download(extension_id): + download_called_with.append(extension_id) + # Return a path that will fail install (we just want to verify the ID) + raise ExtensionError("Mock download - checking ID was resolved") + + mock_catalog.download_extension.side_effect = mock_download + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "Jira Integration"], + catch_exceptions=True, + ) + + assert result.exit_code != 0, ( + f"Expected non-zero exit code since mock download raises, got {result.exit_code}" + ) + + # Verify download_extension was called with the resolved ID, not the display name + assert len(download_called_with) == 1 + assert download_called_with[0] == "acme-jira-integration", ( + f"Expected download_extension to be called with resolved ID 'acme-jira-integration', " + f"but was called with '{download_called_with[0]}'" + ) + + +class TestExtensionUpdateCLI: + """CLI integration tests for extension update command.""" + + @staticmethod + def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path: + """Create a minimal extension source directory for install tests.""" + import yaml + + ext_dir = base_dir / f"test-ext-{version}" + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + "hooks": { + "after_tasks": { + "command": "speckit.test.hello", + "optional": True, + } + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + if include_config: + (ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n") + return ext_dir + + @staticmethod + def _create_catalog_zip(zip_path: Path, version: str): + """Create a minimal ZIP that passes extension_update ID validation.""" + import zipfile + import yaml + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]}, + } + + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("extension.yml", yaml.dump(manifest, sort_keys=False)) + + def test_update_success_preserves_installed_at(self, tmp_path): + """Successful update should keep original installed_at and apply new version.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) + manager.install_from_directory(v1_dir, "0.1.0") + original_installed_at = manager.registry.get("test-ext")["installed_at"] + original_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + def fake_install_from_zip(self_obj, _zip_path, speckit_version): + return self_obj.install_from_directory(v2_dir, speckit_version) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 0, result.output + + updated = ExtensionManager(project_dir).registry.get("test-ext") + assert updated["version"] == "2.0.0" + assert updated["installed_at"] == original_installed_at + restored_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + assert restored_config_content == original_config_content + + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path): + """Failed update should restore original registry, hooks, and command files.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import yaml + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + backup_registry_entry = manager.registry.get("test-ext") + hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + + registered_commands = backup_registry_entry.get("registered_commands", {}) + command_files = [] + registrar = CommandRegistrar() + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_cfg = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_dir / agent_cfg["dir"] + for cmd_name in cmd_names: + cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}" + command_files.append(cmd_path) + + assert command_files, "Expected at least one registered command file" + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}" + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 1, result.output + + restored_entry = ExtensionManager(project_dir).registry.get("test-ext") + assert restored_entry == backup_registry_entry + + hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + assert hooks_after == hooks_before + + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" From d3fc0567434ea1d37fae25c411de1229c2de02fe Mon Sep 17 00:00:00 2001 From: Dhilip Date: Fri, 13 Mar 2026 08:26:01 -0400 Subject: [PATCH 073/321] Add /selftest.extension core extension to test other extensions (#1758) * test(commands): create extension-commands LLM playground sandbox * update(tests): format LLM evaluation as an automated test runner * test(commands): map extension-commands python script with timestamps * test(commands): map extension-commands python script with timestamps * test(commands): update TESTING.md to evaluate discovery, lint, and deploy explicitly * test(commands): simplify execution expectations and add timestamp calculation * fix(tests): address copilot review comments on prompt formatting and relative paths * fix(tests): resolve copilot PR feedback regarding extension schema structure and argparse mutually exclusive groups * feat(extensions): add core selftest utility and migrate away from manual tests sandbox * fix(selftest): update command name array to match spec-kit validation schema * fix(selftest): wrap arguments in quotes to support multi-word extension names * update the command to be more meaningful * fix: if the extension is discovery only, it should not be installable * Address review comments for selftest documentation * address review comments * address review comments * Update extensions/selftest/commands/selftest.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.json | 21 ++++++-- extensions/selftest/commands/selftest.md | 69 ++++++++++++++++++++++++ extensions/selftest/extension.yml | 16 ++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 extensions/selftest/commands/selftest.md create mode 100644 extensions/selftest/extension.yml diff --git a/extensions/catalog.json b/extensions/catalog.json index bdebd83dd4..f06cfe5744 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,21 @@ { "schema_version": "1.0", - "updated_at": "2026-02-03T00:00:00Z", + "updated_at": "2026-03-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", - "extensions": {} -} + "extensions": { + "selftest": { + "name": "Spec Kit Self-Test Utility", + "id": "selftest", + "version": "1.0.0", + "description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", + "tags": [ + "testing", + "core", + "utility" + ] + } + } +} \ No newline at end of file diff --git a/extensions/selftest/commands/selftest.md b/extensions/selftest/commands/selftest.md new file mode 100644 index 0000000000..6f5655edd5 --- /dev/null +++ b/extensions/selftest/commands/selftest.md @@ -0,0 +1,69 @@ +--- +description: "Validate the lifecycle of an extension from the catalog." +--- + +# Extension Self-Test: `$ARGUMENTS` + +This command drives a self-test simulating the developer experience with the `$ARGUMENTS` extension. + +## Goal + +Validate the end-to-end lifecycle (discovery, installation, registration) for the extension: `$ARGUMENTS`. +If `$ARGUMENTS` is empty, you must tell the user to provide an extension name, for example: `/speckit.selftest.extension linear`. + +## Steps + +### Step 1: Catalog Discovery Validation + +Check if the extension exists in the Spec Kit catalog. +Execute this command and verify that it completes successfully and that the returned extension ID exactly matches `$ARGUMENTS`. If the command fails or the ID does not match `$ARGUMENTS`, fail the test. + +```bash +specify extension info "$ARGUMENTS" +``` + +### Step 2: Simulate Installation + +First, try to add the extension to the current workspace configuration directly. If the catalog provides the extension as `install_allowed: false` (discovery-only), this step is *expected* to fail. + +```bash +specify extension add "$ARGUMENTS" +``` + +Then, simulate adding the extension by installing it from its catalog download URL, which should bypass the restriction. +Obtain the extension's `download_url` from the catalog metadata (for example, via a catalog info command or UI), then run: + +```bash +specify extension add "$ARGUMENTS" --from "" +``` + +### Step 3: Registration Verification + +Once the `add` command completes, verify the installation by checking the project configuration. +Use terminal tools (like `cat`) to verify that the following file contains a record for `$ARGUMENTS`. + +```bash +cat .specify/extensions/.registry/$ARGUMENTS.json +``` + +### Step 4: Verification Report + +Analyze the standard output of the three steps. +Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user. + +Example output format: +```text +============================= test session starts ============================== +collected 3 items + +test_selftest_discovery.py::test_catalog_search [PASS/FAIL] + Details: [Provide execution result of specify extension search] + +test_selftest_installation.py::test_extension_add [PASS/FAIL] + Details: [Provide execution result of specify extension add] + +test_selftest_registration.py::test_config_verification [PASS/FAIL] + Details: [Provide execution result of registry record verification] + +============================== [X] passed in ... ============================== +``` diff --git a/extensions/selftest/extension.yml b/extensions/selftest/extension.yml new file mode 100644 index 0000000000..2d47fdf2cf --- /dev/null +++ b/extensions/selftest/extension.yml @@ -0,0 +1,16 @@ +schema_version: "1.0" +extension: + id: selftest + name: Spec Kit Self-Test Utility + version: 1.0.0 + description: Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle. + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT +requires: + speckit_version: ">=0.2.0" +provides: + commands: + - name: speckit.selftest.extension + file: commands/selftest.md + description: Validate the lifecycle of an extension from the catalog. From 976c9981a471b376af035f24b40bc69bdd1979f0 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Fri, 13 Mar 2026 08:35:30 -0400 Subject: [PATCH 074/321] fix(cli): deprecate explicit command support for agy (#1798) (#1808) * fix(cli): deprecate explicit command support for agy (#1798) * docs(cli): add tests and docs for agy deprecation (#1798) * fix: address review comments for agy deprecation * fix: address round 2 review comments for agy deprecation * fix: address round 3 review comments for agy deprecation * fix: address round 4 review comments for agy deprecation * fix: address round 5 review comments for agy deprecation * docs: add inline contextual comments to explain agy deprecation * docs: clarify historical context in agy deprecation docstring * fix: correct skills path in deprecation comment and make test mock fully deterministic --- .../scripts/create-release-packages.ps1 | 2 +- .../scripts/create-release-packages.sh | 4 +- AGENTS.md | 2 +- README.md | 4 +- src/specify_cli/__init__.py | 77 ++++++++++++++----- tests/test_agent_config_consistency.py | 9 ++- tests/test_ai_skills.py | 53 +++++++++++++ 7 files changed, 123 insertions(+), 28 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 8c7d4078f1..fc30708426 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -442,7 +442,7 @@ function Build-Variant { if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') } } 'agy' { - $cmdDir = Join-Path $baseDir ".agent/workflows" + $cmdDir = Join-Path $baseDir ".agent/commands" Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } 'vibe' { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 8be5a0549d..ada3a28993 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -280,8 +280,8 @@ build_variant() { mkdir -p "$base_dir/.kiro/prompts" generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; agy) - mkdir -p "$base_dir/.agent/workflows" - generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;; + mkdir -p "$base_dir/.agent/commands" + generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;; bob) mkdir -p "$base_dir/.bob/commands" generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; diff --git a/AGENTS.md b/AGENTS.md index aa3730222d..561bf257a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ This eliminates the need for special-case mappings throughout the codebase. - `folder`: Directory where agent-specific files are stored (relative to project root) - `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) + - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) - This field enables `--ai-skills` to locate command templates correctly for skill generation - `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `requires_cli`: Whether the agent requires a CLI tool check during initialization diff --git a/README.md b/README.md index 7bda2b9ce6..006d552270 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Kimi Code](https://code.kimi.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | -| [Antigravity (agy)](https://antigravity.google/) | ✅ | | +| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | ## 🔧 Specify CLI Reference @@ -248,7 +248,7 @@ specify init my-project --ai vibe specify init my-project --ai bob # Initialize with Antigravity support -specify init my-project --ai agy +specify init my-project --ai agy --ai-skills # Initialize with an unsupported agent (generic / bring your own agent) specify init my-project --ai generic --ai-commands-dir .myagent/commands/ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 55e97ea9fb..e7781b2b53 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -247,7 +247,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "agy": { "name": "Antigravity", "folder": ".agent/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ + "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, @@ -1270,6 +1270,22 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker return installed_count > 0 or skipped_count > 0 +def _handle_agy_deprecation(console: Console) -> None: + """ + Print the deprecation error for the Antigravity (agy) agent and exit. + + Context for future AI reviewers (e.g., GitHub Copilot): + - Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool. + - v1.20.5 removed this "command support" in favor of "Agent Skills". + - `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is + invoked without the accompanying `--ai-skills` flag. Users are instructed to add + the skills flag to generate agent skills templates instead. + """ + console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.") + console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") + console.print("[yellow]Usage:[/yellow] specify init --ai agy --ai-skills") + raise typer.Exit(1) + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -1379,26 +1395,6 @@ def init( console.print(error_panel) raise typer.Exit(1) - current_dir = Path.cwd() - - setup_lines = [ - "[cyan]Specify Project Setup[/cyan]", - "", - f"{'Project':<15} [green]{project_path.name}[/green]", - f"{'Working Path':<15} [dim]{current_dir}[/dim]", - ] - - if not here: - setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") - - console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) - - should_init_git = False - if not no_git: - should_init_git = check_tool("git") - if not should_init_git: - console.print("[yellow]Git not found - will skip repository initialization[/yellow]") - if ai_assistant: if ai_assistant not in AGENT_CONFIG: console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") @@ -1413,6 +1409,25 @@ def init( "copilot" ) + # [DEPRECATION NOTICE: Antigravity (agy)] + # As of Antigravity v1.20.5, traditional CLI "command" support was fully removed + # in favor of "Agent Skills" (SKILL.md files under /skills//). + # Because 'specify_cli' historically populated .agent/commands/, we now must explicitly + # enforce the `--ai-skills` flag for `agy` to ensure valid template generation. + if selected_ai == "agy" and not ai_skills: + # If agy was selected interactively (no --ai provided), automatically enable + # ai_skills so the agent remains usable without requiring an extra flag. + # Preserve deprecation behavior only for explicit '--ai agy' without skills. + if ai_assistant: + _handle_agy_deprecation(console) + else: + ai_skills = True + console.print( + "\n[yellow]Note:[/yellow] 'agy' was selected interactively; " + "enabling [cyan]--ai-skills[/cyan] automatically for compatibility " + "(explicit .agent/commands usage is deprecated)." + ) + # Validate --ai-commands-dir usage if selected_ai == "generic": if not ai_commands_dir: @@ -1423,6 +1438,26 @@ def init( console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") raise typer.Exit(1) + current_dir = Path.cwd() + + setup_lines = [ + "[cyan]Specify Project Setup[/cyan]", + "", + f"{'Project':<15} [green]{project_path.name}[/green]", + f"{'Working Path':<15} [dim]{current_dir}[/dim]", + ] + + if not here: + setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") + + console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) + + should_init_git = False + if not no_git: + should_init_git = check_tool("git") + if not should_init_git: + console.print("[yellow]Git not found - will skip repository initialization[/yellow]") + if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index d77c6f10d1..6831fad360 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -62,7 +62,14 @@ def test_release_ps_switch_has_shai_and_agy_generation(self): ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None - assert re.search(r"'agy'\s*\{.*?\.agent/workflows", ps_text, re.S) is not None + assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None + + def test_release_sh_switch_has_shai_and_agy_generation(self): + """Bash release builder must generate files for shai and agy agents.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + + assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None + assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None def test_init_ai_help_includes_roo_and_kiro_alias(self): """CLI help text for --ai should stay in sync with agent config and alias guidance.""" diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e9bd71d057..3c50cd502a 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -661,6 +661,59 @@ def test_ai_skills_without_ai_shows_usage(self): assert "Usage:" in result.output assert "--ai" in result.output + def test_agy_without_ai_skills_fails(self): + """--ai agy without --ai-skills should fail with exit code 1.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"]) + + assert result.exit_code == 1 + assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output + assert "--ai-skills" in result.output + + def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): + """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" + from typer.testing import CliRunner + + # Mock select_with_arrows to simulate the user picking 'agy' for AI, + # and return a deterministic default for any other prompts to avoid + # calling the real interactive implementation. + def _fake_select_with_arrows(*args, **kwargs): + options = kwargs.get("options") + if options is None and len(args) >= 1: + options = args[0] + + # If the options include 'agy', simulate selecting it. + if isinstance(options, dict) and "agy" in options: + return "agy" + if isinstance(options, (list, tuple)) and "agy" in options: + return "agy" + + # For any other prompt, return a deterministic, non-interactive default: + # pick the first option if available. + if isinstance(options, dict) and options: + return next(iter(options.keys())) + if isinstance(options, (list, tuple)) and options: + return options[0] + + # If no options are provided, fall back to None (should not occur in normal use). + return None + + monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) + + # Mock download_and_extract_template to prevent real HTTP downloads during testing + monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe? + runner = CliRunner() + # Create temp dir to avoid directory already exists errors or whatever + with runner.isolated_filesystem(): + result = runner.invoke(app, ["init", "test-proj", "--no-git"]) + + # Interactive selection should NOT raise the deprecation error! + assert result.exit_code == 0 + assert "Explicit command support was deprecated" not in result.output + def test_ai_skills_flag_appears_in_help(self): """--ai-skills should appear in init --help output.""" from typer.testing import CliRunner From 7562664fd123803018f9b0b4dc164b755e5f525d Mon Sep 17 00:00:00 2001 From: fuyongde Date: Fri, 13 Mar 2026 20:43:14 +0800 Subject: [PATCH 075/321] fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730) * fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) Qwen Code CLI v0.10.0 deprecated TOML format and fully switched to Markdown as the core format for configuration and interaction files. - Update create-release-packages.sh: generate .md files with $ARGUMENTS instead of .toml files with {{args}} for qwen agent - Update create-release-packages.ps1: same change for PowerShell script - Update AGENTS.md: reflect Qwen's new Markdown format in docs and remove Qwen from TOML format section - Update tests/test_ai_skills.py: add commands_dir_qwen fixture and tests covering Markdown-format skills installation for Qwen Co-Authored-By: Claude Sonnet 4.6 * fix: update CommandRegistrar qwen config to Markdown format extensions.py CommandRegistrar.AGENT_CONFIGS['qwen'] was still set to TOML format, causing `specify extension` to write .toml files into .qwen/commands, conflicting with Qwen Code CLI v0.10.0+ expectations. - Change qwen format from toml to markdown - Change qwen args from {{args}} to $ARGUMENTS - Change qwen extension from .toml to .md - Add test to assert qwen config is Markdown format Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../scripts/create-release-packages.ps1 | 2 +- .../scripts/create-release-packages.sh | 2 +- AGENTS.md | 6 +-- src/specify_cli/extensions.py | 6 +-- tests/test_ai_skills.py | 41 +++++++++++++++++++ tests/test_extensions.py | 9 ++++ 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index fc30708426..60ad3da97c 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -382,7 +382,7 @@ function Build-Variant { } 'qwen' { $cmdDir = Join-Path $baseDir ".qwen/commands" - Generate-Commands -Agent 'qwen' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script + Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script if (Test-Path "agent_templates/qwen/QWEN.md") { Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") } diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index ada3a28993..620da02337 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -240,7 +240,7 @@ build_variant() { generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;; qwen) mkdir -p "$base_dir/.qwen/commands" - generate_commands qwen toml "{{args}}" "$base_dir/.qwen/commands" "$script" + generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script" [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;; opencode) mkdir -p "$base_dir/.opencode/command" diff --git a/AGENTS.md b/AGENTS.md index 561bf257a6..82b444b14a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | | **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | | **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | -| **Qwen Code** | `.qwen/commands/` | TOML | `qwen` | Alibaba's Qwen Code CLI | +| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | | **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI | | **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | @@ -339,7 +339,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code +Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen **Standard format:** @@ -364,7 +364,7 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Qwen, Tabnine +Used by: Gemini, Tabnine ```toml description = "Command description" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index fa9766b003..156daff6ad 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -748,9 +748,9 @@ class CommandRegistrar: }, "qwen": { "dir": ".qwen/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" }, "opencode": { "dir": ".opencode/command", diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 3c50cd502a..45d45cc4a8 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -132,6 +132,16 @@ def commands_dir_gemini(project_dir): return cmd_dir +@pytest.fixture +def commands_dir_qwen(project_dir): + """Create a populated .qwen/commands directory (Markdown format).""" + cmd_dir = project_dir / ".qwen" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: + (cmd_dir / name).write_text(f"# {name}\nContent here\n") + return cmd_dir + + # ===== _get_skills_dir Tests ===== class TestGetSkillsDir: @@ -390,6 +400,28 @@ def test_non_md_commands_dir_falls_back(self, project_dir): # .toml commands should be untouched assert (cmds_dir / "speckit.specify.toml").exists() + def test_qwen_md_commands_dir_installs_skills(self, project_dir): + """Qwen now uses Markdown format; skills should install directly from .qwen/commands/.""" + cmds_dir = project_dir / ".qwen" / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text( + "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" + ) + (cmds_dir / "speckit.plan.md").write_text( + "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" + ) + + result = install_ai_skills(project_dir, "qwen") + + assert result is True + skills_dir = project_dir / ".qwen" / "skills" + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert len(skill_dirs) >= 1 + # .md commands should be untouched + assert (cmds_dir / "speckit.specify.md").exists() + assert (cmds_dir / "speckit.plan.md").exists() + @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) def test_skills_install_for_all_agents(self, temp_dir, agent_key): """install_ai_skills should produce skills for every configured agent.""" @@ -446,6 +478,15 @@ def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, co remaining = list(commands_dir_gemini.glob("speckit.*")) assert len(remaining) == 3 + def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen): + """install_ai_skills must NOT remove pre-existing .qwen/commands files.""" + assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3 + + install_ai_skills(project_dir, "qwen") + + remaining = list(commands_dir_qwen.glob("speckit.*")) + assert len(remaining) == 3 + def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): """install_ai_skills must not remove the commands directory.""" install_ai_skills(project_dir, "claude") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4c098c255e..6299abbb8b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -541,6 +541,15 @@ def test_codex_agent_config_present(self): assert "codex" in CommandRegistrar.AGENT_CONFIGS assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_qwen_agent_config_is_markdown(self): + """Qwen should use Markdown format with $ARGUMENTS (not TOML).""" + assert "qwen" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["qwen"] + assert cfg["dir"] == ".qwen/commands" + assert cfg["format"] == "markdown" + assert cfg["args"] == "$ARGUMENTS" + assert cfg["extension"] == ".md" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- From 017e1c4c2fb68485714f56b97de1af4e81808f3b Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 13 Mar 2026 14:21:55 +0100 Subject: [PATCH 076/321] fix: clean up command templates (specify, analyze) (#1810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: clean up command templates (specify, analyze) - specify.md: fix self-referential step number (step 6c → proceed to step 7) - specify.md: remove empty "General Guidelines" heading before "Quick Guidelines" - analyze.md: deduplicate {ARGS} — already present in "User Input" section at top * fix: restore ## Context heading in analyze template Address PR review feedback from @dhilipkumars: keep the ## Context markdown heading to preserve structural hierarchy for LLM parsing. --- templates/commands/specify.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index d66f3fcca0..0713b68e4f 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -129,7 +129,7 @@ Given that feature description, do this: c. **Handle Validation Results**: - - **If all items pass**: Mark checklist complete and proceed to step 6 + - **If all items pass**: Mark checklist complete and proceed to step 7 - **If items fail (excluding [NEEDS CLARIFICATION])**: 1. List the failing items and specific issues @@ -178,8 +178,6 @@ Given that feature description, do this: **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. -## General Guidelines - ## Quick Guidelines - Focus on **WHAT** users need and **WHY**. From 46bc65b1cefc487e507aa7b809ce4e48b96ba4ec Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 13 Mar 2026 16:47:17 +0100 Subject: [PATCH 077/321] fix: harden bash scripts against shell injection and improve robustness (#1809) - Replace eval of unquoted get_feature_paths output with safe pattern: capture into variable, check return code, then eval quoted result - Use printf '%q' in get_feature_paths to safely emit shell assignments, preventing injection via paths containing quotes or metacharacters - Add json_escape() helper for printf JSON fallback paths, handling backslash, double-quote, and control characters when jq is unavailable - Use jq -cn for safe JSON construction with proper escaping when available, with printf + json_escape() fallback - Replace declare -A (bash 4+) with indexed array for bash 3.2 compatibility (macOS default) - Use inline command -v jq check in create-new-feature.sh since it does not source common.sh - Guard trap cleanup against re-entrant invocation by disarming traps at entry - Use printf '%q' for shell-escaped branch names in user-facing output - Return failure instead of silently returning wrong path on ambiguous spec directory matches - Deduplicate agent file updates via realpath to prevent multiple writes to the same file (e.g. AGENTS.md aliased by multiple variables) --- scripts/bash/check-prerequisites.sh | 42 ++++-- scripts/bash/common.sh | 53 +++++--- scripts/bash/create-new-feature.sh | 27 +++- scripts/bash/setup-plan.sh | 18 ++- scripts/bash/update-agent-context.sh | 191 ++++++++++----------------- 5 files changed, 180 insertions(+), 151 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 98e387c271..6f7c99e038 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -79,15 +79,28 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get feature paths and validate branch -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 # If paths-only mode, output paths and exit (support JSON + paths-only combined) if $PATHS_ONLY; then if $JSON_MODE; then # Minimal JSON paths payload (no validation performed) - printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ - "$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS" + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi else echo "REPO_ROOT: $REPO_ROOT" echo "BRANCH: $CURRENT_BRANCH" @@ -141,14 +154,25 @@ fi # Output results if $JSON_MODE; then # Build JSON array of documents - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' else - json_docs=$(printf '"%s",' "${docs[@]}") - json_docs="[${json_docs%,}]" + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '"%s",' "${docs[@]}") + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" fi - - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" else # Text output echo "FEATURE_DIR:$FEATURE_DIR" diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..7161f43b50 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -120,7 +120,7 @@ find_feature_dir_by_prefix() { # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2 - echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + return 1 fi } @@ -134,21 +134,42 @@ get_feature_paths() { fi # Use prefix-based lookup to support multiple branches per spec - local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch") - - cat <&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and control characters (newline, tab, carriage return). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" } check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54697024d6..725f84c852 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -162,6 +162,17 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -300,14 +311,22 @@ TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" SPEC_FILE="$FEATURE_DIR/spec.md" if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi -# Set the SPECIFY_FEATURE environment variable for the current session -export SPECIFY_FEATURE="$BRANCH_NAME" +# Inform the user how to persist the feature variable in their own shell +printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" + if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" - echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index d01c6d6cb5..60cf372cbd 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -28,7 +28,9 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get all paths and variables from common functions -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output # Check if we're on a proper feature branch (only for git repos) check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 @@ -49,8 +51,18 @@ fi # Output results if $JSON_MODE; then - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ - "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT" + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi else echo "FEATURE_SPEC: $FEATURE_SPEC" echo "IMPL_PLAN: $IMPL_PLAN" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b0022fd45e..341e4e6879 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -53,7 +53,9 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get all paths and variables from common functions -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code AGENT_TYPE="${1:-}" @@ -71,12 +73,14 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -AMP_FILE="$REPO_ROOT/AGENTS.md" +# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid +# updating the same file multiple times. +AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$REPO_ROOT/AGENTS.md" +KIRO_FILE="$AGENTS_FILE" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$REPO_ROOT/AGENTS.md" +BOB_FILE="$AGENTS_FILE" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" @@ -112,6 +116,8 @@ log_warning() { # Cleanup function for temporary files cleanup() { local exit_code=$? + # Disarm traps to prevent re-entrant loop + trap - EXIT INT TERM rm -f /tmp/agent_update_*_$$ rm -f /tmp/manual_additions_$$ exit $exit_code @@ -607,67 +613,67 @@ update_specific_agent() { case "$agent_type" in claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 ;; gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" + update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 ;; copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" + update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 ;; cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" + update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 ;; qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" + update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 ;; opencode) - update_agent_file "$AGENTS_FILE" "opencode" + update_agent_file "$AGENTS_FILE" "opencode" || return 1 ;; codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" + update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 ;; windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" + update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 ;; kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" + update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 ;; auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" + update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 ;; roo) - update_agent_file "$ROO_FILE" "Roo Code" + update_agent_file "$ROO_FILE" "Roo Code" || return 1 ;; codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 ;; qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" + update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 ;; amp) - update_agent_file "$AMP_FILE" "Amp" + update_agent_file "$AMP_FILE" "Amp" || return 1 ;; shai) - update_agent_file "$SHAI_FILE" "SHAI" + update_agent_file "$SHAI_FILE" "SHAI" || return 1 ;; tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" + update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 ;; kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" + update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 ;; agy) - update_agent_file "$AGY_FILE" "Antigravity" + update_agent_file "$AGY_FILE" "Antigravity" || return 1 ;; bob) - update_agent_file "$BOB_FILE" "IBM Bob" + update_agent_file "$BOB_FILE" "IBM Bob" || return 1 ;; vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" + update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 ;; kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" + update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." @@ -682,106 +688,53 @@ update_specific_agent() { update_all_existing_agents() { local found_agent=false - - # Check each possible agent file and update if it exists - if [[ -f "$CLAUDE_FILE" ]]; then - update_agent_file "$CLAUDE_FILE" "Claude Code" - found_agent=true - fi - - if [[ -f "$GEMINI_FILE" ]]; then - update_agent_file "$GEMINI_FILE" "Gemini CLI" - found_agent=true - fi - - if [[ -f "$COPILOT_FILE" ]]; then - update_agent_file "$COPILOT_FILE" "GitHub Copilot" - found_agent=true - fi - - if [[ -f "$CURSOR_FILE" ]]; then - update_agent_file "$CURSOR_FILE" "Cursor IDE" - found_agent=true - fi - - if [[ -f "$QWEN_FILE" ]]; then - update_agent_file "$QWEN_FILE" "Qwen Code" - found_agent=true - fi - - if [[ -f "$AGENTS_FILE" ]]; then - update_agent_file "$AGENTS_FILE" "Codex/opencode" - found_agent=true - fi - - if [[ -f "$WINDSURF_FILE" ]]; then - update_agent_file "$WINDSURF_FILE" "Windsurf" - found_agent=true - fi - - if [[ -f "$KILOCODE_FILE" ]]; then - update_agent_file "$KILOCODE_FILE" "Kilo Code" - found_agent=true - fi - - if [[ -f "$AUGGIE_FILE" ]]; then - update_agent_file "$AUGGIE_FILE" "Auggie CLI" - found_agent=true - fi - - if [[ -f "$ROO_FILE" ]]; then - update_agent_file "$ROO_FILE" "Roo Code" - found_agent=true - fi - - if [[ -f "$CODEBUDDY_FILE" ]]; then - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" - found_agent=true - fi - - if [[ -f "$SHAI_FILE" ]]; then - update_agent_file "$SHAI_FILE" "SHAI" - found_agent=true - fi - - if [[ -f "$TABNINE_FILE" ]]; then - update_agent_file "$TABNINE_FILE" "Tabnine CLI" - found_agent=true - fi - - if [[ -f "$QODER_FILE" ]]; then - update_agent_file "$QODER_FILE" "Qoder CLI" - found_agent=true - fi - - if [[ -f "$KIRO_FILE" ]]; then - update_agent_file "$KIRO_FILE" "Kiro CLI" + local _updated_paths=() + + # Helper: skip non-existent files and files already updated (dedup by + # realpath so that variables pointing to the same file — e.g. AMP_FILE, + # KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). + # Uses a linear array instead of associative array for bash 3.2 compatibility. + update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + update_agent_file "$file" "$name" || return 1 + _updated_paths+=("$real_path") found_agent=true - fi + } - if [[ -f "$AGY_FILE" ]]; then - update_agent_file "$AGY_FILE" "Antigravity" - found_agent=true - fi - if [[ -f "$BOB_FILE" ]]; then - update_agent_file "$BOB_FILE" "IBM Bob" - found_agent=true - fi + update_if_new "$CLAUDE_FILE" "Claude Code" + update_if_new "$GEMINI_FILE" "Gemini CLI" + update_if_new "$COPILOT_FILE" "GitHub Copilot" + update_if_new "$CURSOR_FILE" "Cursor IDE" + update_if_new "$QWEN_FILE" "Qwen Code" + update_if_new "$AGENTS_FILE" "Codex/opencode" + update_if_new "$AMP_FILE" "Amp" + update_if_new "$KIRO_FILE" "Kiro CLI" + update_if_new "$BOB_FILE" "IBM Bob" + update_if_new "$WINDSURF_FILE" "Windsurf" + update_if_new "$KILOCODE_FILE" "Kilo Code" + update_if_new "$AUGGIE_FILE" "Auggie CLI" + update_if_new "$ROO_FILE" "Roo Code" + update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" + update_if_new "$SHAI_FILE" "SHAI" + update_if_new "$TABNINE_FILE" "Tabnine CLI" + update_if_new "$QODER_FILE" "Qoder CLI" + update_if_new "$AGY_FILE" "Antigravity" + update_if_new "$VIBE_FILE" "Mistral Vibe" + update_if_new "$KIMI_FILE" "Kimi Code" - if [[ -f "$VIBE_FILE" ]]; then - update_agent_file "$VIBE_FILE" "Mistral Vibe" - found_agent=true - fi - - if [[ -f "$KIMI_FILE" ]]; then - update_agent_file "$KIMI_FILE" "Kimi Code" - found_agent=true - fi - # If no agent files exist, create a default Claude file if [[ "$found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 fi } print_summary() { From b9c1a1c7bb8f3ff5cd2c575b199e0e676a437e6b Mon Sep 17 00:00:00 2001 From: KhawarHabibKhan <132604863+KhawarHabibKhan@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:50:07 +0500 Subject: [PATCH 078/321] Add specify doctor command for project health diagnostics (#1828) * Add specify doctor command for project health diagnostics * Add tests for specify doctor command * Document specify doctor command in README * Revert "Document specify doctor command in README" This reverts commit c1cfd061293ac5c82acb11d8dcbd07d993ce6b48. * Revert "Add tests for specify doctor command" This reverts commit 65e12fb62b7f3611a6598ec41a59c8bf681fe607. * Revert "Add specify doctor command for project health diagnostics" This reverts commit d5bd93248ae05c31ad2ad012983c0f87956dc417. * Add doctor extension to community catalog * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 33 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index e8f1617e98..4c3f9d8011 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,6 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | +| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 759bd10d81..f1e0a09271 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-13T12:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "azure-devops": { @@ -74,6 +74,37 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "doctor": { + "name": "Project Health Check", + "id": "doctor", + "description": "Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-doctor/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "diagnostics", + "health-check", + "validation", + "project-structure" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-13T00:00:00Z" + }, "fleet": { "name": "Fleet Orchestrator", "id": "fleet", From c883952b43e0e322e410bb66d86c9826eaf8d245 Mon Sep 17 00:00:00 2001 From: eason <85663565+mango766@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:46:21 +0800 Subject: [PATCH 079/321] fix: match 'Last updated' timestamp with or without bold markers (#1836) The template outputs plain text `Last updated: [DATE]` but both update-agent-context scripts only matched `**Last updated**: [DATE]` (bold Markdown). Make the bold markers optional in the regex so the timestamp is refreshed regardless of formatting. Co-authored-by: easonysliu Co-authored-by: Claude (claude-opus-4-6) --- scripts/bash/update-agent-context.sh | 2 +- scripts/powershell/update-agent-context.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 341e4e6879..e0f2854846 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -482,7 +482,7 @@ update_existing_agent_file() { fi # Update timestamp - if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" else echo "$line" >> "$temp_file" diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 95e636ba1d..30e1e0e693 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -331,7 +331,7 @@ function Update-ExistingAgentFile { if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } continue } - if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') { + if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) continue } From 69ee7a836e25f5a732e3099d0c904d9f8b9ba4ac Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:09:14 -0500 Subject: [PATCH 080/321] 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> --- AGENTS.md | 4 - CHANGELOG.md | 28 +- presets/ARCHITECTURE.md | 157 ++ presets/PUBLISHING.md | 295 +++ presets/README.md | 115 ++ presets/catalog.community.json | 6 + presets/catalog.json | 6 + presets/scaffold/README.md | 46 + .../commands/speckit.myext.myextcmd.md | 20 + presets/scaffold/commands/speckit.specify.md | 23 + presets/scaffold/preset.yml | 91 + presets/scaffold/templates/myext-template.md | 24 + presets/scaffold/templates/spec-template.md | 18 + presets/self-test/commands/speckit.specify.md | 15 + presets/self-test/preset.yml | 61 + .../templates/agent-file-template.md | 9 + .../self-test/templates/checklist-template.md | 15 + .../templates/constitution-template.md | 15 + presets/self-test/templates/plan-template.md | 22 + presets/self-test/templates/spec-template.md | 23 + presets/self-test/templates/tasks-template.md | 17 + scripts/bash/common.sh | 76 + scripts/bash/create-new-feature.sh | 5 +- scripts/bash/setup-plan.sh | 6 +- scripts/powershell/common.ps1 | 67 + scripts/powershell/create-new-feature.ps1 | 7 +- scripts/powershell/setup-plan.ps1 | 6 +- src/specify_cli/__init__.py | 576 +++++- src/specify_cli/agents.py | 422 ++++ src/specify_cli/extensions.py | 432 +---- src/specify_cli/presets.py | 1530 +++++++++++++++ tests/test_presets.py | 1712 +++++++++++++++++ 32 files changed, 5446 insertions(+), 403 deletions(-) create mode 100644 presets/ARCHITECTURE.md create mode 100644 presets/PUBLISHING.md create mode 100644 presets/README.md create mode 100644 presets/catalog.community.json create mode 100644 presets/catalog.json create mode 100644 presets/scaffold/README.md create mode 100644 presets/scaffold/commands/speckit.myext.myextcmd.md create mode 100644 presets/scaffold/commands/speckit.specify.md create mode 100644 presets/scaffold/preset.yml create mode 100644 presets/scaffold/templates/myext-template.md create mode 100644 presets/scaffold/templates/spec-template.md create mode 100644 presets/self-test/commands/speckit.specify.md create mode 100644 presets/self-test/preset.yml create mode 100644 presets/self-test/templates/agent-file-template.md create mode 100644 presets/self-test/templates/checklist-template.md create mode 100644 presets/self-test/templates/constitution-template.md create mode 100644 presets/self-test/templates/plan-template.md create mode 100644 presets/self-test/templates/spec-template.md create mode 100644 presets/self-test/templates/tasks-template.md create mode 100644 src/specify_cli/agents.py create mode 100644 src/specify_cli/presets.py create mode 100644 tests/test_presets.py diff --git a/AGENTS.md b/AGENTS.md index 82b444b14a..8f0742eb8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,6 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## General practices - -- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`. - ## Adding New Agent Support This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 536b808473..89918e4c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat(presets): Pluggable preset system with preset catalog and template resolver +- Preset manifest (`preset.yml`) with validation for artifact, command, and script types +- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` +- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info` +- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management +- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system +- `--preset` option for `specify init` to install presets during initialization +- Priority-based preset resolution: presets with lower priority number win (`--priority` flag) +- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts +- Template resolution priority stack: overrides → presets → extensions → core +- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) +- Preset scaffold directory (`presets/scaffold/`) +- Scripts updated to use template resolution instead of hardcoded paths +- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init +- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations +- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) + ## [0.2.1] - 2026-03-11 ### Changed @@ -51,13 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) - -## [Unreleased] - -### Added - -- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) - ## [0.2.0] - 2026-03-09 ### Changed diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md new file mode 100644 index 0000000000..d0e6547816 --- /dev/null +++ b/presets/ARCHITECTURE.md @@ -0,0 +1,157 @@ +# Preset System Architecture + +This document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood. + +For usage instructions, see [README.md](README.md). + +## Template Resolution + +When Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match: + +```mermaid +flowchart TD + A["resolve_template('spec-template')"] --> B{Override exists?} + B -- Yes --> C[".specify/templates/overrides/spec-template.md"] + B -- No --> D{Preset provides it?} + D -- Yes --> E[".specify/presets/‹preset-id›/templates/spec-template.md"] + D -- No --> F{Extension provides it?} + F -- Yes --> G[".specify/extensions/‹ext-id›/templates/spec-template.md"] + F -- No --> H[".specify/templates/spec-template.md"] + + E -- "multiple presets?" --> I["lowest priority number wins"] + I --> E + + style C fill:#4caf50,color:#fff + style E fill:#2196f3,color:#fff + style G fill:#ff9800,color:#fff + style H fill:#9e9e9e,color:#fff +``` + +| Priority | Source | Path | Use case | +|----------|--------|------|----------| +| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks | +| 2 | Preset | `.specify/presets//templates/` | Shareable, stackable customizations | +| 3 | Extension | `.specify/extensions//templates/` | Extension-provided templates | +| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults | + +When multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`. + +The resolution is implemented three times to ensure consistency: +- **Python**: `PresetResolver` in `src/specify_cli/presets.py` +- **Bash**: `resolve_template()` in `scripts/bash/common.sh` +- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` + +## Command Registration + +When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. + +```mermaid +flowchart TD + A["specify preset add my-preset"] --> B{Preset has type: command?} + B -- No --> Z["done (templates only)"] + B -- Yes --> C{Extension command?} + C -- "speckit.myext.cmd\n(3+ dot segments)" --> D{Extension installed?} + D -- No --> E["skip (extension not active)"] + D -- Yes --> F["register command"] + C -- "speckit.specify\n(core command)" --> F + F --> G["detect agent directories"] + G --> H[".claude/commands/"] + G --> I[".gemini/commands/"] + G --> J[".github/agents/"] + G --> K["... (17+ agents)"] + H --> L["write .md (Markdown format)"] + I --> M["write .toml (TOML format)"] + J --> N["write .agent.md + .prompt.md"] + + style E fill:#ff5722,color:#fff + style L fill:#4caf50,color:#fff + style M fill:#4caf50,color:#fff + style N fill:#4caf50,color:#fff +``` + +### Extension safety check + +Command names follow the pattern `speckit..`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions//` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions. + +Core commands (e.g. `speckit.specify`, with only 2 segments) are always registered. + +### Agent format rendering + +The `CommandRegistrar` renders commands differently per agent: + +| Agent | Format | Extension | Arg placeholder | +|-------|--------|-----------|-----------------| +| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` | +| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` | +| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` | + +### Cleanup on removal + +When `specify preset remove` is called, the registered commands are read from the registry metadata and the corresponding files are deleted from each agent directory, including Copilot companion `.prompt.md` files. + +## Catalog System + +```mermaid +flowchart TD + A["specify preset search"] --> B["PresetCatalog.get_active_catalogs()"] + B --> C{SPECKIT_PRESET_CATALOG_URL set?} + C -- Yes --> D["single custom catalog"] + C -- No --> E{.specify/preset-catalogs.yml exists?} + E -- Yes --> F["project-level catalog stack"] + E -- No --> G{"~/.specify/preset-catalogs.yml exists?"} + G -- Yes --> H["user-level catalog stack"] + G -- No --> I["built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. + +## Repository Layout + +``` +presets/ +├── ARCHITECTURE.md # This file +├── PUBLISHING.md # Guide for submitting presets to the catalog +├── README.md # User guide +├── catalog.json # Official preset catalog +├── catalog.community.json # Community preset catalog +├── scaffold/ # Scaffold for creating new presets +│ ├── preset.yml # Example manifest +│ ├── README.md # Guide for customizing the scaffold +│ ├── commands/ +│ │ ├── speckit.specify.md # Core command override example +│ │ └── speckit.myext.myextcmd.md # Extension command override example +│ └── templates/ +│ ├── spec-template.md # Core template override example +│ └── myext-template.md # Extension template override example +└── self-test/ # Self-test preset (overrides all core templates) + ├── preset.yml + ├── commands/ + │ └── speckit.specify.md + └── templates/ + ├── spec-template.md + ├── plan-template.md + ├── tasks-template.md + ├── checklist-template.md + ├── constitution-template.md + └── agent-file-template.md +``` + +## Module Structure + +``` +src/specify_cli/ +├── agents.py # CommandRegistrar — shared infrastructure for writing +│ # command files to agent directories +├── presets.py # PresetManifest, PresetRegistry, PresetManager, +│ # PresetCatalog, PresetCatalogEntry, PresetResolver +└── __init__.py # CLI commands: specify preset list/add/remove/search/ + # resolve/info, specify preset catalog list/add/remove +``` diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md new file mode 100644 index 0000000000..5e91c4b786 --- /dev/null +++ b/presets/PUBLISHING.md @@ -0,0 +1,295 @@ +# Preset Publishing Guide + +This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Preset](#prepare-your-preset) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing a preset, ensure you have: + +1. **Valid Preset**: A working preset with a valid `preset.yml` manifest +2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description and usage instructions +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning (e.g., 1.0.0) +6. **Testing**: Preset tested on real projects with `specify preset add --dev` + +--- + +## Prepare Your Preset + +### 1. Preset Structure + +Ensure your preset follows the standard structure: + +```text +your-preset/ +├── preset.yml # Required: Preset manifest +├── README.md # Required: Documentation +├── LICENSE # Required: License file +├── CHANGELOG.md # Recommended: Version history +│ +├── templates/ # Template overrides +│ ├── spec-template.md +│ ├── plan-template.md +│ └── ... +│ +└── commands/ # Command overrides (optional) + └── speckit.specify.md +``` + +Start from the [scaffold](scaffold/) if you're creating a new preset. + +### 2. preset.yml Validation + +Verify your manifest is valid: + +```yaml +schema_version: "1.0" + +preset: + id: "your-preset" # Unique lowercase-hyphenated ID + name: "Your Preset Name" # Human-readable name + version: "1.0.0" # Semantic version + description: "Brief description (one sentence)" + author: "Your Name or Organization" + repository: "https://github.com/your-org/spec-kit-preset-your-preset" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" # Required spec-kit version + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom spec template" + replaces: "spec-template" + +tags: # 2-5 relevant tags + - "category" + - "workflow" +``` + +**Validation Checklist**: + +- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters) +- ✅ `version` follows semantic versioning (X.Y.Z) +- ✅ `description` is concise (under 200 characters) +- ✅ `repository` URL is valid and public +- ✅ All template and command files exist in the preset directory +- ✅ Template names are lowercase with hyphens only +- ✅ Command names use dot notation (e.g. `speckit.specify`) +- ✅ Tags are lowercase and descriptive + +### 3. Test Locally + +```bash +# Install from local directory +specify preset add --dev /path/to/your-preset + +# Verify templates resolve from your preset +specify preset resolve spec-template + +# Verify preset info +specify preset info your-preset + +# List installed presets +specify preset list + +# Remove when done testing +specify preset remove your-preset +``` + +If your preset includes command overrides, verify they appear in the agent directories: + +```bash +# Check Claude commands (if using Claude) +ls .claude/commands/speckit.*.md + +# Check Copilot commands (if using Copilot) +ls .github/agents/speckit.*.agent.md + +# Check Gemini commands (if using Gemini) +ls .gemini/commands/speckit.*.toml +``` + +### 4. Create GitHub Release + +Create a GitHub release for your preset version: + +```bash +# Tag the release +git tag v1.0.0 +git push origin v1.0.0 +``` + +The release archive URL will be: + +```text +https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +### 5. Test Installation from Archive + +```bash +specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified presets (install allowed by default) +- **`catalog.community.json`** — Community-contributed presets (discovery only by default) + +All community presets should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Preset to Community Catalog + +Edit `presets/catalog.community.json` and add your preset. + +> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-03-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": { + "your-preset": { + "name": "Your Preset Name", + "description": "Brief description of what your preset provides", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-preset-your-preset", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 3, + "commands": 1 + }, + "tags": [ + "category", + "workflow" + ], + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-10T00:00:00Z" + } + } +} +``` + +### 3. Submit Pull Request + +```bash +git checkout -b add-your-preset +git add presets/catalog.community.json +git commit -m "Add your-preset to community catalog + +- Preset ID: your-preset +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-preset +``` + +**Pull Request Checklist**: + +```markdown +## Preset Submission + +**Preset Name**: Your Preset Name +**Preset ID**: your-preset +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-preset-your-preset + +### Checklist +- [ ] Valid preset.yml manifest +- [ ] README.md with description and usage +- [ ] LICENSE file included +- [ ] GitHub release created +- [ ] Preset tested with `specify preset add --dev` +- [ ] Templates resolve correctly (`specify preset resolve`) +- [ ] Commands register to agent directories (if applicable) +- [ ] Commands match template sections (command + template are coherent) +- [ ] Added to presets/catalog.community.json +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Manifest validation** — valid `preset.yml`, all files exist +2. **Template quality** — templates are useful and well-structured +3. **Command coherence** — commands reference sections that exist in templates +4. **Security** — no malicious content, safe file operations +5. **Documentation** — clear README explaining what the preset does + +Once verified, `verified: true` is set and the preset appears in `specify preset search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `preset.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json` + +--- + +## Best Practices + +### Template Design + +- **Keep sections clear** — use headings and placeholder text the LLM can replace +- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template +- **Document customization points** — use HTML comments to guide users on what to change + +### Naming + +- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean` +- Avoid generic names: `my-preset`, `custom`, `test` + +### Stacking + +- Design presets to work well when stacked with others +- Only override templates you need to change +- Document which templates and commands your preset modifies + +### Command Overrides + +- Only override commands when the workflow needs to change, not just the output format +- If you only need different template sections, a template override is sufficient +- Test command overrides with multiple agents (Claude, Gemini, Copilot) diff --git a/presets/README.md b/presets/README.md new file mode 100644 index 0000000000..2fb22a71f0 --- /dev/null +++ b/presets/README.md @@ -0,0 +1,115 @@ +# Presets + +Presets are stackable, priority-ordered collections of template and command overrides for Spec Kit. They let you customize both the artifacts produced by the Spec-Driven Development workflow (specs, plans, tasks, checklists, constitutions) and the commands that guide the LLM in creating them — without forking or modifying core files. + +## How It Works + +When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack: + +1. `.specify/templates/overrides/` — project-local one-off overrides +2. `.specify/presets//templates/` — installed presets (sorted by priority) +3. `.specify/extensions//templates/` — extension-provided templates +4. `.specify/templates/` — core templates shipped with Spec Kit + +If no preset is installed, core templates are used — exactly the same behavior as before presets existed. + +For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Command Overrides + +Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions). + +When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up. + +## Quick Start + +```bash +# Search available presets +specify preset search + +# Install a preset from the catalog +specify preset add healthcare-compliance + +# Install from a local directory (for development) +specify preset add --dev ./my-preset + +# Install with a specific priority (lower = higher precedence) +specify preset add healthcare-compliance --priority 5 + +# List installed presets +specify preset list + +# See which template a name resolves to +specify preset resolve spec-template + +# Get detailed info about a preset +specify preset info healthcare-compliance + +# Remove a preset +specify preset remove healthcare-compliance +``` + +## Stacking Presets + +Multiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence): + +```bash +specify preset add enterprise-safe --priority 10 # base layer +specify preset add healthcare-compliance --priority 5 # overrides enterprise-safe +specify preset add pm-workflow --priority 1 # overrides everything +``` + +Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely. + +## Catalog Management + +Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +```bash +# List active catalogs +specify preset catalog list + +# Add a custom catalog +specify preset catalog add https://example.com/catalog.json --name my-org --install-allowed + +# Remove a catalog +specify preset catalog remove my-org +``` + +## Creating a Preset + +See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset. + +1. Copy `scaffold/` to a new directory +2. Edit `preset.yml` with your preset's metadata +3. Add or replace templates in `templates/` +4. Test locally with `specify preset add --dev .` +5. Verify with `specify preset resolve spec-template` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects | + +## Future Considerations + +The following enhancements are under consideration for future releases: + +- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`: + + | Type | `replace` | `prepend` | `append` | `wrap` | + |------|-----------|-----------|----------|--------| + | **template** | ✓ (default) | ✓ | ✓ | ✓ | + | **command** | ✓ (default) | ✓ | ✓ | ✓ | + | **script** | ✓ (default) | — | — | ✓ | + + For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable. +- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. diff --git a/presets/catalog.community.json b/presets/catalog.community.json new file mode 100644 index 0000000000..368f208b7b --- /dev/null +++ b/presets/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-03-09T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": {} +} diff --git a/presets/catalog.json b/presets/catalog.json new file mode 100644 index 0000000000..ca40f85280 --- /dev/null +++ b/presets/catalog.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-03-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", + "presets": {} +} diff --git a/presets/scaffold/README.md b/presets/scaffold/README.md new file mode 100644 index 0000000000..b30a1ab6ac --- /dev/null +++ b/presets/scaffold/README.md @@ -0,0 +1,46 @@ +# My Preset + +A custom preset for Spec Kit. Copy this directory and customize it to create your own. + +## Templates Included + +| Template | Type | Description | +|----------|------|-------------| +| `spec-template` | template | Custom feature specification template (overrides core and extensions) | +| `myext-template` | template | Override of the myext extension's report template | +| `speckit.specify` | command | Custom specification command (overrides core) | +| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command | + +## Development + +1. Copy this directory: `cp -r presets/scaffold my-preset` +2. Edit `preset.yml` — set your preset's ID, name, description, and templates +3. Add or modify templates in `templates/` +4. Test locally: `specify preset add --dev ./my-preset` +5. Verify resolution: `specify preset resolve spec-template` +6. Remove when done testing: `specify preset remove my-preset` + +## Manifest Reference (`preset.yml`) + +Required fields: +- `schema_version` — always `"1.0"` +- `preset.id` — lowercase alphanumeric with hyphens +- `preset.name` — human-readable name +- `preset.version` — semantic version (e.g. `1.0.0`) +- `preset.description` — brief description +- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`) +- `provides.templates` — list of templates with `type`, `name`, and `file` + +## Template Types + +- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.) +- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan) +- **script** — Custom scripts (reserved for future use) + +## Publishing + +See the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog. + +## License + +MIT diff --git a/presets/scaffold/commands/speckit.myext.myextcmd.md b/presets/scaffold/commands/speckit.myext.myextcmd.md new file mode 100644 index 0000000000..5adef240b6 --- /dev/null +++ b/presets/scaffold/commands/speckit.myext.myextcmd.md @@ -0,0 +1,20 @@ +--- +description: "Override of the myext extension's myextcmd command" +--- + + + +You are following a customized version of the myext extension's myextcmd command. + +When executing this command: + +1. Read the user's input from $ARGUMENTS +2. Follow the standard myextcmd workflow +3. Additionally, apply the following customizations from this preset: + - Add compliance checks before proceeding + - Include audit trail entries in the output + +> CUSTOMIZE: Replace the instructions above with your own. +> This file overrides the command that the "myext" extension provides. +> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.) +> will use this version instead of the extension's original. diff --git a/presets/scaffold/commands/speckit.specify.md b/presets/scaffold/commands/speckit.specify.md new file mode 100644 index 0000000000..7926cc138c --- /dev/null +++ b/presets/scaffold/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: "Create a feature specification (preset override)" +scripts: + sh: scripts/bash/create-new-feature.sh "{ARGS}" + ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" +--- + +## User Input + +```text +$ARGUMENTS +``` + +Given the feature description above: + +1. **Create the feature branch** by running the script: + - Bash: `{SCRIPT} --json --short-name "" ""` + - The JSON output contains BRANCH_NAME and SPEC_FILE paths. + +2. **Read the spec-template** to see the sections you need to fill. + +3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section + (Overview, Requirements, Acceptance Criteria) with details from the user's description. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml new file mode 100644 index 0000000000..975a92a413 --- /dev/null +++ b/presets/scaffold/preset.yml @@ -0,0 +1,91 @@ +schema_version: "1.0" + +preset: + # CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated) + id: "my-preset" + + # CUSTOMIZE: Human-readable name for your preset + name: "My Preset" + + # CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z) + version: "1.0.0" + + # CUSTOMIZE: Brief description (under 200 characters) + description: "Brief description of what your preset provides" + + # CUSTOMIZE: Your name or organization name + author: "Your Name" + + # CUSTOMIZE: GitHub repository URL (create before publishing) + repository: "https://github.com/your-org/spec-kit-preset-my-preset" + + # REVIEW: License (MIT is recommended for open source) + license: "MIT" + +# Requirements for this preset +requires: + # CUSTOMIZE: Minimum spec-kit version required + speckit_version: ">=0.1.0" + +# Templates provided by this preset +provides: + templates: + # CUSTOMIZE: Define your template overrides + # Templates are document scaffolds (spec-template.md, plan-template.md, etc.) + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom feature specification template" + replaces: "spec-template" # Which core template this overrides (optional) + + # ADD MORE TEMPLATES: Copy this block for each template + # - type: "template" + # name: "plan-template" + # file: "templates/plan-template.md" + # description: "Custom plan template" + # replaces: "plan-template" + + # OVERRIDE EXTENSION TEMPLATES: + # Presets sit above extensions in the resolution stack, so you can + # override templates provided by any installed extension. + # For example, if the "myext" extension provides a spec-template, + # the preset's version above will take priority automatically. + + # Override a template provided by the "myext" extension: + - type: "template" + name: "myext-template" + file: "templates/myext-template.md" + description: "Override myext's report template" + replaces: "myext-template" + + # Command overrides (AI agent workflow prompts) + # Presets can override both core and extension commands. + # Commands are automatically registered into all detected agent + # directories (.claude/commands/, .gemini/commands/, etc.) + + # Override a core command: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Custom specification command" + replaces: "speckit.specify" + + # Override an extension command (e.g. from the "myext" extension): + - type: "command" + name: "speckit.myext.myextcmd" + file: "commands/speckit.myext.myextcmd.md" + description: "Override myext's myextcmd command with custom workflow" + replaces: "speckit.myext.myextcmd" + + # Script templates (reserved for future use) + # - type: "script" + # name: "create-new-feature" + # file: "scripts/bash/create-new-feature.sh" + # description: "Custom feature creation script" + # replaces: "create-new-feature" + +# CUSTOMIZE: Add relevant tags (2-5 recommended) +# Used for discovery in catalog +tags: + - "example" + - "preset" diff --git a/presets/scaffold/templates/myext-template.md b/presets/scaffold/templates/myext-template.md new file mode 100644 index 0000000000..2b4f5a3fe0 --- /dev/null +++ b/presets/scaffold/templates/myext-template.md @@ -0,0 +1,24 @@ +# MyExt Report + +> This template overrides the one provided by the "myext" extension. +> Customize it to match your needs. + +## Summary + +Brief summary of the report. + +## Details + +- Detail 1 +- Detail 2 + +## Actions + +- [ ] Action 1 +- [ ] Action 2 + + diff --git a/presets/scaffold/templates/spec-template.md b/presets/scaffold/templates/spec-template.md new file mode 100644 index 0000000000..432bca3ccf --- /dev/null +++ b/presets/scaffold/templates/spec-template.md @@ -0,0 +1,18 @@ +# Feature Specification: [FEATURE NAME] + +**Created**: [DATE] +**Status**: Draft + +## Overview + +[Brief description of the feature] + +## Requirements + +- [ ] Requirement 1 +- [ ] Requirement 2 + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/commands/speckit.specify.md b/presets/self-test/commands/speckit.specify.md new file mode 100644 index 0000000000..d5e2c74889 --- /dev/null +++ b/presets/self-test/commands/speckit.specify.md @@ -0,0 +1,15 @@ +--- +description: "Self-test override of the specify command" +--- + + + +You are following the self-test preset's version of the specify command. + +When creating a specification, follow this process: + +1. Read the user's requirements from $ARGUMENTS +2. Create a specification document using the spec-template +3. Include all standard sections plus the self-test marker + +> This command is provided by the self-test preset. diff --git a/presets/self-test/preset.yml b/presets/self-test/preset.yml new file mode 100644 index 0000000000..82c7b068ad --- /dev/null +++ b/presets/self-test/preset.yml @@ -0,0 +1,61 @@ +schema_version: "1.0" + +preset: + id: "self-test" + name: "Self-Test Preset" + version: "1.0.0" + description: "A preset that overrides all core templates for testing purposes" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Self-test spec template" + replaces: "spec-template" + + - type: "template" + name: "plan-template" + file: "templates/plan-template.md" + description: "Self-test plan template" + replaces: "plan-template" + + - type: "template" + name: "tasks-template" + file: "templates/tasks-template.md" + description: "Self-test tasks template" + replaces: "tasks-template" + + - type: "template" + name: "checklist-template" + file: "templates/checklist-template.md" + description: "Self-test checklist template" + replaces: "checklist-template" + + - type: "template" + name: "constitution-template" + file: "templates/constitution-template.md" + description: "Self-test constitution template" + replaces: "constitution-template" + + - type: "template" + name: "agent-file-template" + file: "templates/agent-file-template.md" + description: "Self-test agent file template" + replaces: "agent-file-template" + + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Self-test override of the specify command" + replaces: "speckit.specify" + +tags: + - "testing" + - "self-test" diff --git a/presets/self-test/templates/agent-file-template.md b/presets/self-test/templates/agent-file-template.md new file mode 100644 index 0000000000..7b9267bade --- /dev/null +++ b/presets/self-test/templates/agent-file-template.md @@ -0,0 +1,9 @@ +# Agent File (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Agent Instructions + +Follow these guidelines when working on this project. diff --git a/presets/self-test/templates/checklist-template.md b/presets/self-test/templates/checklist-template.md new file mode 100644 index 0000000000..c761eb0298 --- /dev/null +++ b/presets/self-test/templates/checklist-template.md @@ -0,0 +1,15 @@ +# Checklist (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Pre-Implementation + +- [ ] Spec reviewed +- [ ] Plan approved + +## Post-Implementation + +- [ ] Tests passing +- [ ] Documentation updated diff --git a/presets/self-test/templates/constitution-template.md b/presets/self-test/templates/constitution-template.md new file mode 100644 index 0000000000..0c53211fb0 --- /dev/null +++ b/presets/self-test/templates/constitution-template.md @@ -0,0 +1,15 @@ +# Constitution (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Principles + +1. Principle 1 +2. Principle 2 + +## Guidelines + +- Guideline 1 +- Guideline 2 diff --git a/presets/self-test/templates/plan-template.md b/presets/self-test/templates/plan-template.md new file mode 100644 index 0000000000..5cdaa0a41e --- /dev/null +++ b/presets/self-test/templates/plan-template.md @@ -0,0 +1,22 @@ +# Implementation Plan (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Approach + +Describe the implementation approach. + +## Steps + +1. Step 1 +2. Step 2 + +## Dependencies + +- Dependency 1 + +## Risks + +- Risk 1 diff --git a/presets/self-test/templates/spec-template.md b/presets/self-test/templates/spec-template.md new file mode 100644 index 0000000000..a54956f166 --- /dev/null +++ b/presets/self-test/templates/spec-template.md @@ -0,0 +1,23 @@ +# Feature Specification (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Overview + +Brief description of the feature. + +## Requirements + +- Requirement 1 +- Requirement 2 + +## Design + +Describe the design approach. + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/templates/tasks-template.md b/presets/self-test/templates/tasks-template.md new file mode 100644 index 0000000000..80fa4c5fab --- /dev/null +++ b/presets/self-test/templates/tasks-template.md @@ -0,0 +1,17 @@ +# Tasks (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Task List + +- [ ] Task 1 +- [ ] Task 2 + +## Estimation + +| Task | Estimate | +|------|----------| +| Task 1 | TBD | +| Task 2 | TBD | diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 7161f43b50..52e363e6d4 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -175,3 +175,79 @@ json_escape() { check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence) + local sorted_presets + sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null) + if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + else + # python3 returned empty list — fall through to directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Return success with empty output so callers using set -e don't abort; + # callers check [ -n "$TEMPLATE" ] to detect "not found". + return 0 +} + diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 725f84c852..0823cca274 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -177,6 +177,7 @@ json_escape() { # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) @@ -307,9 +308,9 @@ fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" -TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi # Inform the user how to persist the feature variable in their own shell printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 60cf372cbd..2a044c679e 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -39,12 +39,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 mkdir -p "$FEATURE_DIR" # Copy plan template if it exists -TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" -if [[ -f "$TEMPLATE" ]]; then +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") +if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN" else - echo "Warning: Plan template not found at $TEMPLATE" + echo "Warning: Plan template not found" # Create a basic plan file if template doesn't exist touch "$IMPL_PLAN" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..3d6a77f295 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,3 +135,70 @@ function Test-DirHasFiles { } } +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +function Resolve-Template { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Priority 1: Project overrides + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { return $override } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + # Fallback: alphabetical directory order + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + foreach ($presetId in $sortedPresets) { + $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } else { + # Fallback: alphabetical directory order + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + } + + # Priority 3: Extension-provided templates + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + + # Priority 4: Core templates + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { return $core } + + return $null +} + diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 172b5bc7dc..31acbe2958 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -141,6 +141,9 @@ if (-not $fallbackRoot) { exit 1 } +# Load common functions (includes Resolve-Template) +. "$PSScriptRoot/common.ps1" + try { $repoRoot = git rev-parse --show-toplevel 2>$null if ($LASTEXITCODE -eq 0) { @@ -276,9 +279,9 @@ if ($hasGit) { $featureDir = Join-Path $specsDir $branchName New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$template = Join-Path $repoRoot '.specify/templates/spec-template.md' +$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { +if ($template -and (Test-Path $template)) { Copy-Item $template $specFile -Force } else { New-Item -ItemType File -Path $specFile | Out-Null diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d0ed582fa9..ee09094bf7 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null # Copy plan template if it exists, otherwise note it or create empty file -$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md' -if (Test-Path $template) { +$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT +if ($template -and (Test-Path $template)) { Copy-Item $template $paths.IMPL_PLAN -Force Write-Output "Copied plan template to $($paths.IMPL_PLAN)" } else { - Write-Warning "Plan template not found at $template" + Write-Warning "Plan template not found" # Create a basic plan file if template doesn't exist New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e7781b2b53..a45535aee4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -34,7 +34,7 @@ import json import yaml from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Tuple import typer import httpx @@ -1067,6 +1067,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + +INIT_OPTIONS_FILE = ".specify/init-options.json" + + +def save_init_options(project_path: Path, options: dict[str, Any]) -> None: + """Persist the CLI options used during ``specify init``. + + Writes a small JSON file to ``.specify/init-options.json`` so that + later operations (e.g. preset install) can adapt their behaviour + without scanning the filesystem. + """ + dest = project_path / INIT_OPTIONS_FILE + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(options, indent=2, sort_keys=True)) + + +def load_init_options(project_path: Path) -> dict[str, Any]: + """Load the init options previously saved by ``specify init``. + + Returns an empty dict if the file does not exist or cannot be parsed. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + # Agent-specific skill directory overrides for agents whose skills directory # doesn't follow the standard /skills/ pattern AGENT_SKILLS_DIR_OVERRIDES = { @@ -1300,6 +1330,7 @@ def init( debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), ): """ Initialize a new Specify project from the latest template. @@ -1328,6 +1359,7 @@ def init( specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --ai claude --preset healthcare-compliance # With preset """ show_banner() @@ -1589,6 +1621,50 @@ def init( else: tracker.skip("git", "--no-git flag") + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE preset install so _get_skills_dir() works. + save_init_options(project_path, { + "ai": selected_ai, + "ai_skills": ai_skills, + "ai_commands_dir": ai_commands_dir, + "here": here, + "preset": preset, + "script": selected_script, + "speckit_version": get_speckit_version(), + }) + + # Install preset if specified + if preset: + try: + from .presets import PresetManager, PresetCatalog, PresetError + preset_manager = PresetManager(project_path) + speckit_ver = get_speckit_version() + + # Try local directory first, then catalog + local_path = Path(preset).resolve() + if local_path.is_dir() and (local_path / "preset.yml").exists(): + preset_manager.install_from_directory(local_path, speckit_ver) + else: + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + else: + try: + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + except Exception as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + tracker.complete("final", "project ready") except Exception as e: tracker.error("final", str(e)) @@ -1826,6 +1902,20 @@ def version(): ) extension_app.add_typer(catalog_app, name="catalog") +preset_app = typer.Typer( + name="preset", + help="Manage spec-kit presets", + add_completion=False, +) +app.add_typer(preset_app, name="preset") + +preset_catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) +preset_app.add_typer(preset_catalog_app, name="catalog") + def get_speckit_version() -> str: """Get current spec-kit version.""" @@ -1848,6 +1938,490 @@ def get_speckit_version() -> str: return "unknown" +# ===== Preset Commands ===== + + +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from .presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + for pack in installed: + status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" + pri = pack.get('priority', 10) + console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") + console.print(f" {pack['description']}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + +@preset_app.command("add") +def preset_add( + pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), + dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install a preset.""" + from .presets import ( + PresetManager, + PresetCatalog, + PresetError, + PresetValidationError, + PresetCompatibilityError, + ) + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory(dev_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + _parsed = _urlparse(from_url) + _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") + if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): + console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.request + import urllib.error + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "preset.zip" + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_path.write_bytes(response.read()) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download: {e}") + raise typer.Exit(1) + + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif pack_id: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(pack_id) + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) + + console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(pack_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) + else: + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") + raise typer.Exit(1) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@preset_app.command("remove") +def preset_remove( + pack_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + if not manager.registry.is_installed(pack_id): + console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(pack_id): + console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'") + raise typer.Exit(1) + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .presets import PresetCatalog, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") + console.print(f" {pack.get('description', '')}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), +): + """Show which template will be resolved for a given name.""" + from .presets import PresetResolver + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + resolver = PresetResolver(project_root) + result = resolver.resolve_with_source(template_name) + + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + + +@preset_app.command("info") +def preset_info( + pack_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .presets import PresetCatalog, PresetManager, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(pack_id) + + if local_pack: + console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") + console.print(f" ID: {local_pack.id}") + console.print(f" Version: {local_pack.version}") + console.print(f" Description: {local_pack.description}") + if local_pack.author: + console.print(f" Author: {local_pack.author}") + if local_pack.tags: + console.print(f" Tags: {', '.join(local_pack.tags)}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {repo}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {license_val}") + console.print("\n [green]Status: installed[/green]") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(pack_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f" ID: {pack_info['id']}") + console.print(f" Version: {pack_info.get('version', '?')}") + console.print(f" Description: {pack_info.get('description', '')}") + if pack_info.get("author"): + console.print(f" Author: {pack_info['author']}") + if pack_info.get("tags"): + console.print(f" Tags: {', '.join(pack_info['tags'])}") + if pack_info.get("repository"): + console.print(f" Repository: {pack_info['repository']}") + if pack_info.get("license"): + console.print(f" License: {pack_info['license']}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]") + console.print() + + +# ===== Preset Catalog Commands ===== + + +@preset_catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) + + +@preset_catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@preset_catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read preset catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + console.print(f"[green]✓[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +# ===== Extension Commands ===== + + def _resolve_installed_extension( argument: str, installed_extensions: list, diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py new file mode 100644 index 0000000000..9927daee8c --- /dev/null +++ b/src/specify_cli/agents.py @@ -0,0 +1,422 @@ +""" +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. +""" + +from pathlib import Path +from typing import Dict, List, Any + +import yaml + + +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). + """ + + # 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/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "auggie": { + "dir": ".augment/commands", + "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" + } + } + + @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 + + @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) + 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, + 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: ) + + Returns: + Formatted Markdown command file content + """ + if context_note is None: + context_note = f"\n\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: + desc = frontmatter["description"].replace('"', '\\"') + toml_lines.append(f'description = "{desc}"') + toml_lines.append("") + + toml_lines.append(f"# Source: {source_id}") + toml_lines.append("") + + 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 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 + """ + 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) + + frontmatter = self._adjust_script_paths(frontmatter) + + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) + + if agent_config["format"] == "markdown": + output = self.render_markdown_command(frontmatter, body, source_id, context_note) + elif agent_config["format"] == "toml": + output = self.render_toml_command(frontmatter, body, source_id) + else: + raise ValueError(f"Unsupported format: {agent_config['format']}") + + dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + 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_file = commands_dir / f"{alias}{agent_config['extension']}" + alias_file.parent.mkdir(parents=True, exist_ok=True) + alias_file.write_text(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" + 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 = {} + + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + agent_dir = project_root / agent_config["dir"].split("/")[0] + + 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 + """ + 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: + cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + if cmd_file.exists(): + cmd_file.unlink() + + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + prompt_file.unlink() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 156daff6ad..0dfd40b7cd 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -578,23 +578,7 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: # 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.""" - - # 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" - } - } - - @staticmethod - def parse_frontmatter(content: str) -> tuple[dict, str]: - """Parse YAML frontmatter from Markdown content. - - Args: - content: Markdown content with YAML frontmatter + """Handles registration of extension commands with AI agents. - Returns: - Tuple of (frontmatter_dict, body_content) - """ - if not content.startswith("---"): - return {}, content + 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. + """ - # Find second --- - end_marker = content.find("---", 3) - if end_marker == -1: - return {}, content + # Re-export AGENT_CONFIGS at class level for direct attribute access + from .agents import CommandRegistrar as _AgentRegistrar + AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS - frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3:].strip() + def __init__(self): + from .agents import CommandRegistrar as _Registrar + self._registrar = _Registrar() - try: - frontmatter = yaml.safe_load(frontmatter_str) or {} - except yaml.YAMLError: - frontmatter = {} - - return frontmatter, body + # Delegate static/utility methods + @staticmethod + def parse_frontmatter(content: str) -> tuple[dict, str]: + from .agents import CommandRegistrar as _Registrar + return _Registrar.parse_frontmatter(content) @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) - 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 + from .agents import CommandRegistrar as _Registrar + return _Registrar.render_frontmatter(fm) - 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 + @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: - 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\n\n" - return self.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 self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body - 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 @@ def register_commands_for_agent( 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\n\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 @@ def register_commands_for_all_agents( extension_dir: Path, project_root: Path ) -> Dict[str, List[str]]: - """Register extension commands for all detected agents. - - 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 + """Register extension commands for all detected agents.""" + context_note = f"\n\n\n" + return self._registrar.register_commands_for_all_agents( + manifest.commands, manifest.id, extension_dir, project_root, + context_note=context_note + ) - 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 @@ def register_commands_for_claude( 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) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py new file mode 100644 index 0000000000..1519633791 --- /dev/null +++ b/src/specify_cli/presets.py @@ -0,0 +1,1530 @@ +""" +Preset Manager for Spec Kit + +Handles installation, removal, and management of Spec Kit presets. +Presets are self-contained, versioned collections of templates +(artifact, command, and script templates) that can be installed to +customize the Spec-Driven Development workflow. +""" + +import json +import hashlib +import os +import tempfile +import zipfile +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Dict, List, Any +from datetime import datetime, timezone +import re + +import yaml +from packaging import version as pkg_version +from packaging.specifiers import SpecifierSet, InvalidSpecifier + + +@dataclass +class PresetCatalogEntry: + """Represents a single entry in the preset catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +class PresetError(Exception): + """Base exception for preset-related errors.""" + pass + + +class PresetValidationError(PresetError): + """Raised when preset manifest validation fails.""" + pass + + +class PresetCompatibilityError(PresetError): + """Raised when preset is incompatible with current environment.""" + pass + + +VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} + + +class PresetManifest: + """Represents and validates a preset manifest (preset.yml).""" + + SCHEMA_VERSION = "1.0" + REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"] + + def __init__(self, manifest_path: Path): + """Load and validate preset manifest. + + Args: + manifest_path: Path to preset.yml file + + Raises: + PresetValidationError: If manifest is invalid + """ + self.path = manifest_path + self.data = self._load_yaml(manifest_path) + self._validate() + + def _load_yaml(self, path: Path) -> dict: + """Load YAML file safely.""" + try: + with open(path, 'r') as f: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise PresetValidationError(f"Invalid YAML in {path}: {e}") + except FileNotFoundError: + raise PresetValidationError(f"Manifest not found: {path}") + + def _validate(self): + """Validate manifest structure and required fields.""" + # Check required top-level fields + for field in self.REQUIRED_FIELDS: + if field not in self.data: + raise PresetValidationError(f"Missing required field: {field}") + + # Validate schema version + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise PresetValidationError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + # Validate preset metadata + pack = self.data["preset"] + for field in ["id", "name", "version", "description"]: + if field not in pack: + raise PresetValidationError(f"Missing preset.{field}") + + # Validate pack ID format + if not re.match(r'^[a-z0-9-]+$', pack["id"]): + raise PresetValidationError( + f"Invalid preset ID '{pack['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + # Validate semantic version + try: + pkg_version.Version(pack["version"]) + except pkg_version.InvalidVersion: + raise PresetValidationError(f"Invalid version: {pack['version']}") + + # Validate requires section + requires = self.data["requires"] + if "speckit_version" not in requires: + raise PresetValidationError("Missing requires.speckit_version") + + # Validate provides section + provides = self.data["provides"] + if "templates" not in provides or not provides["templates"]: + raise PresetValidationError( + "Preset must provide at least one template" + ) + + # Validate templates + for tmpl in provides["templates"]: + if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl: + raise PresetValidationError( + "Template missing 'type', 'name', or 'file'" + ) + + if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: + raise PresetValidationError( + f"Invalid template type '{tmpl['type']}': " + f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" + ) + + # Validate file path safety: must be relative, no parent traversal + file_path = tmpl["file"] + normalized = os.path.normpath(file_path) + if os.path.isabs(normalized) or normalized.startswith(".."): + raise PresetValidationError( + f"Invalid template file path '{file_path}': " + "must be a relative path within the preset directory" + ) + + # Validate template name format + if tmpl["type"] == "command": + # Commands use dot notation (e.g. speckit.specify) + if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid command name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens and dots only" + ) + else: + if not re.match(r'^[a-z0-9-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid template name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + @property + def id(self) -> str: + """Get preset ID.""" + return self.data["preset"]["id"] + + @property + def name(self) -> str: + """Get preset name.""" + return self.data["preset"]["name"] + + @property + def version(self) -> str: + """Get preset version.""" + return self.data["preset"]["version"] + + @property + def description(self) -> str: + """Get preset description.""" + return self.data["preset"]["description"] + + @property + def author(self) -> str: + """Get preset author.""" + return self.data["preset"].get("author", "") + + @property + def requires_speckit_version(self) -> str: + """Get required spec-kit version range.""" + return self.data["requires"]["speckit_version"] + + @property + def templates(self) -> List[Dict[str, Any]]: + """Get list of provided templates.""" + return self.data["provides"]["templates"] + + @property + def tags(self) -> List[str]: + """Get preset tags.""" + return self.data.get("tags", []) + + def get_hash(self) -> str: + """Calculate SHA256 hash of manifest file.""" + with open(self.path, 'rb') as f: + return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + + +class PresetRegistry: + """Manages the registry of installed presets.""" + + REGISTRY_FILE = ".registry" + SCHEMA_VERSION = "1.0" + + def __init__(self, packs_dir: Path): + """Initialize registry. + + Args: + packs_dir: Path to .specify/presets/ directory + """ + self.packs_dir = packs_dir + self.registry_path = packs_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict: + """Load registry from disk.""" + if not self.registry_path.exists(): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + try: + with open(self.registry_path, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + def _save(self): + """Save registry to disk.""" + self.packs_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, 'w') as f: + json.dump(self.data, f, indent=2) + + def add(self, pack_id: str, metadata: dict): + """Add preset to registry. + + Args: + pack_id: Preset ID + metadata: Pack metadata (version, source, etc.) + """ + self.data["presets"][pack_id] = { + **metadata, + "installed_at": datetime.now(timezone.utc).isoformat() + } + self._save() + + def remove(self, pack_id: str): + """Remove preset from registry. + + Args: + pack_id: Preset ID + """ + if pack_id in self.data["presets"]: + del self.data["presets"][pack_id] + self._save() + + def get(self, pack_id: str) -> Optional[dict]: + """Get preset metadata from registry. + + Args: + pack_id: Preset ID + + Returns: + Pack metadata or None if not found + """ + return self.data["presets"].get(pack_id) + + def list(self) -> Dict[str, dict]: + """Get all installed presets. + + Returns: + Dictionary of pack_id -> metadata + """ + return self.data["presets"] + + def list_by_priority(self) -> List[tuple]: + """Get all installed presets sorted by priority. + + Lower priority number = higher precedence (checked first). + + Returns: + List of (pack_id, metadata) tuples sorted by priority + """ + packs = self.data["presets"] + return sorted( + packs.items(), + key=lambda item: item[1].get("priority", 10), + ) + + def is_installed(self, pack_id: str) -> bool: + """Check if preset is installed. + + Args: + pack_id: Preset ID + + Returns: + True if pack is installed + """ + return pack_id in self.data["presets"] + + +class PresetManager: + """Manages preset lifecycle: installation, removal, updates.""" + + def __init__(self, project_root: Path): + """Initialize preset manager. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.registry = PresetRegistry(self.presets_dir) + + def check_compatibility( + self, + manifest: PresetManifest, + speckit_version: str + ) -> bool: + """Check if preset is compatible with current spec-kit version. + + Args: + manifest: Preset manifest + speckit_version: Current spec-kit version + + Returns: + True if compatible + + Raises: + PresetCompatibilityError: If pack is incompatible + """ + required = manifest.requires_speckit_version + current = pkg_version.Version(speckit_version) + + try: + specifier = SpecifierSet(required) + if current not in specifier: + raise PresetCompatibilityError( + f"Preset requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: uv tool install specify-cli --force" + ) + except InvalidSpecifier: + raise PresetCompatibilityError( + f"Invalid version specifier: {required}" + ) + + return True + + def _register_commands( + self, + manifest: PresetManifest, + preset_dir: Path + ) -> Dict[str, List[str]]: + """Register preset command overrides with all detected AI agents. + + Scans the preset's templates for type "command", reads each command + file, and writes it to every detected agent directory using the + CommandRegistrar from the agents module. + + Args: + manifest: Preset manifest + preset_dir: Installed preset directory + + Returns: + Dictionary mapping agent names to lists of registered command names + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return {} + + # Filter out extension command overrides if the extension isn't installed. + # Command names follow the pattern: speckit.. + # Core commands (e.g. speckit.specify) have only one dot — always register. + 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 + filtered.append(cmd) + + if not filtered: + return {} + + try: + from .agents import CommandRegistrar + except ImportError: + return {} + + registrar = CommandRegistrar() + return registrar.register_commands_for_all_agents( + filtered, manifest.id, preset_dir, self.project_root + ) + + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + """ + try: + from .agents import CommandRegistrar + except ImportError: + return + + registrar = CommandRegistrar() + registrar.unregister_commands(registered_commands, self.project_root) + + def _get_skills_dir(self) -> Optional[Path]: + """Return the skills directory if ``--ai-skills`` was used during init. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled or the init-options file is missing. + """ + from . import load_init_options, _get_skills_dir + + opts = load_init_options(self.project_root) + if not opts.get("ai_skills"): + return None + + agent = opts.get("ai") + if not agent: + return None + + skills_dir = _get_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_skills( + self, + manifest: "PresetManifest", + preset_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for preset command overrides. + + For every command template in the preset, checks whether a + corresponding skill already exists in any detected skills + directory. If so, the skill is overwritten with content derived + from the preset's command file. This ensures that presets that + override commands also propagate to the agentskills.io skill + layer when ``--ai-skills`` was used during project initialisation. + + Args: + manifest: Preset manifest. + preset_dir: Installed preset directory. + + Returns: + List of skill names that were written (for registry storage). + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return [] + + # Filter out extension command overrides if the extension isn't installed, + # matching the same logic used by _register_commands(). + 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 + filtered.append(cmd) + + if not filtered: + return [] + + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import SKILL_DESCRIPTIONS, load_init_options + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai", "") + + written: List[str] = [] + + for cmd_tmpl in filtered: + cmd_name = cmd_tmpl["name"] + cmd_file_rel = cmd_tmpl["file"] + source_file = preset_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Derive the short command name (e.g. "specify" from "speckit.specify") + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + # Kimi CLI discovers skills by directory name and invokes them as + # /skill: — use dot separator to match packaging convention. + if selected_ai == "kimi": + skill_name = f"speckit.{short_name}" + else: + skill_name = f"speckit-{short_name}" + + # Only overwrite if the skill already exists (i.e. --ai-skills was used) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + continue + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"preset:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + + skill_file = skill_subdir / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: + """Restore original SKILL.md files after a preset is removed. + + For each skill that was overridden by the preset, attempts to + regenerate the skill from the core command template. If no core + template exists, the skill directory is removed. + + Args: + skill_names: List of skill names written by the preset. + preset_dir: The preset's installed directory (may already be deleted). + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS + + # Locate core command templates from the project's installed templates + core_templates_dir = self.project_root / ".specify" / "templates" / "commands" + + for skill_name in skill_names: + # Derive command name from skill name (speckit-specify -> specify) + short_name = skill_name + if short_name.startswith("speckit-"): + short_name = short_name[len("speckit-"):] + elif short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if not skill_file.exists(): + continue + + # Try to find the core command template + core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None + if core_file and not core_file.exists(): + core_file = None + + if core_file: + # Restore from core template + content = core_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"templates/commands/{short_name}.md", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + skill_file.write_text(skill_content, encoding="utf-8") + else: + # No core template — remove the skill entirely + shutil.rmtree(skill_subdir) + + def install_from_directory( + self, + source_dir: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from a local directory. + + Args: + source_dir: Path to preset directory + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid + PresetCompatibilityError: If pack is incompatible + """ + manifest_path = source_dir / "preset.yml" + manifest = PresetManifest(manifest_path) + + self.check_compatibility(manifest, speckit_version) + + if self.registry.is_installed(manifest.id): + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + + dest_dir = self.presets_dir / manifest.id + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + shutil.copytree(source_dir, dest_dir) + + # Register command overrides with AI agents + registered_commands = self._register_commands(manifest, dest_dir) + + # Update corresponding skills when --ai-skills was previously used + registered_skills = self._register_skills(manifest, dest_dir) + + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + }) + + return manifest + + def install_from_zip( + self, + zip_path: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from ZIP file. + + Args: + zip_path: Path to preset ZIP file + speckit_version: Current spec-kit version + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid + PresetCompatibilityError: If pack is incompatible + """ + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + + with zipfile.ZipFile(zip_path, 'r') as zf: + temp_path_resolved = temp_path.resolve() + for member in zf.namelist(): + member_path = (temp_path / member).resolve() + try: + member_path.relative_to(temp_path_resolved) + except ValueError: + raise PresetValidationError( + f"Unsafe path in ZIP archive: {member} " + "(potential path traversal)" + ) + zf.extractall(temp_path) + + pack_dir = temp_path + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + pack_dir = subdirs[0] + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + raise PresetValidationError( + "No preset.yml found in ZIP file" + ) + + return self.install_from_directory(pack_dir, speckit_version, priority) + + def remove(self, pack_id: str) -> bool: + """Remove an installed preset. + + Args: + pack_id: Preset ID + + Returns: + True if pack was removed + """ + if not self.registry.is_installed(pack_id): + return False + + # Unregister commands from AI agents + metadata = self.registry.get(pack_id) + registered_commands = metadata.get("registered_commands", {}) if metadata else {} + if registered_commands: + self._unregister_commands(registered_commands) + + # Restore original skills when preset is removed + registered_skills = metadata.get("registered_skills", []) if metadata else [] + pack_dir = self.presets_dir / pack_id + if registered_skills: + self._unregister_skills(registered_skills, pack_dir) + + if pack_dir.exists(): + shutil.rmtree(pack_dir) + + self.registry.remove(pack_id) + return True + + def list_installed(self) -> List[Dict[str, Any]]: + """List all installed presets with metadata. + + Returns: + List of preset metadata dictionaries + """ + result = [] + + for pack_id, metadata in self.registry.list().items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + manifest = PresetManifest(manifest_path) + result.append({ + "id": pack_id, + "name": manifest.name, + "version": metadata["version"], + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "installed_at": metadata.get("installed_at"), + "template_count": len(manifest.templates), + "tags": manifest.tags, + "priority": metadata.get("priority", 10), + }) + except PresetValidationError: + result.append({ + "id": pack_id, + "name": pack_id, + "version": metadata.get("version", "unknown"), + "description": "⚠️ Corrupted preset", + "enabled": False, + "installed_at": metadata.get("installed_at"), + "template_count": 0, + "tags": [], + "priority": metadata.get("priority", 10), + }) + + return result + + def get_pack(self, pack_id: str) -> Optional[PresetManifest]: + """Get manifest for an installed preset. + + Args: + pack_id: Preset ID + + Returns: + Preset manifest or None if not installed + """ + if not self.registry.is_installed(pack_id): + return None + + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + return PresetManifest(manifest_path) + except PresetValidationError: + return None + + +class PresetCatalog: + """Manages preset catalog fetching, caching, and searching. + + Supports multi-catalog stacks with priority-based resolution, + mirroring the extension catalog system. + """ + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json" + CACHE_DURATION = 3600 # 1 hour in seconds + + def __init__(self, project_root: Path): + """Initialize preset catalog manager. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.cache_dir = self.presets_dir / ".cache" + self.cache_file = self.cache_dir / "catalog.json" + self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). + + Args: + url: URL to validate + + Raises: + PresetValidationError: If URL is invalid or uses non-HTTPS scheme + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise PresetValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to preset-catalogs.yml + + Returns: + Ordered list of PresetCatalogEntry objects, or None if file + doesn't exist or contains no valid catalog entries. + + Raises: + PresetValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, or a priority value is invalid. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text()) or {} + except (yaml.YAMLError, OSError) as e: + raise PresetValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + if not isinstance(data, dict): + raise PresetValidationError( + f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + return None + if not isinstance(catalogs_data, list): + raise PresetValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + entries: List[PresetCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise PresetValidationError( + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise PresetValidationError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append(PresetCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + return entries if entries else None + + def get_active_catalogs(self) -> List[PresetCatalogEntry]: + """Get the ordered list of active preset catalogs. + + Resolution order: + 1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/preset-catalogs.yml + 3. User-level ~/.specify/preset-catalogs.yml + 4. Built-in default stack (default + community) + + Returns: + List of PresetCatalogEntry objects sorted by priority (ascending) + + Raises: + PresetValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults + if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) + if catalog_url != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default preset catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"), + PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _get_cache_paths(self, url: str): + """Get cache file paths for a given catalog URL. + + For the DEFAULT_CATALOG_URL, uses legacy cache files for backward + compatibility. For all other URLs, uses URL-hash-based cache files. + + Returns: + Tuple of (cache_file_path, cache_metadata_path) + """ + if url == self.DEFAULT_CATALOG_URL: + return self.cache_file, self.cache_metadata_file + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + return ( + self.cache_dir / f"catalog-{url_hash}.json", + self.cache_dir / f"catalog-{url_hash}-metadata.json", + ) + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached catalog for a specific URL is still valid.""" + cache_file, metadata_file = self._get_cache_paths(url) + if not cache_file.exists() or not metadata_file.exists(): + return False + try: + metadata = json.loads(metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + Args: + entry: PresetCatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + cache_file, metadata_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + return json.loads(cache_file.read_text()) + except json.JSONDecodeError: + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(entry.url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2)) + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + } + metadata_file.write_text(json.dumps(metadata, indent=2)) + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {entry.url}: {e}" + ) + + def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Fetch and merge presets from all active catalogs. + + Higher-priority catalogs (lower priority number) win on ID conflicts. + + Returns: + Merged dictionary of pack_id -> pack_data + """ + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + + for entry in reversed(active_catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + for pack_id, pack_data in data.get("presets", {}).items(): + pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} + merged[pack_id] = pack_data_with_catalog + except PresetError: + continue + + return merged + + def is_cache_valid(self) -> bool: + """Check if cached catalog is still valid. + + Returns: + True if cache exists and is within cache duration + """ + if not self.cache_file.exists() or not self.cache_metadata_file.exists(): + return False + + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch preset catalog from URL or cache. + + Args: + force_refresh: If True, bypass cache and fetch from network + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + catalog_url = self.get_catalog_url() + + if not force_refresh and self.is_cache_valid(): + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + if metadata.get("catalog_url") == catalog_url: + return json.loads(self.cache_file.read_text()) + except (json.JSONDecodeError, OSError): + # Cache is corrupt or unreadable; fall through to network fetch + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(catalog_url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text(json.dumps(catalog_data, indent=2)) + + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog_url, + } + self.cache_metadata_file.write_text( + json.dumps(metadata, indent=2) + ) + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {catalog_url}: {e}" + ) + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalog for presets. + + Searches across all active catalogs (merged by priority) so that + community and custom catalogs are included in results. + + Args: + query: Search query (searches name, description, tags) + tag: Filter by specific tag + author: Filter by author name + + Returns: + List of matching preset metadata + """ + try: + packs = self._get_merged_packs() + except PresetError: + return [] + + results = [] + + for pack_id, pack_data in packs.items(): + if author and pack_data.get("author", "").lower() != author.lower(): + continue + + if tag and tag.lower() not in [ + t.lower() for t in pack_data.get("tags", []) + ]: + continue + + if query: + query_lower = query.lower() + searchable_text = " ".join( + [ + pack_data.get("name", ""), + pack_data.get("description", ""), + pack_id, + ] + + pack_data.get("tags", []) + ).lower() + + if query_lower not in searchable_text: + continue + + results.append({**pack_data, "id": pack_id}) + + return results + + def get_pack_info( + self, pack_id: str + ) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific preset. + + Searches across all active catalogs (merged by priority). + + Args: + pack_id: ID of the preset + + Returns: + Pack metadata or None if not found + """ + try: + packs = self._get_merged_packs() + except PresetError: + return None + + if pack_id in packs: + return {**packs[pack_id], "id": pack_id} + return None + + def download_pack( + self, pack_id: str, target_dir: Optional[Path] = None + ) -> Path: + """Download preset ZIP from catalog. + + Args: + pack_id: ID of the preset to download + target_dir: Directory to save ZIP file (defaults to cache directory) + + Returns: + Path to downloaded ZIP file + + Raises: + PresetError: If pack not found or download fails + """ + import urllib.request + import urllib.error + + pack_info = self.get_pack_info(pack_id) + if not pack_info: + raise PresetError( + f"Preset '{pack_id}' not found in catalog" + ) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + raise PresetError( + f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " + f"Use --from with the preset's repository URL instead." + ) + + download_url = pack_info.get("download_url") + if not download_url: + raise PresetError( + f"Preset '{pack_id}' has no download URL" + ) + + from urllib.parse import urlparse + + parsed = urlparse(download_url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetError( + f"Preset download URL must use HTTPS: {download_url}" + ) + + if target_dir is None: + target_dir = self.cache_dir / "downloads" + target_dir.mkdir(parents=True, exist_ok=True) + + version = pack_info.get("version", "unknown") + zip_filename = f"{pack_id}-{version}.zip" + zip_path = target_dir / zip_filename + + try: + with urllib.request.urlopen(download_url, timeout=60) as response: + zip_data = response.read() + + zip_path.write_bytes(zip_data) + return zip_path + + except urllib.error.URLError as e: + raise PresetError( + f"Failed to download preset from {download_url}: {e}" + ) + except IOError as e: + raise PresetError(f"Failed to save preset ZIP: {e}") + + def clear_cache(self): + """Clear all catalog cache files, including per-URL hashed caches.""" + if self.cache_dir.exists(): + for f in self.cache_dir.iterdir(): + if f.is_file() and f.name.startswith("catalog"): + f.unlink(missing_ok=True) + + +class PresetResolver: + """Resolves template names to file paths using a priority stack. + + Resolution order: + 1. .specify/templates/overrides/ - Project-local overrides + 2. .specify/presets// - Installed presets + 3. .specify/extensions//templates/ - Extension-provided templates + 4. .specify/templates/ - Core templates (shipped with Spec Kit) + """ + + def __init__(self, project_root: Path): + """Initialize preset resolver. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.templates_dir = project_root / ".specify" / "templates" + self.presets_dir = project_root / ".specify" / "presets" + self.overrides_dir = self.templates_dir / "overrides" + self.extensions_dir = project_root / ".specify" / "extensions" + + def resolve( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve a template name to its file path. + + Walks the priority stack and returns the first match. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Path to the resolved template file, or None if not found + """ + # Determine subdirectory based on template type + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + # Determine file extension based on template type + ext = ".md" + if template_type == "script": + ext = ".sh" # scripts use .sh; callers can also check .ps1 + + # Priority 1: Project-local overrides + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + return override + + # Priority 2: Installed presets (sorted by priority — lower number wins) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + for subdir in subdirs: + if subdir: + candidate = pack_dir / subdir / f"{template_name}{ext}" + else: + candidate = pack_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 3: Extension-provided templates + if self.extensions_dir.exists(): + for ext_dir in sorted(self.extensions_dir.iterdir()): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + for subdir in subdirs: + if subdir: + candidate = ext_dir / subdir / f"{template_name}{ext}" + else: + candidate = ext_dir / "templates" / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 4: Core templates + if template_type == "template": + core = self.templates_dir / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "command": + core = self.templates_dir / "commands" / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "script": + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): + return core + + return None + + def resolve_with_source( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Dict[str, str]]: + """Resolve a template name and return source attribution. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Dictionary with 'path' and 'source' keys, or None if not found + """ + # Delegate to resolve() for the actual lookup, then determine source + resolved = self.resolve(template_name, template_type) + if resolved is None: + return None + + resolved_str = str(resolved) + + # Determine source attribution + if str(self.overrides_dir) in resolved_str: + return {"path": resolved_str, "source": "project override"} + + if str(self.presets_dir) in resolved_str and self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + try: + resolved.relative_to(pack_dir) + meta = registry.get(pack_id) + version = meta.get("version", "?") if meta else "?" + return { + "path": resolved_str, + "source": f"{pack_id} v{version}", + } + except ValueError: + continue + + if self.extensions_dir.exists(): + for ext_dir in sorted(self.extensions_dir.iterdir()): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + try: + resolved.relative_to(ext_dir) + return { + "path": resolved_str, + "source": f"extension:{ext_dir.name}", + } + except ValueError: + continue + + return {"path": resolved_str, "source": "core"} diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 0000000000..3ad70c6d14 --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,1712 @@ +""" +Unit tests for the preset system. + +Tests cover: +- Preset manifest validation +- Preset registry operations +- Preset manager installation/removal +- Template catalog search +- Template resolver priority stack +- Extension-provided templates +""" + +import pytest +import json +import tempfile +import shutil +import zipfile +from pathlib import Path +from datetime import datetime, timezone + +import yaml + +from specify_cli.presets import ( + PresetManifest, + PresetRegistry, + PresetManager, + PresetCatalog, + PresetCatalogEntry, + PresetResolver, + PresetError, + PresetValidationError, + PresetCompatibilityError, + VALID_PRESET_TEMPLATE_TYPES, +) + + +# ===== Fixtures ===== + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def valid_pack_data(): + """Valid preset manifest data.""" + return { + "schema_version": "1.0", + "preset": { + "id": "test-pack", + "name": "Test Preset", + "version": "1.0.0", + "description": "A test preset", + "author": "Test Author", + "repository": "https://github.com/test/test-pack", + "license": "MIT", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "description": "Custom spec template", + "replaces": "spec-template", + } + ] + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def pack_dir(temp_dir, valid_pack_data): + """Create a complete preset directory structure.""" + p_dir = temp_dir / "test-pack" + p_dir.mkdir() + + # Write manifest + manifest_path = p_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + # Create templates directory + templates_dir = p_dir / "templates" + templates_dir.mkdir() + + # Write template file + tmpl_file = templates_dir / "spec-template.md" + tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n") + + return p_dir + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + # Create .specify directory + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + # Create templates directory with core templates + templates_dir = specify_dir / "templates" + templates_dir.mkdir() + + # Create core spec-template + core_spec = templates_dir / "spec-template.md" + core_spec.write_text("# Core Spec Template\n") + + # Create core plan-template + core_plan = templates_dir / "plan-template.md" + core_plan.write_text("# Core Plan Template\n") + + # Create commands subdirectory + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + + return proj_dir + + +# ===== PresetManifest Tests ===== + + +class TestPresetManifest: + """Test PresetManifest validation and parsing.""" + + def test_valid_manifest(self, pack_dir): + """Test loading a valid manifest.""" + manifest = PresetManifest(pack_dir / "preset.yml") + assert manifest.id == "test-pack" + assert manifest.name == "Test Preset" + assert manifest.version == "1.0.0" + assert manifest.description == "A test preset" + assert manifest.author == "Test Author" + assert manifest.requires_speckit_version == ">=0.1.0" + assert len(manifest.templates) == 1 + assert manifest.tags == ["testing", "example"] + + def test_missing_manifest(self, temp_dir): + """Test that missing manifest raises error.""" + with pytest.raises(PresetValidationError, match="Manifest not found"): + PresetManifest(temp_dir / "nonexistent.yml") + + def test_invalid_yaml(self, temp_dir): + """Test that invalid YAML raises error.""" + bad_file = temp_dir / "bad.yml" + bad_file.write_text(": invalid: yaml: {{{") + with pytest.raises(PresetValidationError, match="Invalid YAML"): + PresetManifest(bad_file) + + def test_missing_schema_version(self, temp_dir, valid_pack_data): + """Test missing schema_version field.""" + del valid_pack_data["schema_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing required field: schema_version"): + PresetManifest(manifest_path) + + def test_wrong_schema_version(self, temp_dir, valid_pack_data): + """Test unsupported schema version.""" + valid_pack_data["schema_version"] = "2.0" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Unsupported schema version"): + PresetManifest(manifest_path) + + def test_missing_pack_id(self, temp_dir, valid_pack_data): + """Test missing preset.id field.""" + del valid_pack_data["preset"]["id"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing preset.id"): + PresetManifest(manifest_path) + + def test_invalid_pack_id_format(self, temp_dir, valid_pack_data): + """Test invalid pack ID format.""" + valid_pack_data["preset"]["id"] = "Invalid_ID" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid preset ID"): + PresetManifest(manifest_path) + + def test_invalid_version(self, temp_dir, valid_pack_data): + """Test invalid semantic version.""" + valid_pack_data["preset"]["version"] = "not-a-version" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid version"): + PresetManifest(manifest_path) + + def test_missing_speckit_version(self, temp_dir, valid_pack_data): + """Test missing requires.speckit_version.""" + del valid_pack_data["requires"]["speckit_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"): + PresetManifest(manifest_path) + + def test_no_templates_provided(self, temp_dir, valid_pack_data): + """Test pack with no templates.""" + valid_pack_data["provides"]["templates"] = [] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="must provide at least one template"): + PresetManifest(manifest_path) + + def test_invalid_template_type(self, temp_dir, valid_pack_data): + """Test template with invalid type.""" + valid_pack_data["provides"]["templates"][0]["type"] = "invalid" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template type"): + PresetManifest(manifest_path) + + def test_valid_template_types(self): + """Test that all expected template types are valid.""" + assert "template" in VALID_PRESET_TEMPLATE_TYPES + assert "command" in VALID_PRESET_TEMPLATE_TYPES + assert "script" in VALID_PRESET_TEMPLATE_TYPES + + def test_template_missing_required_fields(self, temp_dir, valid_pack_data): + """Test template missing required fields.""" + valid_pack_data["provides"]["templates"] = [{"type": "template"}] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"): + PresetManifest(manifest_path) + + def test_invalid_template_name_format(self, temp_dir, valid_pack_data): + """Test template with invalid name format.""" + valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template name"): + PresetManifest(manifest_path) + + def test_get_hash(self, pack_dir): + """Test manifest hash calculation.""" + manifest = PresetManifest(pack_dir / "preset.yml") + hash_val = manifest.get_hash() + assert hash_val.startswith("sha256:") + assert len(hash_val) > 10 + + def test_multiple_templates(self, temp_dir, valid_pack_data): + """Test pack with multiple templates of different types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}, + {"type": "template", "name": "plan-template", "file": "templates/plan-template.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + {"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 4 + + +# ===== PresetRegistry Tests ===== + + +class TestPresetRegistry: + """Test PresetRegistry operations.""" + + def test_empty_registry(self, temp_dir): + """Test empty registry initialization.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + assert not registry.is_installed("test-pack") + + def test_add_and_get(self, temp_dir): + """Test adding and retrieving a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "source": "local"}) + assert registry.is_installed("test-pack") + + metadata = registry.get("test-pack") + assert metadata is not None + assert metadata["version"] == "1.0.0" + assert "installed_at" in metadata + + def test_remove(self, temp_dir): + """Test removing a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0"}) + assert registry.is_installed("test-pack") + + registry.remove("test-pack") + assert not registry.is_installed("test-pack") + + def test_remove_nonexistent(self, temp_dir): + """Test removing a pack that doesn't exist.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + registry.remove("nonexistent") # Should not raise + + def test_list(self, temp_dir): + """Test listing all packs.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) + registry.add("pack-b", {"version": "2.0.0"}) + + all_packs = registry.list() + assert len(all_packs) == 2 + assert "pack-a" in all_packs + assert "pack-b" in all_packs + + def test_persistence(self, temp_dir): + """Test that registry data persists across instances.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + # Add with first instance + registry1 = PresetRegistry(packs_dir) + registry1.add("test-pack", {"version": "1.0.0"}) + + # Load with second instance + registry2 = PresetRegistry(packs_dir) + assert registry2.is_installed("test-pack") + + def test_corrupted_registry(self, temp_dir): + """Test recovery from corrupted registry file.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + registry_file = packs_dir / ".registry" + registry_file.write_text("not valid json{{{") + + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + + def test_get_nonexistent(self, temp_dir): + """Test getting a nonexistent pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.get("nonexistent") is None + + +# ===== PresetManager Tests ===== + + +class TestPresetManager: + """Test PresetManager installation and removal.""" + + def test_install_from_directory(self, project_dir, pack_dir): + """Test installing a preset from a directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") + + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + # Verify files are copied + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert installed_dir.exists() + assert (installed_dir / "preset.yml").exists() + assert (installed_dir / "templates" / "spec-template.md").exists() + + def test_install_already_installed(self, project_dir, pack_dir): + """Test installing an already-installed pack raises error.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with pytest.raises(PresetError, match="already installed"): + manager.install_from_directory(pack_dir, "0.1.5") + + def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): + """Test installing an incompatible pack raises error.""" + valid_pack_data["requires"]["speckit_version"] = ">=99.0.0" + incompat_dir = temp_dir / "incompat-pack" + incompat_dir.mkdir() + manifest_path = incompat_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (incompat_dir / "templates").mkdir() + (incompat_dir / "templates" / "spec-template.md").write_text("test") + + manager = PresetManager(project_dir) + with pytest.raises(PresetCompatibilityError): + manager.install_from_directory(incompat_dir, "0.1.5") + + def test_install_from_zip(self, project_dir, pack_dir, temp_dir): + """Test installing from a ZIP file.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip(zip_path, "0.1.5") + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP with nested directory.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip(zip_path, "0.1.5") + assert manifest.id == "test-pack" + + def test_install_from_zip_no_manifest(self, project_dir, temp_dir): + """Test installing from ZIP without manifest raises error.""" + zip_path = temp_dir / "bad.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.writestr("readme.txt", "no manifest here") + + manager = PresetManager(project_dir) + with pytest.raises(PresetValidationError, match="No preset.yml found"): + manager.install_from_zip(zip_path, "0.1.5") + + def test_remove(self, project_dir, pack_dir): + """Test removing a preset.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + assert manager.registry.is_installed("test-pack") + + result = manager.remove("test-pack") + assert result is True + assert not manager.registry.is_installed("test-pack") + + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert not installed_dir.exists() + + def test_remove_nonexistent(self, project_dir): + """Test removing a pack that doesn't exist.""" + manager = PresetManager(project_dir) + result = manager.remove("nonexistent") + assert result is False + + def test_list_installed(self, project_dir, pack_dir): + """Test listing installed packs.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "test-pack" + assert installed[0]["name"] == "Test Preset" + assert installed[0]["version"] == "1.0.0" + assert installed[0]["template_count"] == 1 + + def test_list_installed_empty(self, project_dir): + """Test listing when no packs installed.""" + manager = PresetManager(project_dir) + assert manager.list_installed() == [] + + def test_get_pack(self, project_dir, pack_dir): + """Test getting a specific installed pack.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + pack = manager.get_pack("test-pack") + assert pack is not None + assert pack.id == "test-pack" + + def test_get_pack_not_installed(self, project_dir): + """Test getting a non-installed pack returns None.""" + manager = PresetManager(project_dir) + assert manager.get_pack("nonexistent") is None + + def test_check_compatibility_valid(self, pack_dir, temp_dir): + """Test compatibility check with valid version.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + assert manager.check_compatibility(manifest, "0.1.5") is True + + def test_check_compatibility_invalid(self, pack_dir, temp_dir): + """Test compatibility check with invalid specifier.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + manifest.data["requires"]["speckit_version"] = "not-a-specifier" + with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): + manager.check_compatibility(manifest, "0.1.5") + + def test_install_with_priority(self, project_dir, pack_dir): + """Test installing a pack with custom priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 5 + + def test_install_default_priority(self, project_dir, pack_dir): + """Test that default priority is 10.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 10 + + def test_list_installed_includes_priority(self, project_dir, pack_dir): + """Test that list_installed includes priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=3) + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 3 + + +class TestRegistryPriority: + """Test registry priority sorting.""" + + def test_list_by_priority(self, temp_dir): + """Test that list_by_priority sorts by priority number.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-high", {"version": "1.0.0", "priority": 1}) + registry.add("pack-low", {"version": "1.0.0", "priority": 20}) + registry.add("pack-mid", {"version": "1.0.0", "priority": 10}) + + sorted_packs = registry.list_by_priority() + assert len(sorted_packs) == 3 + assert sorted_packs[0][0] == "pack-high" + assert sorted_packs[1][0] == "pack-mid" + assert sorted_packs[2][0] == "pack-low" + + def test_list_by_priority_default(self, temp_dir): + """Test that packs without priority default to 10.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10 + registry.add("pack-b", {"version": "1.0.0", "priority": 5}) + + sorted_packs = registry.list_by_priority() + assert sorted_packs[0][0] == "pack-b" + assert sorted_packs[1][0] == "pack-a" + + +# ===== PresetResolver Tests ===== + + +class TestPresetResolver: + """Test PresetResolver priority stack.""" + + def test_resolve_core_template(self, project_dir): + """Test resolving a core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert result.name == "spec-template.md" + assert "Core Spec Template" in result.read_text() + + def test_resolve_nonexistent(self, project_dir): + """Test resolving a nonexistent template returns None.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("nonexistent-template") + assert result is None + + def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): + """Test that a pack with lower priority number wins over higher number.""" + manager = PresetManager(project_dir) + + # Create pack A (priority 10 — lower precedence) + pack_a_dir = temp_dir / "pack-a" + pack_a_dir.mkdir() + data_a = {**valid_pack_data} + data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"} + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(data_a, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n") + + # Create pack B (priority 1 — higher precedence) + pack_b_dir = temp_dir / "pack-b" + pack_b_dir.mkdir() + data_b = {**valid_pack_data} + data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"} + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(data_b, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n") + + # Install A first (priority 10), B second (priority 1) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=10) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=1) + + # Pack B should win because lower priority number + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "From Pack B" in result.read_text() + + def test_resolve_override_takes_priority(self, project_dir): + """Test that project overrides take priority over core.""" + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): + """Test that installed packs take priority over core templates.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): + """Test that overrides take priority over installed packs.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_extension_provided_templates(self, project_dir): + """Test resolving templates provided by extensions.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "custom-template.md" + ext_template.write_text("# Extension Custom Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("custom-template") + assert result is not None + assert "Extension Custom Template" in result.read_text() + + def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): + """Test that pack templates take priority over extension templates.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "spec-template.md" + ext_template.write_text("# Extension Spec Template\n") + + # Install a pack with the same template + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + # Pack should win over extension + assert "Custom Spec Template" in result.read_text() + + def test_resolve_with_source_core(self, project_dir): + """Test resolve_with_source for core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "core" + assert "spec-template.md" in result["path"] + + def test_resolve_with_source_override(self, project_dir): + """Test resolve_with_source for override template.""" + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "project override" + + def test_resolve_with_source_pack(self, project_dir, pack_dir): + """Test resolve_with_source for pack template.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert "test-pack" in result["source"] + assert "v1.0.0" in result["source"] + + def test_resolve_with_source_extension(self, project_dir): + """Test resolve_with_source for extension-provided template.""" + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "unique-template.md" + ext_template.write_text("# Unique\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("unique-template") + assert result is not None + assert result["source"] == "extension:my-ext" + + def test_resolve_with_source_not_found(self, project_dir): + """Test resolve_with_source for nonexistent template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("nonexistent") + assert result is None + + def test_resolve_skips_hidden_extension_dirs(self, project_dir): + """Test that hidden directories in extensions are skipped.""" + ext_dir = project_dir / ".specify" / "extensions" / ".backup" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "hidden-template.md" + ext_template.write_text("# Hidden\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("hidden-template") + assert result is None + + +# ===== PresetCatalog Tests ===== + + +class TestPresetCatalog: + """Test template catalog functionality.""" + + def test_default_catalog_url(self, project_dir): + """Test default catalog URL.""" + catalog = PresetCatalog(project_dir) + assert catalog.DEFAULT_CATALOG_URL.startswith("https://") + assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json") + + def test_community_catalog_url(self, project_dir): + """Test community catalog URL.""" + catalog = PresetCatalog(project_dir) + assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL + + def test_cache_validation_no_cache(self, project_dir): + """Test cache validation when no cache exists.""" + catalog = PresetCatalog(project_dir) + assert catalog.is_cache_valid() is False + + def test_cache_validation_valid(self, project_dir): + """Test cache validation with valid cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog.is_cache_valid() is True + + def test_cache_validation_expired(self, project_dir): + """Test cache validation with expired cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog.is_cache_valid() is False + + def test_cache_validation_corrupted(self, project_dir): + """Test cache validation with corrupted metadata.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text("not json") + catalog.cache_metadata_file.write_text("not json") + + assert catalog.is_cache_valid() is False + + def test_clear_cache(self, project_dir): + """Test clearing the cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}") + catalog.cache_metadata_file.write_text("{}") + + catalog.clear_cache() + + assert not catalog.cache_file.exists() + assert not catalog.cache_metadata_file.exists() + + def test_search_with_cached_data(self, project_dir): + """Test search with cached catalog data.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "safe-agile": { + "name": "SAFe Agile Templates", + "description": "SAFe-aligned templates", + "author": "agile-community", + "version": "1.0.0", + "tags": ["safe", "agile"], + }, + "healthcare": { + "name": "Healthcare Compliance", + "description": "HIPAA-compliant templates", + "author": "healthcare-org", + "version": "1.0.0", + "tags": ["healthcare", "hipaa"], + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + # Search by query + results = catalog.search(query="agile") + assert len(results) == 1 + assert results[0]["id"] == "safe-agile" + + # Search by tag + results = catalog.search(tag="hipaa") + assert len(results) == 1 + assert results[0]["id"] == "healthcare" + + # Search by author + results = catalog.search(author="agile-community") + assert len(results) == 1 + + # Search all + results = catalog.search() + assert len(results) == 2 + + def test_get_pack_info(self, project_dir): + """Test getting info for a specific pack.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + info = catalog.get_pack_info("test-pack") + assert info is not None + assert info["name"] == "Test Pack" + assert info["id"] == "test-pack" + + assert catalog.get_pack_info("nonexistent") is None + + def test_validate_catalog_url_https(self, project_dir): + """Test that HTTPS URLs are accepted.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("https://example.com/catalog.json") + + def test_validate_catalog_url_http_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._validate_catalog_url("http://example.com/catalog.json") + + def test_validate_catalog_url_localhost_http_allowed(self, project_dir): + """Test that HTTP is allowed for localhost.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") + + def test_env_var_catalog_url(self, project_dir, monkeypatch): + """Test catalog URL from environment variable.""" + monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" + + +# ===== Integration Tests ===== + + +class TestIntegration: + """Integration tests for complete preset workflows.""" + + def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir): + """Test complete lifecycle: install → resolve → remove.""" + # Install + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") + assert manifest.id == "test-pack" + + # Resolve — pack template should win over core + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + # Remove + manager.remove("test-pack") + + # Resolve — should fall back to core + result = resolver.resolve("spec-template") + assert result is not None + assert "Core Spec Template" in result.read_text() + + def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir): + """Test the full priority stack: override > pack > extension > core.""" + resolver = PresetResolver(project_dir) + + # Core should resolve + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "core" + + # Add extension template + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "spec-template.md").write_text("# Extension\n") + + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "extension:my-ext" + + # Install pack — should win over extension + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + result = resolver.resolve_with_source("spec-template") + assert "test-pack" in result["source"] + + # Add override — should win over pack + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override\n") + + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "project override" + + def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP and then resolving.""" + # Create ZIP + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + # Install + manager = PresetManager(project_dir) + manager.install_from_zip(zip_path, "0.1.5") + + # Resolve + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + +# ===== PresetCatalogEntry Tests ===== + + +class TestPresetCatalogEntry: + """Test PresetCatalogEntry dataclass.""" + + def test_create_entry(self): + """Test creating a catalog entry.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + """Test default empty description.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +# ===== Multi-Catalog Tests ===== + + +class TestPresetCatalogMultiCatalog: + """Test multi-catalog support in PresetCatalog.""" + + def test_default_active_catalogs(self, project_dir): + """Test that default catalogs are returned when no config exists.""" + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[0].priority == 1 + assert active[0].install_allowed is True + assert active[1].name == "community" + assert active[1].priority == 2 + assert active[1].install_allowed is False + + def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): + """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" + monkeypatch.setenv( + "SPECKIT_PRESET_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + assert active[0].url == "https://custom.example.com/catalog.json" + assert active[0].install_allowed is True + + def test_project_config_overrides_defaults(self, project_dir): + """Test that project-level config overrides built-in defaults.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "my-catalog", + "url": "https://my.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + } + ] + })) + + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "my-catalog" + assert active[0].url == "https://my.example.com/catalog.json" + + def test_load_catalog_config_nonexistent(self, project_dir): + """Test loading config from nonexistent file returns None.""" + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config( + project_dir / ".specify" / "nonexistent.yml" + ) + assert result is None + + def test_load_catalog_config_empty(self, project_dir): + """Test loading empty config returns None.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text("") + + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config(config_path) + assert result is None + + def test_load_catalog_config_invalid_yaml(self, project_dir): + """Test loading invalid YAML raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(": invalid: {{{") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Failed to read"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_not_a_list(self, project_dir): + """Test that non-list catalogs key raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": "not-a-list"})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must be a list"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_invalid_entry(self, project_dir): + """Test that non-dict entry raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="expected a mapping"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_http_url_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "http://insecure.example.com/catalog.json", + "priority": 1, + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_priority_sorting(self, project_dir): + """Test that catalogs are sorted by priority.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "low-priority", + "url": "https://low.example.com/catalog.json", + "priority": 10, + "install_allowed": False, + }, + { + "name": "high-priority", + "url": "https://high.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert len(entries) == 2 + assert entries[0].name == "high-priority" + assert entries[1].name == "low-priority" + + def test_load_catalog_config_invalid_priority(self, project_dir): + """Test that invalid priority raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "https://example.com/catalog.json", + "priority": "not-a-number", + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Invalid priority"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_install_allowed_string(self, project_dir): + """Test that install_allowed accepts string values.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "test", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": "true", + } + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert entries[0].install_allowed is True + + def test_get_catalog_url_uses_highest_priority(self, project_dir): + """Test that get_catalog_url returns URL of highest priority catalog.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "secondary", + "url": "https://secondary.example.com/catalog.json", + "priority": 5, + }, + { + "name": "primary", + "url": "https://primary.example.com/catalog.json", + "priority": 1, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json" + + def test_cache_paths_default_url(self, project_dir): + """Test cache paths for default catalog URL use legacy locations.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + PresetCatalog.DEFAULT_CATALOG_URL + ) + assert cache_file == catalog.cache_file + assert metadata_file == catalog.cache_metadata_file + + def test_cache_paths_custom_url(self, project_dir): + """Test cache paths for custom URLs use hash-based files.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + "https://custom.example.com/catalog.json" + ) + assert cache_file != catalog.cache_file + assert "catalog-" in cache_file.name + assert cache_file.name.endswith(".json") + + def test_url_cache_valid(self, project_dir): + """Test URL-specific cache validation.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog._is_url_cache_valid(url) is True + + def test_url_cache_expired(self, project_dir): + """Test URL-specific cache expiration.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog._is_url_cache_valid(url) is False + + +# ===== Self-Test Preset Tests ===== + + +SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" + +CORE_TEMPLATE_NAMES = [ + "spec-template", + "plan-template", + "tasks-template", + "checklist-template", + "constitution-template", + "agent-file-template", +] + + +class TestSelfTestPreset: + """Tests using the self-test preset that ships with the repo.""" + + def test_self_test_preset_exists(self): + """Verify the self-test preset directory and manifest exist.""" + assert SELF_TEST_PRESET_DIR.exists() + assert (SELF_TEST_PRESET_DIR / "preset.yml").exists() + + def test_self_test_manifest_valid(self): + """Verify the self-test preset manifest is valid.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + assert manifest.id == "self-test" + assert manifest.name == "Self-Test Preset" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 7 # 6 templates + 1 command + + def test_self_test_provides_all_core_templates(self): + """Verify the self-test preset provides an override for every core template.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in CORE_TEMPLATE_NAMES: + assert name in provided_names, f"Self-test preset missing template: {name}" + + def test_self_test_template_files_exist(self): + """Verify that all declared template files actually exist on disk.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}" + + def test_self_test_templates_have_marker(self): + """Verify each template contains the preset:self-test marker.""" + for name in CORE_TEMPLATE_NAMES: + tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md" + content = tmpl_path.read_text() + assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker" + + def test_install_self_test_preset(self, project_dir): + """Test installing the self-test preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + assert manifest.id == "self-test" + assert manager.registry.is_installed("self-test") + + def test_self_test_overrides_all_core_templates(self, project_dir): + """Test that installing self-test overrides every core template.""" + # Set up core templates in the project + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + # Install self-test preset + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Every core template should now resolve from the preset + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve(name) + assert result is not None, f"{name} did not resolve" + content = result.read_text() + assert "preset:self-test" in content, ( + f"{name} resolved but not from self-test preset" + ) + + def test_self_test_resolve_with_source(self, project_dir): + """Test that resolve_with_source attributes templates to self-test.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None, f"{name} did not resolve" + assert "self-test" in result["source"], ( + f"{name} source is '{result['source']}', expected self-test" + ) + + def test_self_test_removal_restores_core(self, project_dir): + """Test that removing self-test falls back to core templates.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + manager.remove("self-test") + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None + assert result["source"] == "core" + + def test_self_test_not_in_catalog(self): + """Verify the self-test preset is NOT in the catalog (it's local-only).""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog_data = json.loads(catalog_path.read_text()) + assert "self-test" not in catalog_data["presets"] + + def test_self_test_has_command(self): + """Verify the self-test preset includes a command override.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + commands = [t for t in manifest.templates if t["type"] == "command"] + assert len(commands) >= 1 + assert commands[0]["name"] == "speckit.specify" + + def test_self_test_command_file_exists(self): + """Verify the self-test command file exists on disk.""" + cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md" + assert cmd_path.exists() + content = cmd_path.read_text() + assert "preset:self-test" in content + + def test_self_test_registers_commands_for_claude(self, project_dir): + """Test that installing self-test registers commands in .claude/commands/.""" + # Create Claude agent directory to simulate Claude being set up + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Check the command was registered + cmd_file = claude_dir / "speckit.specify.md" + assert cmd_file.exists(), "Command not registered in .claude/commands/" + content = cmd_file.read_text() + assert "preset:self-test" in content + + def test_self_test_registers_commands_for_gemini(self, project_dir): + """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" + # Create Gemini agent directory + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Check the command was registered in TOML format + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "Command not registered in .gemini/commands/" + content = cmd_file.read_text() + assert "prompt" in content # TOML format has a prompt field + assert "{{args}}" in content # Gemini uses {{args}} placeholder + + def test_self_test_unregisters_commands_on_remove(self, project_dir): + """Test that removing self-test cleans up registered commands.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + cmd_file = claude_dir / "speckit.specify.md" + assert cmd_file.exists() + + manager.remove("self-test") + assert not cmd_file.exists(), "Command not cleaned up after preset removal" + + def test_self_test_no_commands_without_agent_dirs(self, project_dir): + """Test that no commands are registered when no agent dirs exist.""" + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + metadata = manager.registry.get("self-test") + assert metadata["registered_commands"] == {} + + def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): + """Test that extension command overrides are skipped if the extension isn't installed.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Extension not installed — command should NOT be registered + cmd_file = claude_dir / "speckit.fakeext.cmd.md" + assert not cmd_file.exists(), "Command registered for missing extension" + metadata = manager.registry.get("ext-override") + assert metadata["registered_commands"] == {} + + def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): + """Test that extension command overrides ARE registered when the extension is installed.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset2" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override2", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = claude_dir / "speckit.fakeext.cmd.md" + assert cmd_file.exists(), "Command not registered despite extension being present" + + +# ===== Init Options and Skills Tests ===== + + +class TestInitOptions: + """Tests for save_init_options / load_init_options helpers.""" + + def test_save_and_load_round_trip(self, project_dir): + from specify_cli import save_init_options, load_init_options + + opts = {"ai": "claude", "ai_skills": True, "here": False} + save_init_options(project_dir, opts) + + loaded = load_init_options(project_dir) + assert loaded["ai"] == "claude" + assert loaded["ai_skills"] is True + + def test_load_returns_empty_when_missing(self, project_dir): + from specify_cli import load_init_options + + assert load_init_options(project_dir) == {} + + def test_load_returns_empty_on_invalid_json(self, project_dir): + from specify_cli import load_init_options + + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json") + + assert load_init_options(project_dir) == {} + + +class TestPresetSkills: + """Tests for preset skill registration and unregistration.""" + + def _write_init_options(self, project_dir, ai="claude", ai_skills=True): + from specify_cli import save_init_options + + save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills}) + + def _create_skill(self, skills_dir, skill_name, body="original body"): + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {skill_name}\n---\n\n{body}\n" + ) + return skill_dir + + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): + """When --ai-skills was used, a preset command override should update the skill.""" + # Simulate --ai-skills having been used: write init-options + create skill + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Also create the claude commands dir so commands get registered + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + # Install self-test preset (has a command override for speckit.specify) + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content, "Skill should reference preset source" + + # Verify it was recorded in registry + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", []) + + def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): + """When --ai-skills was NOT used, preset install should not touch skills.""" + self._write_init_options(project_dir, ai="claude", ai_skills=False) + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content, "Skill should not be modified when ai_skills=False" + + def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): + """When no init-options.json exists, preset install should not touch skills.""" + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content + + def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """When a preset is removed, skills should be restored from core templates.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + # Set up core command template in the project so restoration works + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + # Verify preset content is in the skill + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text() + + # Remove the preset + manager.remove("self-test") + + # Skill should be restored (core specify.md template exists) + assert skill_file.exists(), "Skill should still exist after preset removal" + content = skill_file.read_text() + assert "preset:self-test" not in content, "Preset content should be gone" + assert "templates/commands/specify.md" in content, "Should reference core template" + + def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): + """Skills should not be created when no existing skill dir is found.""" + self._write_init_options(project_dir, ai="claude") + # Don't create skills dir — simulate --ai-skills never created them + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + metadata = manager.registry.get("self-test") + assert metadata.get("registered_skills", []) == [] From f92d81bbec62120c83c5976c8eabc298dca5ecf8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:19 -0500 Subject: [PATCH 081/321] chore: bump version to 0.3.0 (#1839) * chore: bump version to 0.3.0 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89918e4c4b..4b1b6a47a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-13 + +### Changed + +- No changes have been documented for this release yet. + + +- make c ignores consistent with c++ (#1747) +- chore: bump version to 0.1.13 (#1746) +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) +- chore: bump version to 0.1.12 (#1737) +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [Unreleased] ### Added diff --git a/pyproject.toml b/pyproject.toml index 04a6791ad9..cdbad2e013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.2.1" +version = "0.3.0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 4a3234496e9f58ce825cc5ca3a3a9c6fd45df222 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly <63126795+raccioly@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:41:55 -0600 Subject: [PATCH 082/321] feat: Add DocGuard CDD enforcement extension to community catalog (#1838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add DocGuard CDD enforcement extension to community catalog DocGuard is a Canonical-Driven Development enforcement tool that generates, validates, scores, and traces project documentation against 51 automated checks. Provides 6 commands: - guard: 51-check validation with quality labels - diagnose: AI-ready fix prompts - score: CDD maturity scoring (0-100) - trace: ISO 29119 traceability matrix - generate: Reverse-engineer docs from codebase - init: Initialize CDD with compliance profiles Features: - Zero dependencies (pure Node.js) - Config-aware traceability (respects .docguard.json) - Orphan file detection - Research-backed (AITPG/TRACE, IEEE TSE/TMLCN 2026) npm: https://www.npmjs.com/package/docguard-cli GitHub: https://github.com/raccioly/docguard * fix: use release asset URL for download_url The source archive URL nests files under a subdirectory, so the Spec Kit installer cannot find extension.yml at the archive root. Switch to a release asset ZIP built from the extension directory. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: add DocGuard to community extensions README table * chore: update DocGuard entry to v0.8.0 (92 checks) * chore: update DocGuard description (51→92 checks) --------- Co-authored-by: Ricardo Accioly Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 66 +++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index 4c3f9d8011..30fc7ca6e0 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -74,6 +74,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|-----| | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks with config-aware traceability, quality labels, and AI-ready fix prompts. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f1e0a09271..dc0e82a029 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -74,6 +74,48 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "docguard": { + "name": "DocGuard — CDD Enforcement", + "id": "docguard", + "description": "Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks. Zero dependencies.", + "author": "raccioly", + "version": "0.8.0", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.8.0/spec-kit-docguard-v0.8.0.zip", + "repository": "https://github.com/raccioly/docguard", + "homepage": "https://www.npmjs.com/package/docguard-cli", + "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", + "changelog": "https://github.com/raccioly/docguard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "node", + "version": ">=18.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 6, + "hooks": 1 + }, + "tags": [ + "documentation", + "validation", + "quality", + "cdd", + "traceability", + "ai-agents", + "enforcement", + "scoring" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-13T00:00:00Z" + }, "doctor": { "name": "Project Health Check", "id": "doctor", @@ -124,7 +166,12 @@ "commands": 2, "hooks": 1 }, - "tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"], + "tags": [ + "orchestration", + "workflow", + "human-in-the-loop", + "parallel" + ], "verified": false, "downloads": 0, "stars": 0, @@ -191,7 +238,12 @@ "commands": 2, "hooks": 1 }, - "tags": ["implementation", "automation", "loop", "copilot"], + "tags": [ + "implementation", + "automation", + "loop", + "copilot" + ], "verified": false, "downloads": 0, "stars": 0, @@ -249,7 +301,15 @@ "commands": 7, "hooks": 1 }, - "tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"], + "tags": [ + "code-review", + "quality", + "review", + "testing", + "error-handling", + "type-design", + "simplification" + ], "verified": false, "downloads": 0, "stars": 0, From 4f81fc298f40b5ca6958f8135c960e2ffe0412db Mon Sep 17 00:00:00 2001 From: Stanislav Deviatov Date: Mon, 16 Mar 2026 13:46:06 +0100 Subject: [PATCH 083/321] feat(extensions): add Archive and Reconcile extensions to community catalog (#1844) * feat(extensions): add reconcile and archive to community catalog * Update extension link text and add changelogs Normalize extension link text in extensions/README.md (replace `[@stn1slv]` with `spec-kit-archive` and `spec-kit-reconcile`) and add CHANGELOG URLs to the corresponding entries in extensions/catalog.community.json for the Archive and Reconcile extensions. --------- Co-authored-by: Stanislav Deviatov --- extensions/README.md | 2 + extensions/catalog.community.json | 64 ++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index 30fc7ca6e0..bbbb078e51 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -72,6 +72,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | URL | |-----------|---------|-----| +| Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks with config-aware traceability, quality labels, and AI-ready fix prompts. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | @@ -79,6 +80,7 @@ The following community-contributed extensions are available in [`catalog.commun | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | +| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index dc0e82a029..5c09339c4f 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,8 +1,39 @@ { "schema_version": "1.0", - "updated_at": "2026-03-13T12:00:00Z", + "updated_at": "2026-03-14T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { + "archive": { + "name": "Archive Extension", + "id": "archive", + "description": "Archive merged features into main project memory, resolving gaps and conflicts.", + "author": "Stanislav Deviatov", + "version": "1.0.0", + "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/stn1slv/spec-kit-archive", + "homepage": "https://github.com/stn1slv/spec-kit-archive", + "documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md", + "changelog": "https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "archive", + "memory", + "merge", + "changelog" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-14T00:00:00Z", + "updated_at": "2026-03-14T00:00:00Z" + }, "azure-devops": { "name": "Azure DevOps Integration", "id": "azure-devops", @@ -250,6 +281,37 @@ "created_at": "2026-03-09T00:00:00Z", "updated_at": "2026-03-09T00:00:00Z" }, + "reconcile": { + "name": "Reconcile Extension", + "id": "reconcile", + "description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.", + "author": "Stanislav Deviatov", + "version": "1.0.0", + "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/stn1slv/spec-kit-reconcile", + "homepage": "https://github.com/stn1slv/spec-kit-reconcile", + "documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md", + "changelog": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "reconcile", + "drift", + "tasks", + "remediation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-14T00:00:00Z", + "updated_at": "2026-03-14T00:00:00Z" + }, "retrospective": { "name": "Retrospective Extension", "id": "retrospective", From bef9c2cb5984332f1190b3e2db6196315344f6aa Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Mon, 16 Mar 2026 14:41:10 +0100 Subject: [PATCH 084/321] fix(extensions): show extension ID in list output (#1843) Display the extension ID below the name in `specify extension list` output. This allows users to easily copy the ID when disambiguation is needed. Fixes #1832 Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- src/specify_cli/__init__.py | 1 + tests/test_extensions.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a45535aee4..8509db7efe 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2576,6 +2576,7 @@ def extension_list( status_color = "green" if ext["enabled"] else "red" console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") + console.print(f" [dim]{ext['id']}[/dim]") console.print(f" {ext['description']}") console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") console.print() diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6299abbb8b..61a3e1c987 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2337,3 +2337,29 @@ def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path): for cmd_file in command_files: assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" + + +class TestExtensionListCLI: + """Test extension list CLI output format.""" + + def test_list_shows_extension_id(self, extension_dir, project_dir): + """extension list should display the extension ID.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install the extension using the manager + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "list"]) + + assert result.exit_code == 0, result.output + # Verify the extension ID is shown in the output + assert "test-ext" in result.output + # Verify name and version are also shown + assert "Test Extension" in result.output + assert "1.0.0" in result.output From 23bd645054439bb10c52411b637d23454ce696ce Mon Sep 17 00:00:00 2001 From: KhawarHabibKhan <132604863+KhawarHabibKhan@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:08:43 +0500 Subject: [PATCH 085/321] Feature: add specify status command (#1837) * feat: add specify status command with project info, agent detection, and feature detection * feat: add SDD artifacts check and task progress parsing to specify status * feat: add workflow phase detection and extensions summary to specify status * Revert "feat: add workflow phase detection and extensions summary to specify status" This reverts commit 1afe3c52af3485b1e73cd3d852311c1e71fbf56c. * Revert "feat: add SDD artifacts check and task progress parsing to specify status" This reverts commit 3be36f87593db6c76b85a904893458600c6fc72e. * Revert "feat: add specify status command with project info, agent detection, and feature detection" This reverts commit 681dc46af9baa707d4de3dae1ad706b9756a9de4. * feat: add spec-kit-status extension to community catalog * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Revert "Potential fix for pull request finding" This reverts commit 040447be03f7adeb9706528fc775a3a912015dbe. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index bbbb078e51..f38cf1b1a7 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -79,6 +79,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | +| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 5c09339c4f..1715c80833 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-14T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -451,6 +451,38 @@ "created_at": "2026-03-07T00:00:00Z", "updated_at": "2026-03-07T00:00:00Z" }, + "status": { + "name": "Project Status", + "id": "status", + "description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-status", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-status", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "status", + "workflow", + "progress", + "feature-tracking", + "task-progress" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From b1650f884d48eb57a9b76bfabb0b205b39099799 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly <63126795+raccioly@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:14:49 -0600 Subject: [PATCH 086/321] chore: update DocGuard extension to v0.9.8 (#1859) * chore: update DocGuard to v0.9.7 * docs: update DocGuard description in extensions table * chore: update DocGuard to v0.9.8 --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- extensions/README.md | 2 +- extensions/catalog.community.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index f38cf1b1a7..8db3d7dd0e 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -75,7 +75,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | -| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks with config-aware traceability, quality labels, and AI-ready fix prompts. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | +| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1715c80833..d2ce824154 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -106,12 +106,12 @@ "updated_at": "2026-02-22T00:00:00Z" }, "docguard": { - "name": "DocGuard — CDD Enforcement", + "name": "DocGuard \u2014 CDD Enforcement", "id": "docguard", - "description": "Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks. Zero dependencies.", + "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies.", "author": "raccioly", - "version": "0.8.0", - "download_url": "https://github.com/raccioly/docguard/releases/download/v0.8.0/spec-kit-docguard-v0.8.0.zip", + "version": "0.9.8", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.8/spec-kit-docguard-v0.9.8.zip", "repository": "https://github.com/raccioly/docguard", "homepage": "https://www.npmjs.com/package/docguard-cli", "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", @@ -129,7 +129,7 @@ }, "provides": { "commands": 6, - "hooks": 1 + "hooks": 3 }, "tags": [ "documentation", @@ -139,13 +139,13 @@ "traceability", "ai-agents", "enforcement", - "scoring" + "spec-kit" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-13T00:00:00Z", - "updated_at": "2026-03-13T00:00:00Z" + "updated_at": "2026-03-15T20:00:00Z" }, "doctor": { "name": "Project Health Check", @@ -413,7 +413,7 @@ "understanding": { "name": "Understanding", "id": "understanding", - "description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", + "description": "Automated requirements quality analysis \u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", "author": "Ladislav Bihari", "version": "3.4.0", "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip", From 2cf332db1bfc703bbb1169b2416e6ae3dfbadfc8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:57:44 -0500 Subject: [PATCH 087/321] docs: add Go / React brownfield walkthrough to community walkthroughs (#1868) Closes #1390 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 006d552270..e97e2d4425 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ See Spec-Driven Development in action across different scenarios with these comm - **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. +- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From 82b8ce429542a8aaffc54b00cd84bfeee0396a1f Mon Sep 17 00:00:00 2001 From: LADISLAV BIHARI <51442396+Testimonial@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:10:37 +0100 Subject: [PATCH 088/321] Add cognitive-squad to community extension catalog (#1870) - Extension ID: cognitive-squad - Version: 0.1.0 - Author: Testimonial - 19-function cognitive agent squad for autonomous pre-code analysis - 7 core agents, 7 specialists, 4 learning functions, feedback loop - Requires: spec-kit >=0.3.0, optionally understanding >=3.4.0 Repository: https://github.com/Testimonial/cognitive-squad Co-authored-by: Ladislav Bihari Co-authored-by: Claude Opus 4.6 (1M context) --- extensions/README.md | 1 + extensions/catalog.community.json | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index 8db3d7dd0e..b7c40803bd 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -75,6 +75,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index d2ce824154..dd03fb1053 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -105,6 +105,50 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "cognitive-squad": { + "name": "Cognitive Squad", + "id": "cognitive-squad", + "description": "19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop", + "author": "Testimonial", + "version": "0.1.0", + "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/Testimonial/cognitive-squad", + "homepage": "https://github.com/Testimonial/cognitive-squad", + "documentation": "https://github.com/Testimonial/cognitive-squad/blob/main/README.md", + "changelog": "https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0", + "tools": [ + { + "name": "understanding", + "version": ">=3.4.0", + "required": false + }, + { + "name": "spec-kit-reverse-eng", + "version": ">=1.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 7, + "hooks": 1 + }, + "tags": [ + "ai-agents", + "cognitive", + "pre-code", + "analysis", + "multi-agent" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" + }, "docguard": { "name": "DocGuard \u2014 CDD Enforcement", "id": "docguard", From 9c0c1446ecc7b8e92d866050b5cbc620d8a307e5 Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 23:51:47 +0100 Subject: [PATCH 089/321] =?UTF-8?q?fix(scripts):=20harden=20bash=20scripts?= =?UTF-8?q?=20=E2=80=94=20escape,=20compat,=20and=20error=20handling=20(#1?= =?UTF-8?q?869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scripts): harden bash scripts with escape, compat, and cleanup fixes - common.sh: complete RFC 8259 JSON escape (\b, \f, strip control chars) - common.sh: distinguish python3 success-empty vs failure in resolve_template - check-prerequisites.sh: escape doc names through json_escape in fallback path - create-new-feature.sh: remove duplicate json_escape (already in common.sh) - create-new-feature.sh: warn on stderr when spec template is not found - update-agent-context.sh: move nested function to top-level for bash 3.2 compat * fix(scripts): explicit resolve_template return code and best-effort agent updates - common.sh: resolve_template now returns 1 when no template is found, making the "not found" case explicit instead of relying on empty stdout - setup-plan.sh, create-new-feature.sh: add || true to resolve_template calls so set -e does not abort on missing templates (non-fatal) - update-agent-context.sh: accumulate errors in update_all_existing_agents instead of silently discarding them — all agents are attempted and the composite result is returned, matching the PowerShell equivalent behavior * style(scripts): add clarifying comment in resolve_template preset branch * fix(scripts): wrap python3 call in if-condition to prevent set -e abort Move the python3 command substitution in resolve_template into an if-condition so that a non-zero exit (e.g. invalid .registry JSON) does not abort the function under set -e. The fallback directory scan now executes as intended regardless of caller errexit settings. * fix(scripts): track agent file existence before update and avoid top-level globals - _update_if_new now records the path and sets _found_agent before calling update_agent_file, so that failures do not cause duplicate attempts on aliased paths (AMP/KIRO/BOB -> AGENTS_FILE) or false "no agent files found" fallback triggers - Remove top-level initialisation of _updated_paths and _found_agent; they are now created exclusively inside update_all_existing_agents, keeping the script side-effect free when sourced --- scripts/bash/check-prerequisites.sh | 2 +- scripts/bash/common.sh | 38 +++++++---- scripts/bash/create-new-feature.sh | 20 ++---- scripts/bash/setup-plan.sh | 2 +- scripts/bash/update-agent-context.sh | 99 +++++++++++++++------------- 5 files changed, 88 insertions(+), 73 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 6f7c99e038..88a5559460 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -168,7 +168,7 @@ if $JSON_MODE; then if [[ ${#docs[@]} -eq 0 ]]; then json_docs="[]" else - json_docs=$(printf '"%s",' "${docs[@]}") + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) json_docs="[${json_docs%,}]" fi printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 52e363e6d4..826e740f00 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -161,7 +161,7 @@ has_jq() { } # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). -# Handles backslash, double-quote, and control characters (newline, tab, carriage return). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). json_escape() { local s="$1" s="${s//\\/\\\\}" @@ -169,6 +169,10 @@ json_escape() { s="${s//$'\n'/\\n}" s="${s//$'\t'/\\t}" s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Strip remaining control characters (U+0000–U+001F) not individually escaped above + s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037') printf '%s' "$s" } @@ -194,9 +198,11 @@ resolve_template() { if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then - # Read preset IDs sorted by priority (lower number = higher precedence) - local sorted_presets - sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " import json, sys, os try: with open(os.environ['SPECKIT_REGISTRY']) as f: @@ -206,14 +212,17 @@ try: print(pid) except Exception: sys.exit(1) -" 2>/dev/null) - if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then - while IFS= read -r preset_id; do - local candidate="$presets_dir/$preset_id/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done <<< "$sorted_presets" +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search else - # python3 returned empty list — fall through to directory scan + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" @@ -246,8 +255,9 @@ except Exception: local core="$base/${template_name}.md" [ -f "$core" ] && echo "$core" && return 0 - # Return success with empty output so callers using set -e don't abort; - # callers check [ -n "$TEMPLATE" ] to detect "not found". - return 0 + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 0823cca274..f7f59884ae 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -162,17 +162,6 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } -# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). -json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\t'/\\t}" - s="${s//$'\r'/\\r}" - printf '%s' "$s" -} - # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -308,9 +297,14 @@ fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" -TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" +fi # Inform the user how to persist the feature variable in their own shell printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 2a044c679e..9f5523149e 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -39,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 mkdir -p "$FEATURE_DIR" # Copy plan template if it exists -TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index e0f2854846..b4b901fb13 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -686,56 +686,67 @@ update_specific_agent() { esac } -update_all_existing_agents() { - local found_agent=false - local _updated_paths=() - - # Helper: skip non-existent files and files already updated (dedup by - # realpath so that variables pointing to the same file — e.g. AMP_FILE, - # KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). - # Uses a linear array instead of associative array for bash 3.2 compatibility. - update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - update_agent_file "$file" "$name" || return 1 - _updated_paths+=("$real_path") - found_agent=true - } +# Helper: skip non-existent files and files already updated (dedup by +# realpath so that variables pointing to the same file — e.g. AMP_FILE, +# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). +# Uses a linear array instead of associative array for bash 3.2 compatibility. +# Note: defined at top level because bash 3.2 does not support true +# nested/local functions. _updated_paths, _found_agent, and _all_ok are +# initialised exclusively inside update_all_existing_agents so that +# sourcing this script has no side effects on the caller's environment. + +_update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + # Record the file as seen before attempting the update so that: + # (a) aliases pointing to the same path are not retried on failure + # (b) _found_agent reflects file existence, not update success + _updated_paths+=("$real_path") + _found_agent=true + update_agent_file "$file" "$name" +} - update_if_new "$CLAUDE_FILE" "Claude Code" - update_if_new "$GEMINI_FILE" "Gemini CLI" - update_if_new "$COPILOT_FILE" "GitHub Copilot" - update_if_new "$CURSOR_FILE" "Cursor IDE" - update_if_new "$QWEN_FILE" "Qwen Code" - update_if_new "$AGENTS_FILE" "Codex/opencode" - update_if_new "$AMP_FILE" "Amp" - update_if_new "$KIRO_FILE" "Kiro CLI" - update_if_new "$BOB_FILE" "IBM Bob" - update_if_new "$WINDSURF_FILE" "Windsurf" - update_if_new "$KILOCODE_FILE" "Kilo Code" - update_if_new "$AUGGIE_FILE" "Auggie CLI" - update_if_new "$ROO_FILE" "Roo Code" - update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" - update_if_new "$SHAI_FILE" "SHAI" - update_if_new "$TABNINE_FILE" "Tabnine CLI" - update_if_new "$QODER_FILE" "Qoder CLI" - update_if_new "$AGY_FILE" "Antigravity" - update_if_new "$VIBE_FILE" "Mistral Vibe" - update_if_new "$KIMI_FILE" "Kimi Code" +update_all_existing_agents() { + _found_agent=false + _updated_paths=() + local _all_ok=true + + _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false + _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false + _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false + _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false + _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false + _update_if_new "$AMP_FILE" "Amp" || _all_ok=false + _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false + _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false + _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false + _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false + _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false + _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false + _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false + _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false + _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false + _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false + _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false + _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false + _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false # If no agent files exist, create a default Claude file - if [[ "$found_agent" == false ]]; then + if [[ "$_found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 fi + + [[ "$_all_ok" == true ]] } print_summary() { echo From 7a5762fe6a974eed070ab01570e57de7e2bbfe4c Mon Sep 17 00:00:00 2001 From: Seiya Kojima Date: Tue, 17 Mar 2026 23:22:34 +0900 Subject: [PATCH 090/321] fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876) In multi-remote environments, `git fetch --all` outputs messages like "Fetching origin" to stdout. Since `check_existing_branches()` only redirected stderr (`2>/dev/null`), the stdout output was captured by the `$(...)` command substitution calling this function, contaminating the branch number return value and causing arithmetic errors like `$((10#Fetching...))`. Fix: redirect both stdout and stderr to /dev/null (`>/dev/null 2>&1`). --- scripts/bash/create-new-feature.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f7f59884ae..58c5c86c48 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -138,7 +138,7 @@ check_existing_branches() { local specs_dir="$1" # Fetch all remotes to get latest branch info (suppress errors if no remotes) - git fetch --all --prune 2>/dev/null || true + git fetch --all --prune >/dev/null 2>&1 || true # Get highest number from ALL branches (not just matching short name) local highest_branch=$(get_highest_from_branches) From d2ecf6560de325449e47e9498a9395bb74d81dc1 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Tue, 17 Mar 2026 15:44:34 +0100 Subject: [PATCH 091/321] feat(extensions,presets): add priority-based resolution ordering (#1855) * feat(extensions,presets): add priority-based resolution ordering Add priority field to extension and preset registries for deterministic template resolution when multiple sources provide the same template. Extensions: - Add `list_by_priority()` method to ExtensionRegistry - Add `--priority` option to `extension add` command - Add `extension set-priority` command - Show priority in `extension list` and `extension info` - Preserve priority during `extension update` - Update RFC documentation Presets: - Add `preset set-priority` command - Show priority in `preset info` output - Use priority ordering in PresetResolver for extensions Both systems: - Lower priority number = higher precedence (default: 10) - Backwards compatible with legacy entries (missing priority defaults to 10) - Comprehensive test coverage including backwards compatibility Closes #1845 Closes #1854 Co-Authored-By: Claude Opus 4.5 * fix: address code review feedback - list_by_priority(): add secondary sort by ID for deterministic ordering, return deep copies to prevent mutation - install_from_directory/zip: validate priority >= 1 early - extension add CLI: validate --priority >= 1 before install - PresetRegistry.update(): preserve installed_at timestamp - Test assertions: use exact source string instead of substring match Co-Authored-By: Claude Opus 4.5 * fix: address additional review feedback - PresetResolver: add fallback to directory scanning when registry is empty/corrupted for robustness and backwards compatibility - PresetRegistry.update(): add guard to prevent injecting installed_at when absent in existing entry (mirrors ExtensionRegistry behavior) - RFC: update extension list example to match actual CLI output format Co-Authored-By: Claude Opus 4.5 * fix: restore defensive code and RFC descriptions lost in rebase - Restore defensive code in list_by_priority() with .get() and isinstance check - Restore detailed --from URL and --dev option descriptions in RFC Co-Authored-By: Claude Opus 4.5 * fix: add defensive code to presets list_by_priority() - Add .get() and isinstance check for corrupted/empty registry - Move copy import to module level (remove local import) - Matches defensive pattern used in extensions.py Co-Authored-By: Claude Opus 4.5 * fix: address reviewer feedback on priority resolution - Rename _normalize_priority to normalize_priority (public API) - Add comprehensive tests for normalize_priority function (9 tests) - Filter non-dict metadata entries in list_by_priority() methods - Fix extension priority resolution to merge registered and unregistered extensions into unified sorted list (unregistered get implicit priority 10) - Add tests for extension priority resolution ordering (4 tests) The key fix ensures unregistered extensions with implicit priority 10 correctly beat registered extensions with priority > 10, and vice versa. Co-Authored-By: Claude Opus 4.5 * fix: DRY refactor and strengthen test assertions - Extract _get_all_extensions_by_priority() helper in PresetResolver to eliminate duplicated extension list construction - Add priority=10 assertion to test_legacy_extension_without_priority_field - Add priority=10 assertion to test_legacy_preset_without_priority_field Co-Authored-By: Claude Opus 4.5 * fix: add isinstance(dict) checks for corrupted registry entries Add defensive checks throughout CLI commands and manager methods to handle cases where registry entries may be corrupted (non-dict values). This prevents AttributeError when calling .get() on non-dict metadata. Locations fixed: - __init__.py: preset/extension info, set-priority, enable/disable, upgrade commands - extensions.py: list_installed() - presets.py: list_installed() Co-Authored-By: Claude Opus 4.5 * fix: normalize priority display to match resolution behavior Use normalize_priority() for all priority display in CLI commands to ensure displayed values match actual resolution behavior when registry data is corrupted/hand-edited. Locations fixed: - extensions.py: list_installed() - presets.py: list_installed(), PresetResolver - __init__.py: preset info, extension info, set-priority commands Also added GraphQL query for unresolved PR comments to CLAUDE.md. Co-Authored-By: Claude Opus 4.5 * fix: repair corrupted priority values in set-priority commands Changed set-priority commands to check if the raw stored value is already a valid int equal to the requested priority before skipping. This ensures corrupted values (e.g., "high") get repaired even when setting to the default priority (10). Also removed CLAUDE.md that was accidentally added to the repo. Co-Authored-By: Claude Opus 4.5 * fix: harden registry update methods against corrupted entries - Normalize priority when restoring during extension update to prevent propagating corrupted values (e.g., "high", 0, negative) - Add isinstance(dict) checks in ExtensionRegistry.update() and PresetRegistry.update() to handle corrupted entries (string/list) that would cause TypeError on merge Co-Authored-By: Claude Opus 4.5 * fix: use safe fallback for version in list_installed() When registry entry is corrupted (non-dict), metadata becomes {} after the isinstance check. Use metadata.get("version", manifest.version) instead of metadata["version"] to avoid KeyError. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- extensions/RFC-EXTENSION-SYSTEM.md | 49 +++- src/specify_cli/__init__.py | 168 +++++++++++- src/specify_cli/extensions.py | 77 +++++- src/specify_cli/presets.py | 160 +++++++++-- tests/test_extensions.py | 427 +++++++++++++++++++++++++++++ tests/test_presets.py | 295 +++++++++++++++++++- 6 files changed, 1114 insertions(+), 62 deletions(-) diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index a0f6034e5c..5d3d8e9cb2 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -359,12 +359,15 @@ specify extension add jira "installed_at": "2026-01-28T14:30:00Z", "source": "catalog", "manifest_hash": "sha256:abc123...", - "enabled": true + "enabled": true, + "priority": 10 } } } ``` +**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template. + ### 3. Configuration ```bash @@ -1084,11 +1087,15 @@ List installed extensions in current project. $ specify extension list Installed Extensions: - ✓ jira (v1.0.0) - Jira Integration - Commands: 3 | Hooks: 2 | Status: Enabled - - ✓ linear (v0.9.0) - Linear Integration - Commands: 1 | Hooks: 1 | Status: Enabled + ✓ Jira Integration (v1.0.0) + jira + Create Jira issues from spec-kit artifacts + Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled + + ✓ Linear Integration (v0.9.0) + linear + Create Linear issues from spec-kit artifacts + Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled ``` **Options:** @@ -1196,10 +1203,9 @@ Next steps: **Options:** -- `--from URL`: Install from custom URL or Git repo -- `--version VERSION`: Install specific version -- `--dev PATH`: Install from local path (development mode) -- `--no-register`: Skip command registration (manual setup) +- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly. +- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument). +- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10) #### `specify extension remove NAME` @@ -1280,6 +1286,29 @@ $ specify extension disable jira To re-enable: specify extension enable jira ``` +#### `specify extension set-priority NAME PRIORITY` + +Change the resolution priority of an installed extension. + +```bash +$ specify extension set-priority jira 5 + +✓ Extension 'Jira Integration' priority changed: 10 → 5 + +Lower priority = higher precedence in template resolution +``` + +**Priority Values:** + +- Lower numbers = higher precedence (checked first in resolution) +- Default priority is 10 +- Must be a positive integer (1 or higher) + +**Use Cases:** + +- Ensure a critical extension's templates take precedence +- Override default resolution order when multiple extensions provide similar templates + --- ## Compatibility & Versioning diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8509db7efe..ec1ccf978e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2000,6 +2000,11 @@ def preset_add( console.print("Run this command from a spec-kit project root") raise typer.Exit(1) + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + manager = PresetManager(project_root) speckit_version = get_speckit_version() @@ -2177,6 +2182,7 @@ def preset_info( pack_id: str = typer.Argument(..., help="Preset ID to get info about"), ): """Show detailed information about a preset.""" + from .extensions import normalize_priority from .presets import PresetCatalog, PresetManager, PresetError project_root = Path.cwd() @@ -2210,6 +2216,10 @@ def preset_info( if license_val: console.print(f" License: {license_val}") console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(pack_id) + priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) + console.print(f" [dim]Priority:[/dim] {priority}") console.print() return @@ -2241,6 +2251,58 @@ def preset_info( console.print() +@preset_app.command("set-priority") +def preset_set_priority( + pack_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(pack_id): + console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(pack_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from .extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(pack_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + # ===== Preset Catalog Commands ===== @@ -2578,7 +2640,7 @@ def extension_list( console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") console.print(f" [dim]{ext['id']}[/dim]") console.print(f" {ext['description']}") - console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") console.print() if available or all_extensions: @@ -2766,6 +2828,7 @@ def extension_add( extension: str = typer.Argument(help="Extension name or path"), dev: bool = typer.Option(False, "--dev", help="Install from local directory"), from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): """Install an extension.""" from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError @@ -2779,6 +2842,11 @@ def extension_add( console.print("Run this command from a spec-kit project root") raise typer.Exit(1) + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + manager = ExtensionManager(project_root) speckit_version = get_speckit_version() @@ -2795,7 +2863,7 @@ def extension_add( console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") raise typer.Exit(1) - manifest = manager.install_from_directory(source_path, speckit_version) + manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) elif from_url: # Install from URL (ZIP file) @@ -2828,7 +2896,7 @@ def extension_add( zip_path.write_bytes(zip_data) # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version) + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) except urllib.error.URLError as e: console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") raise typer.Exit(1) @@ -2872,7 +2940,7 @@ def extension_add( try: # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version) + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) finally: # Clean up downloaded ZIP if zip_path.exists(): @@ -3048,7 +3116,7 @@ def extension_info( extension: str = typer.Argument(help="Extension ID or name"), ): """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager + from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority project_root = Path.cwd() @@ -3085,8 +3153,15 @@ def extension_info( # Get local manifest info ext_manifest = manager.get_extension(resolved_installed_id) metadata = manager.registry.get(resolved_installed_id) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + console.print( + "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " + "some information may be unavailable." + ) + version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" - console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})") + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") console.print(f"ID: {resolved_installed_id}") console.print() @@ -3114,6 +3189,8 @@ def extension_info( console.print() console.print("[green]✓ Installed[/green]") + priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) + console.print(f"[dim]Priority:[/dim] {priority}") console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") return @@ -3129,6 +3206,8 @@ def extension_info( def _print_extension_info(ext_info: dict, manager): """Print formatted extension info from catalog data.""" + from .extensions import normalize_priority + # Header verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") @@ -3207,6 +3286,9 @@ def _print_extension_info(ext_info: dict, manager): install_allowed = ext_info.get("_install_allowed", True) if is_installed: console.print("[green]✓ Installed[/green]") + metadata = manager.registry.get(ext_info['id']) + priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) + console.print(f"[dim]Priority:[/dim] {priority}") console.print(f"\nTo remove: specify extension remove {ext_info['id']}") elif install_allowed: console.print("[yellow]Not installed[/yellow]") @@ -3233,6 +3315,7 @@ def extension_update( ValidationError, CommandRegistrar, HookExecutor, + normalize_priority, ) from packaging import version as pkg_version import shutil @@ -3272,7 +3355,7 @@ def extension_update( for ext_id in extensions_to_update: # Get installed version metadata = manager.registry.get(ext_id) - if metadata is None or "version" not in metadata: + if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") continue try: @@ -3457,13 +3540,13 @@ def extension_update( shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) # 9. Restore metadata from backup (installed_at, enabled state) - if backup_registry_entry: + if backup_registry_entry and isinstance(backup_registry_entry, dict): # Copy current registry entry to avoid mutating internal # registry state before explicit restore(). current_metadata = manager.registry.get(extension_id) - if current_metadata is None: + if current_metadata is None or not isinstance(current_metadata, dict): raise RuntimeError( - f"Registry entry for '{extension_id}' missing after install — update incomplete" + f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" ) new_metadata = dict(current_metadata) @@ -3471,6 +3554,10 @@ def extension_update( if "installed_at" in backup_registry_entry: new_metadata["installed_at"] = backup_registry_entry["installed_at"] + # Preserve the original priority (normalized to handle corruption) + if "priority" in backup_registry_entry: + new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) + # If extension was disabled before update, disable it again if not backup_registry_entry.get("enabled", True): new_metadata["enabled"] = False @@ -3524,7 +3611,7 @@ def extension_update( # (files that weren't in the original backup) try: new_registry_entry = manager.registry.get(extension_id) - if new_registry_entry is None: + if new_registry_entry is None or not isinstance(new_registry_entry, dict): new_registered_commands = {} else: new_registered_commands = new_registry_entry.get("registered_commands", {}) @@ -3644,10 +3731,10 @@ def extension_enable( # Update registry metadata = manager.registry.get(extension_id) - if metadata is None: + if metadata is None or not isinstance(metadata, dict): console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") raise typer.Exit(1) - + if metadata.get("enabled", True): console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") raise typer.Exit(0) @@ -3692,10 +3779,10 @@ def extension_disable( # Update registry metadata = manager.registry.get(extension_id) - if metadata is None: + if metadata is None or not isinstance(metadata, dict): console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") raise typer.Exit(1) - + if not metadata.get("enabled", True): console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") raise typer.Exit(0) @@ -3717,6 +3804,57 @@ def extension_disable( console.print(f"To re-enable: specify extension enable {extension_id}") +@extension_app.command("set-priority") +def extension_set_priority( + extension: str = typer.Argument(help="Extension ID or name"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed extension.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") + + # Get current metadata + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from .extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(extension_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + def main(): app() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 0dfd40b7cd..984ca83d64 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -41,6 +41,26 @@ class CompatibilityError(ExtensionError): pass +def normalize_priority(value: Any, default: int = 10) -> int: + """Normalize a stored priority value for sorting and display. + + Corrupted registry data may contain missing, non-numeric, or non-positive + values. In those cases, fall back to the default priority. + + Args: + value: Priority value to normalize (may be int, str, None, etc.) + default: Default priority to use for invalid values (default: 10) + + Returns: + Normalized priority as positive integer (>= 1) + """ + try: + priority = int(value) + except (TypeError, ValueError): + return default + return priority if priority >= 1 else default + + @dataclass class CatalogEntry: """Represents a single catalog entry in the catalog stack.""" @@ -251,6 +271,9 @@ def update(self, extension_id: str, metadata: dict): raise KeyError(f"Extension '{extension_id}' is not installed") # Merge new metadata with existing, preserving original installed_at existing = self.data["extensions"][extension_id] + # Handle corrupted registry entries (e.g., string/list instead of dict) + if not isinstance(existing, dict): + existing = {} # Merge: existing fields preserved, new fields override merged = {**existing, **metadata} # Always preserve original installed_at based on key existence, not truthiness, @@ -324,6 +347,32 @@ def is_installed(self, extension_id: str) -> bool: """ return extension_id in self.data["extensions"] + def list_by_priority(self) -> List[tuple]: + """Get all installed extensions sorted by priority. + + Lower priority number = higher precedence (checked first). + Extensions with equal priority are sorted alphabetically by ID + for deterministic ordering. + + Returns: + List of (extension_id, metadata_copy) tuples sorted by priority. + Metadata is deep-copied to prevent accidental mutation. + """ + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + extensions = {} + sortable_extensions = [] + for ext_id, meta in extensions.items(): + if not isinstance(meta, dict): + continue + metadata_copy = copy.deepcopy(meta) + metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + sortable_extensions.append((ext_id, metadata_copy)) + return sorted( + sortable_extensions, + key=lambda item: (item[1]["priority"], item[0]), + ) + class ExtensionManager: """Manages extension lifecycle: installation, removal, updates.""" @@ -440,7 +489,8 @@ def install_from_directory( self, source_dir: Path, speckit_version: str, - register_commands: bool = True + register_commands: bool = True, + priority: int = 10, ) -> ExtensionManifest: """Install extension from a local directory. @@ -448,14 +498,19 @@ def install_from_directory( source_dir: Path to extension directory speckit_version: Current spec-kit version register_commands: If True, register commands with AI agents + priority: Resolution priority (lower = higher precedence, default 10) Returns: Installed extension manifest Raises: - ValidationError: If manifest is invalid + ValidationError: If manifest is invalid or priority is invalid CompatibilityError: If extension is incompatible """ + # Validate priority + if priority < 1: + raise ValidationError("Priority must be a positive integer (1 or higher)") + # Load and validate manifest manifest_path = source_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) @@ -497,6 +552,7 @@ def install_from_directory( "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, + "priority": priority, "registered_commands": registered_commands }) @@ -505,21 +561,27 @@ def install_from_directory( def install_from_zip( self, zip_path: Path, - speckit_version: str + speckit_version: str, + priority: int = 10, ) -> ExtensionManifest: """Install extension from ZIP file. Args: zip_path: Path to extension ZIP file speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) Returns: Installed extension manifest Raises: - ValidationError: If manifest is invalid + ValidationError: If manifest is invalid or priority is invalid CompatibilityError: If extension is incompatible """ + # Validate priority early + if priority < 1: + raise ValidationError("Priority must be a positive integer (1 or higher)") + with tempfile.TemporaryDirectory() as tmpdir: temp_path = Path(tmpdir) @@ -554,7 +616,7 @@ def install_from_zip( raise ValidationError("No extension.yml found in ZIP file") # Install from extracted directory - return self.install_from_directory(extension_dir, speckit_version) + return self.install_from_directory(extension_dir, speckit_version, priority=priority) def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. @@ -632,6 +694,9 @@ def list_installed(self) -> List[Dict[str, Any]]: result = [] for ext_id, metadata in self.registry.list().items(): + # Ensure metadata is a dictionary to avoid AttributeError when using .get() + if not isinstance(metadata, dict): + metadata = {} ext_dir = self.extensions_dir / ext_id manifest_path = ext_dir / "extension.yml" @@ -643,6 +708,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "version": metadata.get("version", "unknown"), "description": manifest.description, "enabled": metadata.get("enabled", True), + "priority": normalize_priority(metadata.get("priority")), "installed_at": metadata.get("installed_at"), "command_count": len(manifest.commands), "hook_count": len(manifest.hooks) @@ -655,6 +721,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "version": metadata.get("version", "unknown"), "description": "⚠️ Corrupted extension", "enabled": False, + "priority": normalize_priority(metadata.get("priority")), "installed_at": metadata.get("installed_at"), "command_count": 0, "hook_count": 0 diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 1519633791..121d596178 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -7,6 +7,7 @@ customize the Spec-Driven Development workflow. """ +import copy import json import hashlib import os @@ -23,6 +24,8 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier +from .extensions import ExtensionRegistry, normalize_priority + @dataclass class PresetCatalogEntry: @@ -271,6 +274,38 @@ def remove(self, pack_id: str): del self.data["presets"][pack_id] self._save() + def update(self, pack_id: str, updates: dict): + """Update preset metadata in registry. + + Merges the provided updates with the existing entry, preserving any + fields not specified. The installed_at timestamp is always preserved + from the original entry. + + Args: + pack_id: Preset ID + updates: Partial metadata to merge into existing metadata + + Raises: + KeyError: If preset is not installed + """ + if pack_id not in self.data["presets"]: + raise KeyError(f"Preset '{pack_id}' not found in registry") + existing = self.data["presets"][pack_id] + # Handle corrupted registry entries (e.g., string/list instead of dict) + if not isinstance(existing, dict): + existing = {} + # Merge: existing fields preserved, new fields override + merged = {**existing, **updates} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + self.data["presets"][pack_id] = merged + self._save() + def get(self, pack_id: str) -> Optional[dict]: """Get preset metadata from registry. @@ -294,14 +329,26 @@ def list_by_priority(self) -> List[tuple]: """Get all installed presets sorted by priority. Lower priority number = higher precedence (checked first). + Presets with equal priority are sorted alphabetically by ID + for deterministic ordering. Returns: - List of (pack_id, metadata) tuples sorted by priority + List of (pack_id, metadata_copy) tuples sorted by priority. + Metadata is deep-copied to prevent accidental mutation. """ - packs = self.data["presets"] + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + packs = {} + sortable_packs = [] + for pack_id, meta in packs.items(): + if not isinstance(meta, dict): + continue + metadata_copy = copy.deepcopy(meta) + metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + sortable_packs.append((pack_id, metadata_copy)) return sorted( - packs.items(), - key=lambda item: item[1].get("priority", 10), + sortable_packs, + key=lambda item: (item[1]["priority"], item[0]), ) def is_installed(self, pack_id: str) -> bool: @@ -680,9 +727,13 @@ def install_from_directory( Installed preset manifest Raises: - PresetValidationError: If manifest is invalid + PresetValidationError: If manifest is invalid or priority is invalid PresetCompatibilityError: If pack is incompatible """ + # Validate priority + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + manifest_path = source_dir / "preset.yml" manifest = PresetManifest(manifest_path) @@ -729,14 +780,19 @@ def install_from_zip( Args: zip_path: Path to preset ZIP file speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) Returns: Installed preset manifest Raises: - PresetValidationError: If manifest is invalid + PresetValidationError: If manifest is invalid or priority is invalid PresetCompatibilityError: If pack is incompatible """ + # Validate priority early + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + with tempfile.TemporaryDirectory() as tmpdir: temp_path = Path(tmpdir) @@ -808,6 +864,9 @@ def list_installed(self) -> List[Dict[str, Any]]: result = [] for pack_id, metadata in self.registry.list().items(): + # Ensure metadata is a dictionary to avoid AttributeError when using .get() + if not isinstance(metadata, dict): + metadata = {} pack_dir = self.presets_dir / pack_id manifest_path = pack_dir / "preset.yml" @@ -816,13 +875,13 @@ def list_installed(self) -> List[Dict[str, Any]]: result.append({ "id": pack_id, "name": manifest.name, - "version": metadata["version"], + "version": metadata.get("version", manifest.version), "description": manifest.description, "enabled": metadata.get("enabled", True), "installed_at": metadata.get("installed_at"), "template_count": len(manifest.templates), "tags": manifest.tags, - "priority": metadata.get("priority", 10), + "priority": normalize_priority(metadata.get("priority")), }) except PresetValidationError: result.append({ @@ -834,7 +893,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "installed_at": metadata.get("installed_at"), "template_count": 0, "tags": [], - "priority": metadata.get("priority", 10), + "priority": normalize_priority(metadata.get("priority")), }) return result @@ -1393,6 +1452,40 @@ def __init__(self, project_root: Path): self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" + def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: + """Build unified list of registered and unregistered extensions sorted by priority. + + Registered extensions use their stored priority; unregistered directories + get implicit priority=10. Results are sorted by (priority, ext_id) for + deterministic ordering. + + Returns: + List of (priority, ext_id, metadata_or_none) tuples sorted by priority. + """ + if not self.extensions_dir.exists(): + return [] + + registry = ExtensionRegistry(self.extensions_dir) + registered_extensions = registry.list_by_priority() + registered_extension_ids = {ext_id for ext_id, _ in registered_extensions} + + all_extensions: list[tuple[int, str, dict | None]] = [] + + for ext_id, metadata in registered_extensions: + priority = normalize_priority(metadata.get("priority") if metadata else None) + all_extensions.append((priority, ext_id, metadata)) + + # Add unregistered directories with implicit priority=10 + for ext_dir in self.extensions_dir.iterdir(): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + if ext_dir.name not in registered_extension_ids: + all_extensions.append((10, ext_dir.name, None)) + + # Sort by (priority, ext_id) for deterministic ordering + all_extensions.sort(key=lambda x: (x[0], x[1])) + return all_extensions + def resolve( self, template_name: str, @@ -1445,18 +1538,18 @@ def resolve( if candidate.exists(): return candidate - # Priority 3: Extension-provided templates - if self.extensions_dir.exists(): - for ext_dir in sorted(self.extensions_dir.iterdir()): - if not ext_dir.is_dir() or ext_dir.name.startswith("."): - continue - for subdir in subdirs: - if subdir: - candidate = ext_dir / subdir / f"{template_name}{ext}" - else: - candidate = ext_dir / "templates" / f"{template_name}{ext}" - if candidate.exists(): - return candidate + # Priority 3: Extension-provided templates (sorted by priority — lower number wins) + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + for subdir in subdirs: + if subdir: + candidate = ext_dir / subdir / f"{template_name}{ext}" + else: + candidate = ext_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate # Priority 4: Core templates if template_type == "template": @@ -1514,17 +1607,24 @@ def resolve_with_source( except ValueError: continue - if self.extensions_dir.exists(): - for ext_dir in sorted(self.extensions_dir.iterdir()): - if not ext_dir.is_dir() or ext_dir.name.startswith("."): - continue - try: - resolved.relative_to(ext_dir) + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + try: + resolved.relative_to(ext_dir) + if ext_meta: + version = ext_meta.get("version", "?") return { "path": resolved_str, - "source": f"extension:{ext_dir.name}", + "source": f"extension:{ext_id} v{version}", } - except ValueError: - continue + else: + return { + "path": resolved_str, + "source": f"extension:{ext_id} (unregistered)", + } + except ValueError: + continue return {"path": resolved_str, "source": "core"} diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61a3e1c987..c87ba5b533 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -26,6 +26,7 @@ ExtensionError, ValidationError, CompatibilityError, + normalize_priority, version_satisfies, ) @@ -121,6 +122,57 @@ def project_dir(temp_dir): return proj_dir +# ===== normalize_priority Tests ===== + +class TestNormalizePriority: + """Test normalize_priority helper function.""" + + def test_valid_integer(self): + """Test with valid integer priority.""" + assert normalize_priority(5) == 5 + assert normalize_priority(1) == 1 + assert normalize_priority(100) == 100 + + def test_valid_string_number(self): + """Test with string that can be converted to int.""" + assert normalize_priority("5") == 5 + assert normalize_priority("10") == 10 + + def test_zero_returns_default(self): + """Test that zero priority returns default.""" + assert normalize_priority(0) == 10 + assert normalize_priority(0, default=5) == 5 + + def test_negative_returns_default(self): + """Test that negative priority returns default.""" + assert normalize_priority(-1) == 10 + assert normalize_priority(-100, default=5) == 5 + + def test_none_returns_default(self): + """Test that None returns default.""" + assert normalize_priority(None) == 10 + assert normalize_priority(None, default=5) == 5 + + def test_invalid_string_returns_default(self): + """Test that non-numeric string returns default.""" + assert normalize_priority("invalid") == 10 + assert normalize_priority("abc", default=5) == 5 + + def test_float_truncates(self): + """Test that float is truncated to int.""" + assert normalize_priority(5.9) == 5 + assert normalize_priority(3.1) == 3 + + def test_empty_string_returns_default(self): + """Test that empty string returns default.""" + assert normalize_priority("") == 10 + + def test_custom_default(self): + """Test custom default value.""" + assert normalize_priority(None, default=20) == 20 + assert normalize_priority("invalid", default=1) == 1 + + # ===== ExtensionManifest Tests ===== class TestExtensionManifest: @@ -2363,3 +2415,378 @@ def test_list_shows_extension_id(self, extension_dir, project_dir): # Verify name and version are also shown assert "Test Extension" in result.output assert "1.0.0" in result.output + + +class TestExtensionPriority: + """Test extension priority-based resolution.""" + + def test_list_by_priority_empty(self, temp_dir): + """Test list_by_priority on empty registry.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + result = registry.list_by_priority() + + assert result == [] + + def test_list_by_priority_single(self, temp_dir): + """Test list_by_priority with single extension.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "priority": 5}) + + result = registry.list_by_priority() + + assert len(result) == 1 + assert result[0][0] == "test-ext" + assert result[0][1]["priority"] == 5 + + def test_list_by_priority_ordering(self, temp_dir): + """Test list_by_priority returns extensions sorted by priority.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + # Add in non-priority order + registry.add("ext-low", {"version": "1.0.0", "priority": 20}) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.add("ext-mid", {"version": "1.0.0", "priority": 10}) + + result = registry.list_by_priority() + + assert len(result) == 3 + # Lower priority number = higher precedence (first) + assert result[0][0] == "ext-high" + assert result[1][0] == "ext-mid" + assert result[2][0] == "ext-low" + + def test_list_by_priority_default(self, temp_dir): + """Test list_by_priority uses default priority of 10.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + # Add without explicit priority + registry.add("ext-default", {"version": "1.0.0"}) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.add("ext-low", {"version": "1.0.0", "priority": 20}) + + result = registry.list_by_priority() + + assert len(result) == 3 + # ext-high (1), ext-default (10), ext-low (20) + assert result[0][0] == "ext-high" + assert result[1][0] == "ext-default" + assert result[2][0] == "ext-low" + + def test_list_by_priority_invalid_priority_defaults(self, temp_dir): + """Malformed priority values fall back to the default priority.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.data["extensions"]["ext-invalid"] = { + "version": "1.0.0", + "priority": "high", + } + registry._save() + + result = registry.list_by_priority() + + assert [item[0] for item in result] == ["ext-high", "ext-invalid"] + assert result[1][1]["priority"] == 10 + + def test_install_with_priority(self, extension_dir, project_dir): + """Test that install_from_directory stores priority.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5) + + metadata = manager.registry.get("test-ext") + assert metadata["priority"] == 5 + + def test_install_default_priority(self, extension_dir, project_dir): + """Test that install_from_directory uses default priority of 10.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + metadata = manager.registry.get("test-ext") + assert metadata["priority"] == 10 + + def test_list_installed_includes_priority(self, extension_dir, project_dir): + """Test that list_installed includes priority in returned data.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=3) + + installed = manager.list_installed() + + assert len(installed) == 1 + assert installed[0]["priority"] == 3 + + def test_priority_preserved_on_update(self, temp_dir): + """Test that registry update preserves priority.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "priority": 5, "enabled": True}) + + # Update with new metadata (no priority specified) + registry.update("test-ext", {"enabled": False}) + + updated = registry.get("test-ext") + assert updated["priority"] == 5 # Preserved + assert updated["enabled"] is False # Updated + + def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir): + """Resolution scans unregistered extension dirs after valid registry entries.""" + extensions_dir = project_dir / ".specify" / "extensions" + + valid_dir = extensions_dir / "valid-ext" / "templates" + valid_dir.mkdir(parents=True) + (valid_dir / "other-template.md").write_text("# Valid\n") + + broken_dir = extensions_dir / "broken-ext" / "templates" + broken_dir.mkdir(parents=True) + (broken_dir / "target-template.md").write_text("# Broken Target\n") + + registry = ExtensionRegistry(extensions_dir) + registry.add("valid-ext", {"version": "1.0.0", "priority": 10}) + registry.data["extensions"]["broken-ext"] = "corrupted" + registry._save() + + from specify_cli.presets import PresetResolver + + resolver = PresetResolver(project_dir) + resolved = resolver.resolve("target-template") + sourced = resolver.resolve_with_source("target-template") + + assert resolved is not None + assert resolved.name == "target-template.md" + assert "Broken Target" in resolved.read_text() + assert sourced is not None + assert sourced["source"] == "extension:broken-ext (unregistered)" + + +class TestExtensionPriorityCLI: + """Test extension priority CLI integration.""" + + def test_add_with_priority_option(self, extension_dir, project_dir): + """Test extension add command with --priority option.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, [ + "extension", "add", str(extension_dir), "--dev", "--priority", "3" + ]) + + assert result.exit_code == 0, result.output + + manager = ExtensionManager(project_dir) + metadata = manager.registry.get("test-ext") + assert metadata["priority"] == 3 + + def test_list_shows_priority(self, extension_dir, project_dir): + """Test extension list shows priority.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install extension with priority + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "list"]) + + assert result.exit_code == 0, result.output + assert "Priority: 7" in result.output + + def test_set_priority_changes_priority(self, extension_dir, project_dir): + """Test set-priority command changes extension priority.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install extension with default priority + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Verify default priority + assert manager.registry.get("test-ext")["priority"] == 10 + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) + + assert result.exit_code == 0, result.output + assert "priority changed: 10 → 5" in result.output + + # Reload registry to see updated value + manager2 = ExtensionManager(project_dir) + assert manager2.registry.get("test-ext")["priority"] == 5 + + def test_set_priority_same_value_no_change(self, extension_dir, project_dir): + """Test set-priority with same value shows already set message.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install extension with priority 5 + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) + + assert result.exit_code == 0, result.output + assert "already has priority 5" in result.output + + def test_set_priority_invalid_value(self, extension_dir, project_dir): + """Test set-priority rejects invalid priority values.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install extension + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "0"]) + + assert result.exit_code == 1, result.output + assert "Priority must be a positive integer" in result.output + + def test_set_priority_not_installed(self, project_dir): + """Test set-priority fails for non-installed extension.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Ensure .specify exists + (project_dir / ".specify").mkdir(parents=True, exist_ok=True) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower() + + def test_set_priority_by_display_name(self, extension_dir, project_dir): + """Test set-priority works with extension display name.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install extension + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Use display name "Test Extension" instead of ID "test-ext" + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"]) + + assert result.exit_code == 0, result.output + assert "priority changed" in result.output + + # Reload registry to see updated value + manager2 = ExtensionManager(project_dir) + assert manager2.registry.get("test-ext")["priority"] == 3 + + +class TestExtensionPriorityBackwardsCompatibility: + """Test backwards compatibility for extensions installed before priority feature.""" + + def test_legacy_extension_without_priority_field(self, temp_dir): + """Extensions installed before priority feature should default to 10.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + # Simulate legacy registry entry without priority field + registry = ExtensionRegistry(extensions_dir) + registry.data["extensions"]["legacy-ext"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + "installed_at": "2025-01-01T00:00:00Z", + # No "priority" field - simulates pre-feature extension + } + registry._save() + + # Reload registry + registry2 = ExtensionRegistry(extensions_dir) + + # list_by_priority should use default of 10 + result = registry2.list_by_priority() + assert len(result) == 1 + assert result[0][0] == "legacy-ext" + # Priority defaults to 10 and is normalized in returned metadata + assert result[0][1]["priority"] == 10 + + def test_legacy_extension_in_list_installed(self, extension_dir, project_dir): + """list_installed returns priority=10 for legacy extensions without priority field.""" + manager = ExtensionManager(project_dir) + + # Install extension normally + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Manually remove priority to simulate legacy extension + ext_data = manager.registry.data["extensions"]["test-ext"] + del ext_data["priority"] + manager.registry._save() + + # list_installed should still return priority=10 + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 10 + + def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir): + """Legacy extensions (no priority) sort with default=10 among prioritized extensions.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + # Add extension with explicit priority=5 + registry.add("ext-with-priority", {"version": "1.0.0", "priority": 5}) + + # Add legacy extension without priority (manually) + registry.data["extensions"]["legacy-ext"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + # No priority field + } + registry._save() + + # Add extension with priority=15 + registry.add("ext-low-priority", {"version": "1.0.0", "priority": 15}) + + # Reload and check ordering + registry2 = ExtensionRegistry(extensions_dir) + result = registry2.list_by_priority() + + assert len(result) == 3 + # Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15) + assert result[0][0] == "ext-with-priority" + assert result[1][0] == "legacy-ext" + assert result[2][0] == "ext-low-priority" diff --git a/tests/test_presets.py b/tests/test_presets.py index 3ad70c6d14..b6fe81d5ba 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -32,6 +32,7 @@ PresetCompatibilityError, VALID_PRESET_TEMPLATE_TYPES, ) +from specify_cli.extensions import ExtensionRegistry # ===== Fixtures ===== @@ -573,6 +574,24 @@ def test_list_by_priority_default(self, temp_dir): assert sorted_packs[0][0] == "pack-b" assert sorted_packs[1][0] == "pack-a" + def test_list_by_priority_invalid_priority_defaults(self, temp_dir): + """Malformed priority values fall back to the default priority.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-high", {"version": "1.0.0", "priority": 1}) + registry.data["presets"]["pack-invalid"] = { + "version": "1.0.0", + "priority": "high", + } + registry._save() + + sorted_packs = registry.list_by_priority() + + assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"] + assert sorted_packs[1][1]["priority"] == 10 + # ===== PresetResolver Tests ===== @@ -678,6 +697,11 @@ def test_resolve_extension_provided_templates(self, project_dir): ext_template = ext_templates_dir / "custom-template.md" ext_template.write_text("# Extension Custom Template\n") + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) + resolver = PresetResolver(project_dir) result = resolver.resolve("custom-template") assert result is not None @@ -741,10 +765,15 @@ def test_resolve_with_source_extension(self, project_dir): ext_template = ext_templates_dir / "unique-template.md" ext_template.write_text("# Unique\n") + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) + resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("unique-template") assert result is not None - assert result["source"] == "extension:my-ext" + assert result["source"] == "extension:my-ext v1.0.0" def test_resolve_with_source_not_found(self, project_dir): """Test resolve_with_source for nonexistent template.""" @@ -765,6 +794,104 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): assert result is None +class TestExtensionPriorityResolution: + """Test extension priority resolution with registered and unregistered extensions.""" + + def test_unregistered_beats_registered_with_lower_precedence(self, project_dir): + """Unregistered extension (implicit priority 10) beats registered with priority 20.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 20 (lower precedence than 10) + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) + + # Create unregistered extension directory (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Unregistered (priority 10) should beat registered (priority 20) + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From Unregistered" in result.read_text() + + def test_registered_with_higher_precedence_beats_unregistered(self, project_dir): + """Registered extension with priority 5 beats unregistered (implicit priority 10).""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 5 (higher precedence than 10) + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5}) + + # Create unregistered extension directory (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Registered (priority 5) should beat unregistered (priority 10) + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From Registered" in result.read_text() + + def test_unregistered_attribution_with_priority_ordering(self, project_dir): + """Test resolve_with_source correctly attributes unregistered extension.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 20 + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) + + # Create unregistered extension (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Attribution should show unregistered extension + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("test-template") + assert result is not None + assert "unregistered-ext" in result["source"] + assert "(unregistered)" in result["source"] + + def test_same_priority_sorted_alphabetically(self, project_dir): + """Extensions with same priority are sorted alphabetically by ID.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create two unregistered extensions (both implicit priority 10) + # "aaa-ext" should come before "zzz-ext" alphabetically + zzz_dir = extensions_dir / "zzz-ext" + (zzz_dir / "templates").mkdir(parents=True) + (zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n") + + aaa_dir = extensions_dir / "aaa-ext" + (aaa_dir / "templates").mkdir(parents=True) + (aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n") + + # AAA should win due to alphabetical ordering at same priority + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From AAA" in result.read_text() + + # ===== PresetCatalog Tests ===== @@ -979,8 +1106,13 @@ def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_ ext_templates_dir.mkdir(parents=True) (ext_templates_dir / "spec-template.md").write_text("# Extension\n") + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) + result = resolver.resolve_with_source("spec-template") - assert result["source"] == "extension:my-ext" + assert result["source"] == "extension:my-ext v1.0.0" # Install pack — should win over extension manager = PresetManager(project_dir) @@ -1710,3 +1842,162 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d metadata = manager.registry.get("self-test") assert metadata.get("registered_skills", []) == [] + + +class TestPresetSetPriority: + """Test preset set-priority CLI command.""" + + def test_set_priority_changes_priority(self, project_dir, pack_dir): + """Test set-priority command changes preset priority.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset with default priority + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify default priority + assert manager.registry.get("test-pack")["priority"] == 10 + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + assert "priority changed: 10 → 5" in result.output + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["priority"] == 5 + + def test_set_priority_same_value_no_change(self, project_dir, pack_dir): + """Test set-priority with same value shows already set message.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset with priority 5 + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + assert "already has priority 5" in result.output + + def test_set_priority_invalid_value(self, project_dir, pack_dir): + """Test set-priority rejects invalid priority values.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) + + assert result.exit_code == 1, result.output + assert "Priority must be a positive integer" in result.output + + def test_set_priority_not_installed(self, project_dir): + """Test set-priority fails for non-installed preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + +class TestPresetPriorityBackwardsCompatibility: + """Test backwards compatibility for presets installed before priority feature.""" + + def test_legacy_preset_without_priority_field(self, temp_dir): + """Presets installed before priority feature should default to 10.""" + presets_dir = temp_dir / ".specify" / "presets" + presets_dir.mkdir(parents=True) + + # Simulate legacy registry entry without priority field + registry = PresetRegistry(presets_dir) + registry.data["presets"]["legacy-pack"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + "installed_at": "2025-01-01T00:00:00Z", + # No "priority" field - simulates pre-feature preset + } + registry._save() + + # Reload registry + registry2 = PresetRegistry(presets_dir) + + # list_by_priority should use default of 10 + result = registry2.list_by_priority() + assert len(result) == 1 + assert result[0][0] == "legacy-pack" + # Priority defaults to 10 and is normalized in returned metadata + assert result[0][1]["priority"] == 10 + + def test_legacy_preset_in_list_installed(self, project_dir, pack_dir): + """list_installed returns priority=10 for legacy presets without priority field.""" + manager = PresetManager(project_dir) + + # Install preset normally + manager.install_from_directory(pack_dir, "0.1.5") + + # Manually remove priority to simulate legacy preset + pack_data = manager.registry.data["presets"]["test-pack"] + del pack_data["priority"] + manager.registry._save() + + # list_installed should still return priority=10 + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 10 + + def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): + """Legacy presets (no priority) sort with default=10 among prioritized presets.""" + presets_dir = temp_dir / ".specify" / "presets" + presets_dir.mkdir(parents=True) + + registry = PresetRegistry(presets_dir) + + # Add preset with explicit priority=5 + registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5}) + + # Add legacy preset without priority (manually) + registry.data["presets"]["legacy-pack"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + # No priority field + } + + # Add another preset with priority=15 + registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15}) + registry._save() + + # Reload and check ordering + registry2 = PresetRegistry(presets_dir) + sorted_presets = registry2.list_by_priority() + + # Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15) + assert [p[0] for p in sorted_presets] == [ + "pack-with-priority", + "legacy-pack", + "low-priority-pack", + ] From c12b8c176344c787f55fd71ae352575ec38f7d9d Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Tue, 17 Mar 2026 22:51:13 +0800 Subject: [PATCH 092/321] feat(cli): polite deep merge for settings.json and support JSONC (#1874) * feat(cli): polite deep merge for settings.json with json5 and safe atomic write * fix(cli): prevent temp fd leak and align merge-policy docs --- CHANGELOG.md | 1 + pyproject.toml | 1 + src/specify_cli/__init__.py | 136 +++++++++++++++++++++----- tests/test_merge.py | 190 ++++++++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 tests/test_merge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1b6a47a0..a479928172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks - feat(presets): Pluggable preset system with preset catalog and template resolver - Preset manifest (`preset.yml`) with validation for artifact, command, and script types - `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` diff --git a/pyproject.toml b/pyproject.toml index cdbad2e013..322abe3a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyyaml>=6.0", "packaging>=23.0", "pathspec>=0.12.0", + "json5>=0.13.0", ] [project.scripts] diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ec1ccf978e..f30e58f965 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -7,6 +7,7 @@ # "platformdirs", # "readchar", # "httpx", +# "json5", # ] # /// """ @@ -32,6 +33,8 @@ import shutil import shlex import json +import json5 +import stat import yaml from pathlib import Path from typing import Any, Optional, Tuple @@ -654,37 +657,82 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option os.chdir(original_cwd) def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: - """Handle merging or copying of .vscode/settings.json files.""" + """Handle merging or copying of .vscode/settings.json files. + + Note: when merge produces changes, rewritten output is normalized JSON and + existing JSONC comments/trailing commas are not preserved. + """ def log(message, color="green"): if verbose and not tracker: console.print(f"[{color}]{message}[/] {rel_path}") + def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: + """Atomically write JSON while preserving existing mode bits when possible.""" + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + dir=target_file.parent, + prefix=f"{target_file.name}.", + suffix=".tmp", + delete=False, + ) as f: + temp_path = Path(f.name) + json.dump(payload, f, indent=4) + f.write('\n') + + if target_file.exists(): + try: + existing_stat = target_file.stat() + os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode)) + if hasattr(os, "chown"): + try: + os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid) + except PermissionError: + # Best-effort owner/group preservation without requiring elevated privileges. + pass + except OSError: + # Best-effort metadata preservation; data safety is prioritized. + pass + + os.replace(temp_path, target_file) + except Exception: + if temp_path and temp_path.exists(): + temp_path.unlink() + raise + try: with open(sub_item, 'r', encoding='utf-8') as f: - new_settings = json.load(f) + # json5 natively supports comments and trailing commas (JSONC) + new_settings = json5.load(f) if dest_file.exists(): merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) - with open(dest_file, 'w', encoding='utf-8') as f: - json.dump(merged, f, indent=4) - f.write('\n') - log("Merged:", "green") + if merged is not None: + atomic_write_json(dest_file, merged) + log("Merged:", "green") + log("Note: comments/trailing commas are normalized when rewritten", "yellow") + else: + log("Skipped merge (preserved existing settings)", "yellow") else: shutil.copy2(sub_item, dest_file) log("Copied (no existing settings.json):", "blue") except Exception as e: - log(f"Warning: Could not merge, copying instead: {e}", "yellow") - shutil.copy2(sub_item, dest_file) + log(f"Warning: Could not merge settings: {e}", "yellow") + if not dest_file.exists(): + shutil.copy2(sub_item, dest_file) -def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict: + +def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: """Merge new JSON content into existing JSON file. - Performs a deep merge where: + Performs a polite deep merge where: - New keys are added - - Existing keys are preserved unless overwritten by new content - - Nested dictionaries are merged recursively - - Lists and other values are replaced (not merged) + - Existing keys are preserved (not overwritten) unless both values are dictionaries + - Nested dictionaries are merged recursively only when both sides are dictionaries + - Lists and other values are preserved from base if they exist Args: existing_path: Path to existing JSON file @@ -692,28 +740,64 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal verbose: Whether to print merge details Returns: - Merged JSON content as dict + Merged JSON content as dict, or None if the existing file should be left untouched. """ - try: - with open(existing_path, 'r', encoding='utf-8') as f: - existing_content = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - # If file doesn't exist or is invalid, just use new content + # Load existing content first to have a safe fallback + existing_content = None + exists = existing_path.exists() + + if exists: + try: + with open(existing_path, 'r', encoding='utf-8') as f: + # Handle comments (JSONC) natively with json5 + # Note: json5 handles BOM automatically + existing_content = json5.load(f) + except FileNotFoundError: + # Handle race condition where file is deleted after exists() check + exists = False + except Exception as e: + if verbose: + console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") + # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) + return None + + # Validate template content + if not isinstance(new_content, dict): + if verbose: + console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") + return None + + if not exists: return new_content - def deep_merge(base: dict, update: dict) -> dict: - """Recursively merge update dict into base dict.""" + # If existing content parsed but is not a dict, skip merge to avoid data loss + if not isinstance(existing_content, dict): + if verbose: + console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") + return None + + def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Recursively merge update dict into base dict, preserving base values.""" result = base.copy() for key, value in update.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if key not in result: + # Add new key + result[key] = value + elif isinstance(result[key], dict) and isinstance(value, dict): # Recursively merge nested dictionaries - result[key] = deep_merge(result[key], value) + result[key] = deep_merge_polite(result[key], value) else: - # Add new key or replace existing value - result[key] = value + # Key already exists and values are not both dicts; preserve existing value. + # This ensures user settings aren't overwritten by template defaults. + pass return result - merged = deep_merge(existing_content, new_content) + merged = deep_merge_polite(existing_content, new_content) + + # Detect if anything actually changed. If not, return None so the caller + # can skip rewriting the file (preserving user's comments/formatting). + if merged == existing_content: + return None if verbose: console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000000..69f946ccc5 --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,190 @@ +import stat + +from specify_cli import merge_json_files +from specify_cli import handle_vscode_settings + +# --- Dimension 2: Polite Deep Merge Strategy --- + +def test_merge_json_files_type_mismatch_preservation(tmp_path): + """If user has a string but template wants a dict, PRESERVE user's string.""" + existing_file = tmp_path / "settings.json" + # User might have overridden a setting with a simple string or different type + existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}') + + # Template might expect a dict for the same key (hypothetically) + new_settings = { + "chat.editor.fontFamily": {"font": "TemplateFont"} + } + + merged = merge_json_files(existing_file, new_settings) + # Result is None because user settings were preserved and nothing else changed + assert merged is None + +def test_merge_json_files_deep_nesting(tmp_path): + """Verify deep recursive merging of new keys.""" + existing_file = tmp_path / "settings.json" + existing_file.write_text(""" + { + "a": { + "b": { + "c": 1 + } + } + } + """) + + new_settings = { + "a": { + "b": { + "d": 2 # New nested key + }, + "e": 3 # New mid-level key + } + } + + merged = merge_json_files(existing_file, new_settings) + assert merged["a"]["b"]["c"] == 1 + assert merged["a"]["b"]["d"] == 2 + assert merged["a"]["e"] == 3 + +def test_merge_json_files_empty_existing(tmp_path): + """Merging into an empty/new file.""" + existing_file = tmp_path / "empty.json" + existing_file.write_text("{}") + + new_settings = {"a": 1} + merged = merge_json_files(existing_file, new_settings) + assert merged == {"a": 1} + +# --- Dimension 3: Real-world Simulation --- + +def test_merge_vscode_realistic_scenario(tmp_path): + """A realistic VSCode settings.json with many existing preferences, comments, and trailing commas.""" + existing_file = tmp_path / "vscode_settings.json" + existing_file.write_text(""" + { + "editor.fontSize": 12, + "editor.formatOnSave": true, /* block comment */ + "files.exclude": { + "**/.git": true, + "**/node_modules": true, + }, + "chat.promptFilesRecommendations": { + "existing.tool": true, + } // User comment + } + """) + + template_settings = { + "chat.promptFilesRecommendations": { + "speckit.specify": True, + "speckit.plan": True + }, + "chat.tools.terminal.autoApprove": { + ".specify/scripts/bash/": True + } + } + + merged = merge_json_files(existing_file, template_settings) + + # Check preservation + assert merged["editor.fontSize"] == 12 + assert merged["files.exclude"]["**/.git"] is True + assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True + + # Check additions + assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True + assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True + +# --- Dimension 4: Error Handling & Robustness --- + +def test_merge_json_files_with_bom(tmp_path): + """Test files with UTF-8 BOM (sometimes created on Windows).""" + existing_file = tmp_path / "bom.json" + content = '{"a": 1}' + # Prepend UTF-8 BOM + existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8')) + + new_settings = {"b": 2} + merged = merge_json_files(existing_file, new_settings) + assert merged == {"a": 1, "b": 2} + +def test_merge_json_files_not_a_dictionary_template(tmp_path): + """If for some reason new_content is not a dict, PRESERVE existing settings by returning None.""" + existing_file = tmp_path / "ok.json" + existing_file.write_text('{"a": 1}') + + # Secure fallback: return None to skip writing and avoid clobbering + assert merge_json_files(existing_file, ["not", "a", "dict"]) is None + +def test_merge_json_files_unparseable_existing(tmp_path): + """If the existing file is unparseable JSON, return None to avoid overwriting it.""" + bad_file = tmp_path / "bad.json" + bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON + + assert merge_json_files(bad_file, {"b": 2}) is None + + +def test_merge_json_files_list_preservation(tmp_path): + """Verify that existing list values are preserved and NOT merged or overwritten.""" + existing_file = tmp_path / "list.json" + existing_file.write_text('{"my.list": ["user_item"]}') + + template_settings = { + "my.list": ["template_item"] + } + + merged = merge_json_files(existing_file, template_settings) + # The polite merge policy says: keep existing values if they exist and aren't both dicts. + # Since nothing changed, it returns None. + assert merged is None + +def test_merge_json_files_no_changes(tmp_path): + """If the merge doesn't introduce any new keys or changes, return None to skip rewrite.""" + existing_file = tmp_path / "no_change.json" + existing_file.write_text('{"a": 1, "b": {"c": 2}}') + + template_settings = { + "a": 1, # Already exists + "b": {"c": 2} # Already exists nested + } + + # Should return None because result == existing + assert merge_json_files(existing_file, template_settings) is None + +def test_merge_json_files_type_mismatch_no_op(tmp_path): + """If a key exists with different type and we preserve it, it might still result in no change.""" + existing_file = tmp_path / "mismatch_no_op.json" + existing_file.write_text('{"a": "user_string"}') + + template_settings = { + "a": {"key": "template_dict"} # Mismatch, will be ignored + } + + # Should return None because we preserved the user's string and nothing else changed + assert merge_json_files(existing_file, template_settings) is None + + +def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path): + """Atomic rewrite should preserve existing file mode bits.""" + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + dest_file = vscode_dir / "settings.json" + template_file = tmp_path / "template_settings.json" + + dest_file.write_text('{"a": 1}\n', encoding="utf-8") + dest_file.chmod(0o640) + before_mode = stat.S_IMODE(dest_file.stat().st_mode) + + template_file.write_text('{"b": 2}\n', encoding="utf-8") + + handle_vscode_settings( + template_file, + dest_file, + "settings.json", + verbose=False, + tracker=None, + ) + + after_mode = stat.S_IMODE(dest_file.stat().st_mode) + assert after_mode == before_mode From a177a1a6d1493cd72adb7c0354a8e3a05453daf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=BB=84=E6=B1=AA?= <50308467+huangguang1999@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:03:54 +0800 Subject: [PATCH 093/321] feat: add Trae IDE support as a new agent (#1817) * feat: add Trae IDE support as a new agent Add Trae (https://www.trae.ai/) as a supported AI agent in spec-kit. Trae is an IDE-based agent that uses .trae/rules/ directory for project-level rules in Markdown format. Changes across 9 files: - src/specify_cli/__init__.py: Add trae to AGENT_CONFIG (IDE-based, .trae/ folder, rules subdir, no CLI required) - src/specify_cli/extensions.py: Add trae to CommandRegistrar.AGENT_CONFIGS (.trae/rules, markdown format, .md extension) - README.md: Add Trae to supported agents table, CLI examples, and --ai option description - .github/workflows/scripts/create-release-packages.sh: Add trae to ALL_AGENTS array and build case statement - .github/workflows/scripts/create-release-packages.ps1: Add trae to AllAgents array and switch statement - .github/workflows/scripts/create-github-release.sh: Add trae template zip files to release assets - scripts/bash/update-agent-context.sh: Add TRAE_FILE, trae case in update function, and auto-detect block - scripts/powershell/update-agent-context.ps1: Add TRAE_FILE, ValidateSet entry, switch case, and auto-detect block - tests/test_agent_config_consistency.py: Add 8 consistency tests for trae following established kimi/tabnine patterns * fix: correct Generate-Commands parameter names for trae in PowerShell release script Fix incorrect parameter names in the trae case of Build-Variant: - -Format -> -Extension - -ArgsToken -> -ArgFormat - -OutDir -> -OutputDir These now match the Generate-Commands function signature and all other agent entries in the script. Co-authored-by: Copilot * Update release packaging scripts and agent docs * Update Agent.md * Restore format * Adjust order * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Unused * fix: add TRAE_FILE to update_all_existing_agents() for auto-detect support Add missing update_if_new call for TRAE_FILE in the bash update-agent-context.sh script's update_all_existing_agents() function, matching the PowerShell implementation. This ensures running the script without arguments will correctly auto-detect and update existing Trae agent files. * Add configuration for 'trae' in agents.py * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refactor trae configuration test for clarity * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update update-agent-context.sh * Fix formatting in update-agent-context.sh --------- Co-authored-by: root Co-authored-by: root Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 9 ++- .../scripts/create-release-packages.sh | 7 +- AGENTS.md | 1 + README.md | 1 + scripts/bash/update-agent-context.sh | 11 ++- scripts/powershell/update-agent-context.ps1 | 9 ++- src/specify_cli/__init__.py | 7 ++ src/specify_cli/agents.py | 6 ++ tests/test_agent_config_consistency.py | 76 +++++++++++++++++++ 10 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 864e0011ba..b577783845 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -58,6 +58,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ .genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \ .genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-trae-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-trae-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 60ad3da97c..aa79a95568 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -454,6 +454,11 @@ function Build-Variant { New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script } + 'trae' { + $rulesDir = Join-Path $baseDir ".trae/rules" + New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null + Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script + } 'generic' { $cmdDir = Join-Path $baseDir ".speckit/commands" Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script @@ -470,7 +475,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 620da02337..77dac397ab 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -291,6 +291,9 @@ build_variant() { kimi) mkdir -p "$base_dir/.kimi/skills" create_kimi_skills "$base_dir/.kimi/skills" "$script" ;; + trae) + mkdir -p "$base_dir/.trae/rules" + generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; generic) mkdir -p "$base_dir/.speckit/commands" generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; @@ -300,7 +303,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index 8f0742eb8f..77f54d05a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | | **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | +| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | ### Step-by-Step Integration Guide diff --git a/README.md b/README.md index e97e2d4425..51ee094f1d 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Kimi Code](https://code.kimi.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | +| [Trae](https://www.trae.ai/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | ## 🔧 Specify CLI Reference diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b4b901fb13..0e33772f9d 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -35,7 +35,7 @@ # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic # Leave empty to update all existing agent files set -e @@ -83,6 +83,7 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$AGENTS_FILE" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" +TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" # Template file TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" @@ -675,12 +676,15 @@ update_specific_agent() { kimi) update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 ;; + trae) + update_agent_file "$TRAE_FILE" "Trae" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic" exit 1 ;; esac @@ -739,6 +743,7 @@ update_all_existing_agents() { _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false + _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false # If no agent files exist, create a default Claude file if [[ "$_found_agent" == false ]]; then @@ -765,7 +770,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 30e1e0e693..c0e0a674f8 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','generic')] [string]$AgentType ) @@ -64,6 +64,7 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' +$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -408,8 +409,9 @@ function Update-SpecificAgent { 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } + 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic'; return $false } } } @@ -435,6 +437,7 @@ function Update-AllExistingAgents { if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true } + if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true } if (-not $found) { Write-Info 'No existing agent files found, creating default Claude file...' if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f30e58f965..219df9d589 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -275,6 +275,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://code.kimi.com/", "requires_cli": True, }, + "trae": { + "name": "Trae", + "folder": ".trae/", + "commands_subdir": "rules", # Trae uses .trae/rules/ for project rules + "install_url": None, # IDE-based + "requires_cli": False, + }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 9927daee8c..6f9aaa910b 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -135,6 +135,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md" + }, + "trae": { + "dir": ".trae/rules", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" } } diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 6831fad360..1d6a4b2df1 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -233,3 +233,79 @@ def test_kimi_in_github_release_output(self): def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP + + # --- Trae IDE consistency checks --- + + def test_trae_in_agent_config(self): + """AGENT_CONFIG should include trae with correct folder and commands_subdir.""" + assert "trae" in AGENT_CONFIG + assert AGENT_CONFIG["trae"]["folder"] == ".trae/" + assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules" + assert AGENT_CONFIG["trae"]["requires_cli"] is False + assert AGENT_CONFIG["trae"]["install_url"] is None + + def test_trae_in_extension_registrar(self): + """Extension command registrar should include trae using .trae/rules and markdown, if present.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "trae" in cfg + trae_cfg = cfg["trae"] + assert trae_cfg["format"] == "markdown" + assert trae_cfg["args"] == "$ARGUMENTS" + assert trae_cfg["extension"] == ".md" + + def test_trae_in_release_agent_lists(self): + """Bash and PowerShell release scripts should include trae in agent lists.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "trae" in sh_agents + assert "trae" in ps_agents + + def test_trae_in_release_scripts_generate_commands(self): + """Release scripts should generate markdown commands for trae in .trae/rules.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert ".trae/rules" in sh_text + assert ".trae/rules" in ps_text + assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None + + def test_trae_in_github_release_output(self): + """GitHub release script should include trae template packages.""" + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert "spec-kit-template-trae-sh-" in gh_release_text + assert "spec-kit-template-trae-ps-" in gh_release_text + + def test_trae_in_agent_context_scripts(self): + """Agent context scripts should support trae agent type.""" + bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") + pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + assert "trae" in bash_text + assert "TRAE_FILE" in bash_text + assert "trae" in pwsh_text + assert "TRAE_FILE" in pwsh_text + + def test_trae_in_powershell_validate_set(self): + """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" + ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) + assert validate_set_match is not None + validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) + + assert "trae" in validate_set_values + + def test_ai_help_includes_trae(self): + """CLI help text for --ai should include trae.""" + assert "trae" in AI_ASSISTANT_HELP From 6644f69a964e161d2809f927b163274f72cc7b6d Mon Sep 17 00:00:00 2001 From: darkglow-net <41721768+darkglow-net@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:25:18 +0800 Subject: [PATCH 094/321] =?UTF-8?q?fix(ai-skills):=20exclude=20non-speckit?= =?UTF-8?q?=20copilot=20agent=20markdown=20from=20skill=E2=80=A6=20(#1867)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai-skills): exclude non-speckit copilot agent markdown from skill generation * Potential fix for pull request finding Fix missing `.agent` filename suffix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix test assertion speckit.plan.md to speckit.plan.agent Fix test assertion speckit.plan.md to speckit.plan.agent * Fix filter glob based on review suggestions fix(ai-skills): normalize Copilot .agent template names and align template fallback filtering * Add template glob for fallback directory * GH Copilot Suggestions Clarify comment regarding Copilot's use of templates in tests. Add extra test assertion * fix(ai-skills): normalize Copilot .agent templates and preserve fallback behavior fix(ai-skills): handle Copilot .agent templates and fallback filtering Normalize Copilot command template names by stripping the .agent suffix when deriving skill names and metadata sources, so files like speckit.plan.agent.md produce speckit-plan and map to plan.md metadata. Also align Copilot template discovery with speckit.* filtering while preserving fallback to templates/commands/ when .github/agents contains only user-authored markdown files, and add regression coverage for both non-speckit agent exclusion and fallback behavior. * fix(ai-skills): ignore non-speckit markdown commands --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 21 +++++-- tests/test_ai_skills.py | 118 +++++++++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 219df9d589..ff2364d29a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1256,7 +1256,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker else: templates_dir = project_path / commands_subdir - if not templates_dir.exists() or not any(templates_dir.glob("*.md")): + # Only consider speckit.*.md templates so that user-authored command + # files (e.g. custom slash commands, agent files) coexisting in the + # same commands directory are not incorrectly converted into skills. + template_glob = "speckit.*.md" + + if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): # Fallback: try the repo-relative path (for running from source checkout) # This also covers agents whose extracted commands are in a different # format (e.g. gemini/tabnine use .toml, not .md). @@ -1264,15 +1269,16 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker fallback_dir = script_dir / "templates" / "commands" if fallback_dir.exists() and any(fallback_dir.glob("*.md")): templates_dir = fallback_dir + template_glob = "*.md" - if not templates_dir.exists() or not any(templates_dir.glob("*.md")): + if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): if tracker: tracker.error("ai-skills", "command templates not found") else: console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") return False - command_files = sorted(templates_dir.glob("*.md")) + command_files = sorted(templates_dir.glob(template_glob)) if not command_files: if tracker: tracker.skip("ai-skills", "no command templates found") @@ -1311,11 +1317,14 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker body = content command_name = command_file.stem - # Normalize: extracted commands may be named "speckit..md"; - # strip the "speckit." prefix so skill names stay clean and + # Normalize: extracted commands may be named "speckit..md" + # or "speckit..agent.md"; strip the "speckit." prefix and + # any trailing ".agent" suffix so skill names stay clean and # SKILL_DESCRIPTIONS lookups work. if command_name.startswith("speckit."): command_name = command_name[len("speckit."):] + if command_name.endswith(".agent"): + command_name = command_name[:-len(".agent")] # Kimi CLI discovers skills by directory name and invokes them as # /skill: — use dot separator to match packaging convention. if selected_ai == "kimi": @@ -1340,6 +1349,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker source_name = command_file.name if source_name.startswith("speckit."): source_name = source_name[len("speckit."):] + if source_name.endswith(".agent.md"): + source_name = source_name[:-len(".agent.md")] + ".md" frontmatter_data = { "name": skill_name, diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 45d45cc4a8..e09320cc0b 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -62,7 +62,7 @@ def templates_dir(project_dir): tpl_root.mkdir(parents=True, exist_ok=True) # Template with valid YAML frontmatter - (tpl_root / "specify.md").write_text( + (tpl_root / "speckit.specify.md").write_text( "---\n" "description: Create or update the feature specification.\n" "handoffs:\n" @@ -79,7 +79,7 @@ def templates_dir(project_dir): ) # Template with minimal frontmatter - (tpl_root / "plan.md").write_text( + (tpl_root / "speckit.plan.md").write_text( "---\n" "description: Generate implementation plan.\n" "---\n" @@ -91,7 +91,7 @@ def templates_dir(project_dir): ) # Template with no frontmatter - (tpl_root / "tasks.md").write_text( + (tpl_root / "speckit.tasks.md").write_text( "# Tasks Command\n" "\n" "Body without frontmatter.\n", @@ -99,7 +99,7 @@ def templates_dir(project_dir): ) # Template with empty YAML frontmatter (yaml.safe_load returns None) - (tpl_root / "empty_fm.md").write_text( + (tpl_root / "speckit.empty_fm.md").write_text( "---\n" "---\n" "\n" @@ -337,7 +337,7 @@ def test_malformed_yaml_frontmatter(self, project_dir): cmds_dir = project_dir / ".claude" / "commands" cmds_dir.mkdir(parents=True) - (cmds_dir / "broken.md").write_text( + (cmds_dir / "speckit.broken.md").write_text( "---\n" "description: [unclosed bracket\n" " invalid: yaml: content: here\n" @@ -430,9 +430,12 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key): # Place .md templates in the agent's commands directory agent_folder = AGENT_CONFIG[agent_key]["folder"] - cmds_dir = proj / agent_folder.rstrip("/") / "commands" + commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") + cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir cmds_dir.mkdir(parents=True) - (cmds_dir / "specify.md").write_text( + # Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md + fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md" + (cmds_dir / fname).write_text( "---\ndescription: Test command\n---\n\n# Test\n\nBody.\n" ) @@ -448,7 +451,100 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key): assert expected_skill_name in skill_dirs assert (skills_dir / expected_skill_name / "SKILL.md").exists() + def test_copilot_ignores_non_speckit_agents(self, project_dir): + """Non-speckit markdown in .github/agents/ must not produce skills.""" + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + (agents_dir / "speckit.plan.agent.md").write_text( + "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" + ) + (agents_dir / "my-custom-agent.agent.md").write_text( + "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" + ) + result = install_ai_skills(project_dir, "copilot") + + assert result is True + skills_dir = _get_skills_dir(project_dir, "copilot") + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert "speckit-plan" in skill_dirs + assert "speckit-my-custom-agent.agent" not in skill_dirs + assert "speckit-my-custom-agent" not in skill_dirs + + @pytest.mark.parametrize("agent_key,custom_file", [ + ("claude", "review.md"), + ("cursor-agent", "deploy.md"), + ("qwen", "my-workflow.md"), + ]) + def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file): + """User-authored command files must not produce skills for any agent.""" + proj = temp_dir / f"proj-{agent_key}" + proj.mkdir() + + agent_folder = AGENT_CONFIG[agent_key]["folder"] + commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") + cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text( + "---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n" + ) + (cmds_dir / custom_file).write_text( + "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" + ) + + result = install_ai_skills(proj, agent_key) + + assert result is True + skills_dir = _get_skills_dir(proj, agent_key) + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert "speckit-specify" in skill_dirs + custom_stem = Path(custom_file).stem + assert f"speckit-{custom_stem}" not in skill_dirs + + def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir): + """Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files.""" + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + # Only a user-authored agent, no speckit.* templates + (agents_dir / "my-custom-agent.agent.md").write_text( + "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" + ) + + result = install_ai_skills(project_dir, "copilot") + + # Should succeed via fallback to templates/commands/ + assert result is True + skills_dir = _get_skills_dir(project_dir, "copilot") + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + # Should have skills from fallback templates, not from the custom agent + assert "speckit-plan" in skill_dirs + assert not any("my-custom" in d for d in skill_dirs) + + @pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"]) + def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key): + """Fallback to templates/commands/ when agent dir has no speckit.*.md files.""" + proj = temp_dir / f"proj-{agent_key}" + proj.mkdir() + + agent_folder = AGENT_CONFIG[agent_key]["folder"] + commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") + cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir + cmds_dir.mkdir(parents=True) + # Only a user-authored command, no speckit.* templates + (cmds_dir / "my-custom-command.md").write_text( + "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" + ) + + result = install_ai_skills(proj, agent_key) + + # Should succeed via fallback to templates/commands/ + assert result is True + skills_dir = _get_skills_dir(proj, agent_key) + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert not any("my-custom" in d for d in skill_dirs) class TestCommandCoexistence: """Verify install_ai_skills never touches command files. @@ -460,14 +556,16 @@ class TestCommandCoexistence: def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): """install_ai_skills must NOT remove pre-existing .claude/commands files.""" - # Verify commands exist before - assert len(list(commands_dir_claude.glob("speckit.*"))) == 3 + # Verify commands exist before (templates_dir adds 4 speckit.* files, + # commands_dir_claude overlaps with 3 of them) + before = list(commands_dir_claude.glob("speckit.*")) + assert len(before) >= 3 install_ai_skills(project_dir, "claude") # Commands must still be there — install_ai_skills never touches them remaining = list(commands_dir_claude.glob("speckit.*")) - assert len(remaining) == 3 + assert len(remaining) == len(before) def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini): """install_ai_skills must NOT remove pre-existing .gemini/commands files.""" From 489ced56bad9861978e1cd13406a6527b452233e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:05:37 -0500 Subject: [PATCH 095/321] docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 51ee094f1d..b3c6235e5b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ See Spec-Driven Development in action across different scenarios with these comm - **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. +- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From b471b5e6f3099a067deffe2bea59226f25be6701 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:41:29 -0500 Subject: [PATCH 096/321] chore: bump version to 0.3.1 (#1880) * chore: bump version to 0.3.1 * fix: correct 0.3.1 CHANGELOG.md entries (#1882) * Initial plan * fix: correct 0.3.1 CHANGELOG.md entries - fix truncated title and remove duplicates Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a479928172..9fab8a3d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-03-17 + +### Changed + +- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) +- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867) +- feat: add Trae IDE support as a new agent (#1817) +- feat(cli): polite deep merge for settings.json and support JSONC (#1874) +- feat(extensions,presets): add priority-based resolution ordering (#1855) +- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876) +- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869) +- Add cognitive-squad to community extension catalog (#1870) +- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868) +- chore: update DocGuard extension to v0.9.8 (#1859) +- Feature: add specify status command (#1837) +- fix(extensions): show extension ID in list output (#1843) +- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844) +- feat: Add DocGuard CDD enforcement extension to community catalog (#1838) + + ## [0.3.0] - 2026-03-13 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 322abe3a57..df200d480e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.3.0" +version = "0.3.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From f21eb71990989446b8f497fb6978bb001f7ecbdb Mon Sep 17 00:00:00 2001 From: "Vianca M." <92328779+imviancagrace@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:31:00 -0700 Subject: [PATCH 097/321] feat: register spec-kit-learn extension (#1883) * feat: register spec-kit-learn extension * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> resolve copilot review Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 33 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index b7c40803bd..a6700b4ba1 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -79,6 +79,7 @@ The following community-contributed extensions are available in [`catalog.commun | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | +| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index dd03fb1053..a6bdd7dad1 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -559,6 +559,37 @@ "created_at": "2026-02-20T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "learn": { + "name": "Learning Extension", + "id": "learn", + "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.", + "author": "Vianca Martinez", + "version": "1.0.0", + "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/imviancagrace/spec-kit-learn", + "homepage": "https://github.com/imviancagrace/spec-kit-learn", + "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md", + "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "learning", + "education", + "mentoring", + "knowledge-transfer" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z" + }, "verify": { "name": "Verify Extension", "id": "verify", From 1a21bdef0107c704b5e51aa7708784b02c0662db Mon Sep 17 00:00:00 2001 From: Greazly Date: Tue, 17 Mar 2026 22:50:18 +0300 Subject: [PATCH 098/321] Feature/spec kit add pi coding agent pullrequest (#1853) * feat(ai): add native support for Pi coding agent by pi+gpt 5.4 * docs(pi): document MCP limitations for Pi agent * fix: unitended kimi agent mention added to update-agent-context.ps1 * fix: address reviewer feedback * Apply suggestions from code review Changes in AGENTS.md weren't part of my PR, but the Copilot feedback seems to be correct is correct. I've doublechecked it with contents of test_agent_config_consistency.py and create-release-packages scripts Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .devcontainer/post-create.sh | 4 + .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 8 +- .../scripts/create-release-packages.sh | 7 +- AGENTS.md | 18 +++-- README.md | 10 ++- docs/installation.md | 3 +- docs/upgrade.md | 10 ++- scripts/bash/update-agent-context.sh | 13 ++-- scripts/powershell/update-agent-context.ps1 | 9 ++- src/specify_cli/__init__.py | 7 ++ src/specify_cli/agents.py | 6 ++ tests/test_agent_config_consistency.py | 78 +++++++++++++++++++ tests/test_ai_skills.py | 26 +++++++ tests/test_extensions.py | 9 +++ 15 files changed, 184 insertions(+), 26 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index d7d3da5884..ad85dd3a77 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -51,6 +51,10 @@ echo -e "\n🤖 Installing OpenCode CLI..." run_command "npm install -g opencode-ai@latest" echo "✅ Done" +echo -e "\n🤖 Installing Pi Coding Agent..." +run_command "npm install -g @mariozechner/pi-coding-agent@latest" +echo "✅ Done" + echo -e "\n🤖 Installing Kiro CLI..." # https://kiro.dev/docs/cli/ KIRO_INSTALLER_URL="https://kiro.dev/install.sh" diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index b577783845..d284f8f4de 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -60,6 +60,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \ .genreleases/spec-kit-template-trae-sh-"$VERSION".zip \ .genreleases/spec-kit-template-trae-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-pi-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-pi-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index aa79a95568..29d48c3402 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -459,6 +459,10 @@ function Build-Variant { New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script } + 'pi' { + $cmdDir = Join-Path $baseDir ".pi/prompts" + Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } 'generic' { $cmdDir = Join-Path $baseDir ".speckit/commands" Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script @@ -475,7 +479,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 77dac397ab..021789d5b1 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -294,6 +294,9 @@ build_variant() { trae) mkdir -p "$base_dir/.trae/rules" generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; + pi) + mkdir -p "$base_dir/.pi/prompts" + generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;; generic) mkdir -p "$base_dir/.speckit/commands" generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; @@ -303,7 +306,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index 77f54d05a8..7f647fe1b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,11 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | | **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI | +| **Codex CLI** | `.codex/prompts/` | Markdown | `codex` | Codex CLI | | **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | -| **Kilo Code** | `.kilocode/rules/` | Markdown | N/A (IDE-based) | Kilo Code IDE | -| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI | -| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | +| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE | +| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI | +| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE | | **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | | **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | | **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | @@ -45,6 +45,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | | **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | +| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | @@ -85,7 +86,7 @@ This eliminates the need for special-case mappings throughout the codebase. - `folder`: Directory where agent-specific files are stored (relative to project root) - `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) + - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular) - This field enables `--ai-skills` to locate command templates correctly for skill generation - `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `requires_cli`: Whether the agent requires a CLI tool check during initialization @@ -323,6 +324,7 @@ Require a command-line tool to be installed: - **SHAI**: `shai` CLI - **Tabnine CLI**: `tabnine` CLI - **Kimi Code**: `kimi` CLI +- **Pi Coding Agent**: `pi` CLI ### IDE-Based Agents @@ -336,7 +338,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen +Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi **Standard format:** @@ -374,6 +376,10 @@ Command content with {SCRIPT} and {{args}} placeholders. ## Directory Conventions - **CLI agents**: Usually `./commands/` +- **Common prompt-based exceptions**: + - Codex: `.codex/prompts/` + - Kiro CLI: `.kiro/prompts/` + - Pi: `.pi/prompts/` - **IDE agents**: Follow IDE-specific patterns: - Copilot: `.github/agents/` - Cursor: `.cursor/commands/` diff --git a/README.md b/README.md index b3c6235e5b..c958002bb9 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Jules](https://jules.google.com/) | ✅ | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | | | [opencode](https://opencode.ai/) | ✅ | | +| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | | [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | | [Roo Code](https://roocode.com/) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | @@ -200,14 +201,14 @@ The `specify` command supports the following options: | Command | Description | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`) | +| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `pi`) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `pi`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | @@ -252,6 +253,9 @@ specify init my-project --ai vibe # Initialize with IBM Bob support specify init my-project --ai bob +# Initialize with Pi Coding Agent support +specify init my-project --ai pi + # Initialize with Antigravity support specify init my-project --ai agy --ai-skills @@ -429,7 +433,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/docs/installation.md b/docs/installation.md index 6daff24315..7cb7b1ff9b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,7 @@ ## Prerequisites - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) -- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) +- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) - [uv](https://docs.astral.sh/uv/) for package management - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) @@ -35,6 +35,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --ai gemini uvx --from git+https://github.com/github/spec-kit.git specify init --ai copilot uvx --from git+https://github.com/github/spec-kit.git specify init --ai codebuddy +uvx --from git+https://github.com/github/spec-kit.git specify init --ai pi ``` ### Specify Script Type (Shell vs PowerShell) diff --git a/docs/upgrade.md b/docs/upgrade.md index 676e5131f0..74bf6192c0 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -289,8 +289,9 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a ```bash ls -la .claude/commands/ # Claude Code - ls -la .gemini/commands/ # Gemini - ls -la .cursor/commands/ # Cursor + ls -la .gemini/commands/ # Gemini + ls -la .cursor/commands/ # Cursor + ls -la .pi/prompts/ # Pi Coding Agent ``` 3. **Check agent-specific setup:** @@ -398,7 +399,7 @@ The `specify` CLI tool is used for: - **Upgrades:** `specify init --here --force` to update templates and commands - **Diagnostics:** `specify check` to verify tool installation -Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. +Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. **If your agent isn't recognizing slash commands:** @@ -410,6 +411,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s # For Claude ls -la .claude/commands/ + + # For Pi + ls -la .pi/prompts/ ``` 2. **Restart your IDE/editor completely** (not just reload window) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 0e33772f9d..3d9d3e9556 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic # Leave empty to update all existing agent files set -e @@ -73,7 +73,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid +# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid # updating the same file multiple times. AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" @@ -679,12 +679,15 @@ update_specific_agent() { trae) update_agent_file "$TRAE_FILE" "Trae" || return 1 ;; + pi) + update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic" exit 1 ;; esac @@ -770,7 +773,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index c0e0a674f8..5eb0347435 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','generic')] [string]$AgentType ) @@ -410,8 +410,9 @@ function Update-SpecificAgent { 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } + 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic'; return $false } } } @@ -452,7 +453,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ff2364d29a..38117b54f4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -282,6 +282,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": None, # IDE-based "requires_cli": False, }, + "pi": { + "name": "Pi Coding Agent", + "folder": ".pi/", + "commands_subdir": "prompts", + "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", + "requires_cli": True, + }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6f9aaa910b..19010908f6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -106,6 +106,12 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".md" }, + "pi": { + "dir": ".pi/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, "amp": { "dir": ".agents/commands", "format": "markdown", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 1d6a4b2df1..ecb57a3bfb 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -309,3 +309,81 @@ def test_trae_in_powershell_validate_set(self): def test_ai_help_includes_trae(self): """CLI help text for --ai should include trae.""" assert "trae" in AI_ASSISTANT_HELP + + # --- Pi Coding Agent consistency checks --- + + def test_pi_in_agent_config(self): + """AGENT_CONFIG should include pi with correct folder and commands_subdir.""" + assert "pi" in AGENT_CONFIG + assert AGENT_CONFIG["pi"]["folder"] == ".pi/" + assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts" + assert AGENT_CONFIG["pi"]["requires_cli"] is True + assert AGENT_CONFIG["pi"]["install_url"] is not None + + def test_pi_in_extension_registrar(self): + """Extension command registrar should include pi using .pi/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "pi" in cfg + pi_cfg = cfg["pi"] + assert pi_cfg["dir"] == ".pi/prompts" + assert pi_cfg["format"] == "markdown" + assert pi_cfg["args"] == "$ARGUMENTS" + assert pi_cfg["extension"] == ".md" + + def test_pi_in_release_agent_lists(self): + """Bash and PowerShell release scripts should include pi in agent lists.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "pi" in sh_agents + assert "pi" in ps_agents + + def test_release_scripts_generate_pi_prompt_templates(self): + """Release scripts should generate Markdown prompt templates for pi in .pi/prompts.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert ".pi/prompts" in sh_text + assert ".pi/prompts" in ps_text + assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None + assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None + + def test_pi_in_powershell_validate_set(self): + """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" + ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) + assert validate_set_match is not None + validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) + + assert "pi" in validate_set_values + + def test_pi_in_github_release_output(self): + """GitHub release script should include pi template packages.""" + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert "spec-kit-template-pi-sh-" in gh_release_text + assert "spec-kit-template-pi-ps-" in gh_release_text + + def test_agent_context_scripts_include_pi(self): + """Agent context scripts should support pi agent type.""" + bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") + pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + assert "pi" in bash_text + assert "Pi Coding Agent" in bash_text + assert "pi" in pwsh_text + assert "Pi Coding Agent" in pwsh_text + + def test_ai_help_includes_pi(self): + """CLI help text for --ai should include pi.""" + assert "pi" in AI_ASSISTANT_HELP diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e09320cc0b..b2bc01a954 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -182,6 +182,11 @@ def test_kiro_cli_skills_dir(self, project_dir): result = _get_skills_dir(project_dir, "kiro-cli") assert result == project_dir / ".kiro" / "skills" + def test_pi_skills_dir(self, project_dir): + """Pi should use .pi/skills/.""" + result = _get_skills_dir(project_dir, "pi") + assert result == project_dir / ".pi" / "skills" + def test_unknown_agent_uses_default(self, project_dir): """Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" result = _get_skills_dir(project_dir, "nonexistent-agent") @@ -422,6 +427,27 @@ def test_qwen_md_commands_dir_installs_skills(self, project_dir): assert (cmds_dir / "speckit.specify.md").exists() assert (cmds_dir / "speckit.plan.md").exists() + def test_pi_prompt_dir_installs_skills(self, project_dir): + """Pi should install skills directly from .pi/prompts/.""" + prompts_dir = project_dir / ".pi" / "prompts" + prompts_dir.mkdir(parents=True) + (prompts_dir / "speckit.specify.md").write_text( + "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" + ) + (prompts_dir / "speckit.plan.md").write_text( + "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" + ) + + result = install_ai_skills(project_dir, "pi") + + assert result is True + skills_dir = project_dir / ".pi" / "skills" + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert len(skill_dirs) >= 1 + assert (prompts_dir / "speckit.specify.md").exists() + assert (prompts_dir / "speckit.plan.md").exists() + @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) def test_skills_install_for_all_agents(self, temp_dir, agent_key): """install_ai_skills should produce skills for every configured agent.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c87ba5b533..5ab7729787 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -593,6 +593,15 @@ def test_codex_agent_config_present(self): assert "codex" in CommandRegistrar.AGENT_CONFIGS assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_pi_agent_config_present(self): + """Pi should be mapped to .pi/prompts.""" + assert "pi" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["pi"] + assert cfg["dir"] == ".pi/prompts" + assert cfg["format"] == "markdown" + assert cfg["args"] == "$ARGUMENTS" + assert cfg["extension"] == ".md" + def test_qwen_agent_config_is_markdown(self): """Qwen should use Markdown format with $ARGUMENTS (not TOML).""" assert "qwen" in CommandRegistrar.AGENT_CONFIGS From eecb723663d86607eb10676016b4d61a5d84b1ff Mon Sep 17 00:00:00 2001 From: Ricardo Accioly <63126795+raccioly@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:43:42 -0400 Subject: [PATCH 099/321] chore: update DocGuard extension to v0.9.10 (#1890) Co-authored-by: github-actions[bot] --- extensions/README.md | 2 +- extensions/catalog.community.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index a6700b4ba1..3fbf96940f 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,7 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | -| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | +| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index a6bdd7dad1..8981e0ec9c 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-18T04:21:25Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -152,10 +152,10 @@ "docguard": { "name": "DocGuard \u2014 CDD Enforcement", "id": "docguard", - "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies.", + "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.", "author": "raccioly", - "version": "0.9.8", - "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.8/spec-kit-docguard-v0.9.8.zip", + "version": "0.9.10", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.10/spec-kit-docguard-v0.9.10.zip", "repository": "https://github.com/raccioly/docguard", "homepage": "https://www.npmjs.com/package/docguard-cli", "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", @@ -189,7 +189,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-13T00:00:00Z", - "updated_at": "2026-03-15T20:00:00Z" + "updated_at": "2026-03-18T04:21:25Z" }, "doctor": { "name": "Project Health Check", From 2e55bdd3f279849a4e499a4a7c1a0d63d39f117b Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Wed, 18 Mar 2026 13:58:34 +0100 Subject: [PATCH 100/321] fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872) * fix(scripts): encode residual control chars as \uXXXX instead of stripping json_escape() was silently deleting control characters (U+0000-U+001F) that were not individually handled (\n, \t, \r, \b, \f). Per RFC 8259, these must be encoded as \uXXXX sequences to preserve data integrity. Replace the tr -d strip with a char-by-char loop that emits proper \uXXXX escapes for any remaining control characters. * fix(scripts): address Copilot review on json_escape control char loop - Set LC_ALL=C for the entire loop (not just printf) so that ${#s} and ${s:$i:1} operate on bytes deterministically across locales - Fix comment: U+0000 (NUL) cannot exist in bash strings, range is U+0001-U+001F; adjust code guard accordingly (code >= 1) - Emit directly to stdout instead of accumulating in a variable, avoiding quadratic string concatenation on longer inputs * perf(scripts): use printf -v to avoid subshell in json_escape loop Replace code=$(printf ...) with printf -v code to assign the character code without spawning a subshell on every byte, reducing overhead for longer inputs. --- scripts/bash/common.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 826e740f00..40f1c96e7d 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -171,9 +171,21 @@ json_escape() { s="${s//$'\r'/\\r}" s="${s//$'\b'/\\b}" s="${s//$'\f'/\\f}" - # Strip remaining control characters (U+0000–U+001F) not individually escaped above - s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037') - printf '%s' "$s" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done } check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } From 96712e1cdf3299b92a844f8d2a11757cd1bd1b7e Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 18 Mar 2026 06:00:16 -0700 Subject: [PATCH 101/321] fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885) The $Number (Int32) parameter was implicitly receiving positional arguments intended for $FeatureDescription, causing a ParameterBindingArgumentTransformationException when AI agents called the script with positional strings. Add [Parameter(Position = 0)] to $FeatureDescription so it binds first, and mark $Number with [Parameter()] (no Position) so it only binds by name (-Number N). Fixes #1879 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- scripts/powershell/create-new-feature.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 31acbe2958..17e61bb845 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,9 +4,10 @@ param( [switch]$Json, [string]$ShortName, + [Parameter()] [int]$Number = 0, [switch]$Help, - [Parameter(ValueFromRemainingArguments = $true)] + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription ) $ErrorActionPreference = 'Stop' From cfd99ad49910096dcf9adc3d4c30d856f9db497f Mon Sep 17 00:00:00 2001 From: "Vianca M." <92328779+imviancagrace@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:50:19 -0700 Subject: [PATCH 102/321] feat: register spec-kit-iterate extension (#1887) * feat: register spec-kit-iterate extension * fix: copilot review * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index 3fbf96940f..954115142a 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -78,6 +78,7 @@ The following community-contributed extensions are available in [`catalog.commun | Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | +| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8981e0ec9c..1f8cbb3c0e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -253,6 +253,36 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-06T00:00:00Z" }, + "iterate": { + "name": "Iterate", + "id": "iterate", + "description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building", + "author": "Vianca Martinez", + "version": "2.0.0", + "download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip", + "repository": "https://github.com/imviancagrace/spec-kit-iterate", + "homepage": "https://github.com/imviancagrace/spec-kit-iterate", + "documentation": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/README.md", + "changelog": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "iteration", + "change-management", + "spec-maintenance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z" + }, "jira": { "name": "Jira Integration", "id": "jira", From f97c8e95a64071c89f802d45c13dbb0dbfb831d2 Mon Sep 17 00:00:00 2001 From: LADISLAV BIHARI <51442396+Testimonial@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:50:36 +0100 Subject: [PATCH 103/321] =?UTF-8?q?Update=20cognitive-squad=20catalog=20en?= =?UTF-8?q?try=20=E2=80=94=20Triadic=20Model,=20full=20lifecycle=20(#1884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated description to version-independent wording: "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing" Changes: - description: version-independent (no counts) - provides.commands: 7 → 10 - tags: pre-code,analysis → full-lifecycle,verification - updated_at: bumped to 2026-03-18 Co-authored-by: Ladislav Bihari Co-authored-by: Claude Opus 4.6 (1M context) --- extensions/README.md | 2 +- extensions/catalog.community.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index 954115142a..da7c89b53b 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -75,7 +75,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | -| Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | +| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1f8cbb3c0e..618b702cd4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -108,7 +108,7 @@ "cognitive-squad": { "name": "Cognitive Squad", "id": "cognitive-squad", - "description": "19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop", + "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing", "author": "Testimonial", "version": "0.1.0", "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip", @@ -133,21 +133,21 @@ ] }, "provides": { - "commands": 7, + "commands": 10, "hooks": 1 }, "tags": [ "ai-agents", "cognitive", - "pre-code", - "analysis", + "full-lifecycle", + "verification", "multi-agent" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-16T00:00:00Z", - "updated_at": "2026-03-16T00:00:00Z" + "updated_at": "2026-03-18T00:00:00Z" }, "docguard": { "name": "DocGuard \u2014 CDD Enforcement", From 33c83a6162ae221035c0800f3f389ce6a62675c3 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly <63126795+raccioly@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:07:44 -0400 Subject: [PATCH 104/321] chore: update DocGuard extension to v0.9.11 (#1899) Co-authored-by: github-actions[bot] --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 618b702cd4..00c29f6f02 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-18T04:21:25Z", + "updated_at": "2026-03-18T18:53:31Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -154,8 +154,8 @@ "id": "docguard", "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.", "author": "raccioly", - "version": "0.9.10", - "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.10/spec-kit-docguard-v0.9.10.zip", + "version": "0.9.11", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip", "repository": "https://github.com/raccioly/docguard", "homepage": "https://www.npmjs.com/package/docguard-cli", "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", @@ -189,7 +189,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-13T00:00:00Z", - "updated_at": "2026-03-18T04:21:25Z" + "updated_at": "2026-03-18T18:53:31Z" }, "doctor": { "name": "Project Health Check", From 497b5885e18e05d4ef7e628ea12d330e4b519980 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:21:20 -0500 Subject: [PATCH 105/321] docs: Add Extensions & Presets section to README (#1898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Extensions & Presets section to README Add a new 'Making Spec Kit Your Own: Extensions & Presets' section that covers: - Layering diagram (Mermaid) showing resolution order - Extensions: what they are, when to use, examples - Presets: what they are, when to use, examples - When-to-use-which comparison table - Links to extensions/README.md and presets/README.md * docs: clarify project-local overrides in layering diagram Address review feedback: explain the project-local overrides layer shown in the diagram, and adjust the intro to acknowledge it as a third customization mechanism alongside extensions and presets. * docs: Clarify template vs command resolution in README - Separate template resolution (top-down, first-match-wins stack) from command registration (written directly into agent directories) - Update Mermaid diagram paths to use and placeholders consistent with existing documentation Addresses PR review feedback on #1898. * docs: Clarify install-time vs runtime resolution for commands and templates - README: label templates as runtime-resolved (stack walk) and commands as install-time (copied into agent directories, last-installed wins) - presets/README: add runtime note to template resolution, contrast with install-time command registration * docs: Address review — fix template copy wording, tighten command override description - presets/README: clarify that preset files are copied at install but template resolution still walks the stack at runtime - README: describe priority-based command resolution and automatic restoration on removal instead of vague 'replacing whatever was there' --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++ presets/README.md | 4 ++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c958002bb9..c70de1baee 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🤖 Supported AI Agents](#-supported-ai-agents) - [🔧 Specify CLI Reference](#-specify-cli-reference) +- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) - [📚 Core Philosophy](#-core-philosophy) - [🌟 Development Phases](#-development-phases) - [🎯 Experimental Goals](#-experimental-goals) @@ -326,6 +327,68 @@ Additional commands for enhanced quality and validation: | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.
\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | +## 🧩 Making Spec Kit Your Own: Extensions & Presets + +Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments: + +```mermaid +block-beta + columns 1 + overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"] + presets["Presets — Customize core & extensions\n.specify/presets//templates/"] + extensions["Extensions — Add new capabilities\n.specify/extensions//templates/"] + core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"] + + style overrides fill:transparent,stroke:#999 + style presets fill:transparent,stroke:#4a9eda + style extensions fill:transparent,stroke:#4a9e4a + style core fill:transparent,stroke:#e6a817 +``` + +**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults. + +### Extensions — Add New Capabilities + +Use **extensions** when you need functionality that goes beyond Spec Kit's core. Extensions introduce new commands and templates — for example, adding domain-specific workflows that are not covered by the built-in SDD commands, integrating with external tools, or adding entirely new development phases. They expand *what Spec Kit can do*. + +```bash +# Search available extensions +specify extension search + +# Install an extension +specify extension add +``` + +For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. + +See the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own. + +### Presets — Customize Existing Workflows + +Use **presets** when you want to change *how* Spec Kit works without adding new capabilities. Presets override the templates and commands that ship with the core *and* with installed extensions — for example, enforcing a compliance-oriented spec format, using domain-specific terminology, or applying organizational standards to plans and tasks. They customize the artifacts and instructions that Spec Kit and its extensions produce. + +```bash +# Search available presets +specify preset search + +# Install a preset +specify preset add +``` + +For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. + +See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own. + +### When to Use Which + +| Goal | Use | +| --- | --- | +| Add a brand-new command or workflow | Extension | +| Customize the format of specs, plans, or tasks | Preset | +| Integrate an external tool or service | Extension | +| Enforce organizational or regulatory standards | Preset | +| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands | + ## 📚 Core Philosophy Spec-Driven Development is a structured process that emphasizes: diff --git a/presets/README.md b/presets/README.md index 2fb22a71f0..f039b83d43 100644 --- a/presets/README.md +++ b/presets/README.md @@ -13,13 +13,15 @@ When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution sta If no preset is installed, core templates are used — exactly the same behavior as before presets existed. +Template resolution happens **at runtime** — although preset files are copied into `.specify/presets//` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location. + For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). ## Command Overrides Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions). -When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up. +Unlike templates, command overrides are applied **at install time**. When a preset includes `type: "command"` entries, the commands are registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up. ## Quick Start From 6d0b84ab5b639578c88cb0a87f1f593bf2dbe78e Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 18 Mar 2026 12:27:27 -0700 Subject: [PATCH 106/321] docs(catalog): add speckit-utils to community catalog (#1896) * docs(catalog): add speckit-utils to community catalog Adds SDD Utilities extension (resume, doctor, validate) to the community catalog and README table. Hosted at mvanhorn/speckit-utils. Co-Authored-By: Claude Opus 4.6 (1M context) * Bump catalog updated_at to current date Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index da7c89b53b..e58787c1de 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -87,6 +87,7 @@ The following community-contributed extensions are available in [`catalog.commun | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | +| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 00c29f6f02..d613dfda45 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-18T18:53:31Z", + "updated_at": "2026-03-18T19:53:31Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -452,6 +452,38 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-06T00:00:00Z" }, + "speckit-utils": { + "name": "SDD Utilities", + "id": "speckit-utils", + "description": "Resume interrupted workflows, validate project health, and verify spec-to-task traceability.", + "author": "mvanhorn", + "version": "1.0.0", + "download_url": "https://github.com/mvanhorn/speckit-utils/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/mvanhorn/speckit-utils", + "homepage": "https://github.com/mvanhorn/speckit-utils", + "documentation": "https://github.com/mvanhorn/speckit-utils/blob/main/README.md", + "changelog": "https://github.com/mvanhorn/speckit-utils/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 2 + }, + "tags": [ + "resume", + "doctor", + "validate", + "workflow", + "health-check" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", From 333a76535b01aca13fa5f6d958f04e08e875e6c4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 19 Mar 2026 04:37:03 -0700 Subject: [PATCH 107/321] feat(commands): wire before/after hook events into specify and plan templates (#1886) * feat(commands): wire before/after hook events into specify and plan templates Replicates the hook evaluation pattern from tasks.md and implement.md (introduced in PR #1702) into the specify and plan command templates. This completes the hook lifecycle across all SDD phases. Changes: - specify.md: Add before_specify/after_specify hook blocks - plan.md: Add before_plan/after_plan hook blocks - EXTENSION-API-REFERENCE.md: Document new hook events - EXTENSION-USER-GUIDE.md: List all available hook events Fixes #1788 Co-Authored-By: Claude Opus 4.6 * Mark before_commit/after_commit as planned in extension docs These hook events are defined in the API reference but not yet wired into any core command template. Marking them as planned rather than removing them, since the infrastructure supports them. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix hook enablement to default true when field is absent Matches HookExecutor.get_hooks_for_event() semantics where hooks without an explicit enabled field are treated as enabled. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(docs): mark commit hooks as planned in user guide config example The yaml config comment listed before_commit/after_commit as "Available events" but they are not yet wired into core templates. Moved them to a separate "Planned" line, consistent with the API reference. Co-Authored-By: Claude Opus 4.6 * fix(commands): align enabled-filtering semantics across all hook templates tasks.md and implement.md previously said "Filter to only hooks where enabled: true", which would skip hooks that omit the enabled field. Updated to match specify.md/plan.md and HookExecutor's h.get('enabled', True) behavior: filter out only hooks where enabled is explicitly false. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- extensions/EXTENSION-API-REFERENCE.md | 14 ++++-- extensions/EXTENSION-USER-GUIDE.md | 3 ++ templates/commands/implement.md | 4 +- templates/commands/plan.md | 63 +++++++++++++++++++++++++++ templates/commands/specify.md | 63 +++++++++++++++++++++++++++ templates/commands/tasks.md | 4 +- 6 files changed, 143 insertions(+), 8 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index bd25d4bb49..6be3d0633d 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -53,7 +53,7 @@ provides: required: boolean # Default: false hooks: # Optional, event hooks - event_name: # e.g., "after_tasks", "after_implement" + event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement" command: string # Command to execute optional: boolean # Default: true prompt: string # Prompt text for optional hooks @@ -108,7 +108,7 @@ defaults: # Optional, default configuration values #### `hooks` - **Type**: object -- **Keys**: Event names (e.g., `after_tasks`, `after_implement`, `before_commit`) +- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`) - **Description**: Hooks that execute at lifecycle events - **Events**: Defined by core spec-kit commands @@ -551,10 +551,16 @@ hooks: Standard events (defined by core): +- `before_specify` - Before specification generation +- `after_specify` - After specification generation +- `before_plan` - Before implementation planning +- `after_plan` - After implementation planning +- `before_tasks` - Before task generation - `after_tasks` - After task generation +- `before_implement` - Before implementation - `after_implement` - After implementation -- `before_commit` - Before git commit -- `after_commit` - After git commit +- `before_commit` - Before git commit *(planned - not yet wired into core templates)* +- `after_commit` - After git commit *(planned - not yet wired into core templates)* ### Hook Configuration diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index ae77860fe5..21313c0aca 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -387,6 +387,9 @@ settings: auto_execute_hooks: true # Hook configuration +# Available events: before_specify, after_specify, before_plan, after_plan, +# before_tasks, after_tasks, before_implement, after_implement +# Planned (not yet wired into core templates): before_commit, after_commit hooks: after_tasks: - extension: jira diff --git a/templates/commands/implement.md b/templates/commands/implement.md index da58027d06..9a91d2dc4b 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_implement` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally -- Filter to only hooks where `enabled: true` +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation @@ -174,7 +174,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task 10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_implement` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - - Filter to only hooks where `enabled: true` + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 00e83eabd0..4f1e9ed295 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -24,6 +24,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before planning)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_plan` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -41,6 +75,35 @@ You **MUST** consider the user input before proceeding (if not empty). 4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. +5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_plan` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Phases ### Phase 0: Outline & Research diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 0713b68e4f..eeca4b58ca 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -21,6 +21,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before specification)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_specify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command. @@ -176,6 +210,35 @@ Given that feature description, do this: 7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_specify` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. ## Quick Guidelines diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 9ad199634d..4e204abc1b 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -28,7 +28,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.before_tasks` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally -- Filter to only hooks where `enabled: true` +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation @@ -100,7 +100,7 @@ You **MUST** consider the user input before proceeding (if not empty). 6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_tasks` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - - Filter to only hooks where `enabled: true` + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation From f6794685b67b4b54ac972b5a7a38ad1448c4cc53 Mon Sep 17 00:00:00 2001 From: fuyongde Date: Thu, 19 Mar 2026 19:44:22 +0800 Subject: [PATCH 108/321] feat: add iFlow CLI support (#1875) Add `iflow` as a supported AI agent (the key users pass to --ai) across all relevant configuration files, release scripts, agent context scripts, and README. Includes consistency tests following the same pattern as kimi/tabnine additions. - README: describe `check` generically (git + all AGENT_CONFIG CLI agents) - README: describe `--ai` with reference to AGENT_CONFIG for full list Co-authored-by: Claude Sonnet 4.6 --- .../scripts/create-github-release.sh | 2 + .../scripts/create-release-packages.ps1 | 8 ++- .../scripts/create-release-packages.sh | 7 +- AGENTS.md | 1 + README.md | 17 ++--- scripts/bash/update-agent-context.sh | 13 ++-- scripts/powershell/update-agent-context.ps1 | 11 ++-- src/specify_cli/__init__.py | 7 ++ src/specify_cli/agents.py | 6 ++ tests/test_agent_config_consistency.py | 64 +++++++++++++++++++ 10 files changed, 116 insertions(+), 20 deletions(-) diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index d284f8f4de..bb6ae2be85 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -62,6 +62,8 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-trae-ps-"$VERSION".zip \ .genreleases/spec-kit-template-pi-sh-"$VERSION".zip \ .genreleases/spec-kit-template-pi-ps-"$VERSION".zip \ + .genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \ + .genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ --title "Spec Kit Templates - $VERSION_NO_V" \ diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 29d48c3402..54698b9ec8 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -14,7 +14,7 @@ .PARAMETER Agents Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, generic + Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic .PARAMETER Scripts Comma or space separated subset of script types to build (default: both) @@ -463,6 +463,10 @@ function Build-Variant { $cmdDir = Join-Path $baseDir ".pi/prompts" Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } + 'iflow' { + $cmdDir = Join-Path $baseDir ".iflow/commands" + Generate-Commands -Agent 'iflow' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + } 'generic' { $cmdDir = Join-Path $baseDir ".speckit/commands" Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script @@ -479,7 +483,7 @@ function Build-Variant { } # Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'generic') +$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic') $AllScripts = @('sh', 'ps') function Normalize-List { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 021789d5b1..cb3c0552ba 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -6,7 +6,7 @@ set -euo pipefail # Usage: .github/workflows/scripts/create-release-packages.sh # Version argument should include leading 'v'. # Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi generic (default: all) +# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all) # SCRIPTS : space or comma separated subset of: sh ps (default: both) # Examples: # AGENTS=claude SCRIPTS=sh $0 v0.2.0 @@ -297,6 +297,9 @@ build_variant() { pi) mkdir -p "$base_dir/.pi/prompts" generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;; + iflow) + mkdir -p "$base_dir/.iflow/commands" + generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;; generic) mkdir -p "$base_dir/.speckit/commands" generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; @@ -306,7 +309,7 @@ build_variant() { } # Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi generic) +ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic) ALL_SCRIPTS=(sh ps) norm_list() { diff --git a/AGENTS.md b/AGENTS.md index 7f647fe1b7..c1ce263fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | | **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | | **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | +| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | diff --git a/README.md b/README.md index c70de1baee..883becc305 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Kimi Code](https://code.kimi.com/) | ✅ | | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | | [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | | [Trae](https://www.trae.ai/) | ✅ | | @@ -199,17 +200,17 @@ The `specify` command supports the following options: ### Commands -| Command | Description | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `pi`) | +| Command | Description | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init` | Initialize a new Specify project from the latest template | +| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) | ### `specify init` Arguments & Options -| Argument/Option | Type | Description | -| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `pi`, or `generic` (requires `--ai-commands-dir`) | +| Argument/Option | Type | Description | +| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | +| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 3d9d3e9556..74a98669fd 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic # Leave empty to update all existing agent files set -e @@ -84,6 +84,7 @@ BOB_FILE="$AGENTS_FILE" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" +IFLOW_FILE="$REPO_ROOT/IFLOW.md" # Template file TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" @@ -682,12 +683,15 @@ update_specific_agent() { pi) update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 ;; + iflow) + update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic" exit 1 ;; esac @@ -747,6 +751,7 @@ update_all_existing_agents() { _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false + _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false # If no agent files exist, create a default Claude file if [[ "$_found_agent" == false ]]; then @@ -773,7 +778,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 5eb0347435..7f3996767d 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')] [string]$AgentType ) @@ -65,6 +65,7 @@ $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md' +$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -411,8 +412,9 @@ function Update-SpecificAgent { 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } + 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false } } } @@ -439,6 +441,7 @@ function Update-AllExistingAgents { if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true } if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true } + if (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true } if (-not $found) { Write-Info 'No existing agent files found, creating default Claude file...' if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } @@ -453,7 +456,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 38117b54f4..ec00301c5e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -289,6 +289,13 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", "requires_cli": True, }, + "iflow": { + "name": "iFlow CLI", + "folder": ".iflow/", + "commands_subdir": "commands", + "install_url": "https://docs.iflow.cn/en/cli/quickstart", + "requires_cli": True, + }, "generic": { "name": "Generic (bring your own agent)", "folder": None, # Set dynamically via --ai-commands-dir diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 19010908f6..2f71cb188a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -147,6 +147,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" + }, + "iflow": { + "dir": ".iflow/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" } } diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index ecb57a3bfb..b748287551 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -387,3 +387,67 @@ def test_agent_context_scripts_include_pi(self): def test_ai_help_includes_pi(self): """CLI help text for --ai should include pi.""" assert "pi" in AI_ASSISTANT_HELP + + # --- iFlow CLI consistency checks --- + + def test_iflow_in_agent_config(self): + """AGENT_CONFIG should include iflow with correct folder and commands_subdir.""" + assert "iflow" in AGENT_CONFIG + assert AGENT_CONFIG["iflow"]["folder"] == ".iflow/" + assert AGENT_CONFIG["iflow"]["commands_subdir"] == "commands" + assert AGENT_CONFIG["iflow"]["requires_cli"] is True + + def test_iflow_in_extension_registrar(self): + """Extension command registrar should include iflow targeting .iflow/commands.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "iflow" in cfg + assert cfg["iflow"]["dir"] == ".iflow/commands" + assert cfg["iflow"]["format"] == "markdown" + assert cfg["iflow"]["args"] == "$ARGUMENTS" + + def test_iflow_in_release_agent_lists(self): + """Bash and PowerShell release scripts should include iflow in agent lists.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) + assert sh_match is not None + sh_agents = sh_match.group(1).split() + + ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) + assert ps_match is not None + ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) + + assert "iflow" in sh_agents + assert "iflow" in ps_agents + + def test_iflow_in_release_scripts_build_variant(self): + """Release scripts should generate Markdown commands for iflow in .iflow/commands.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert ".iflow/commands" in sh_text + assert ".iflow/commands" in ps_text + assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None + + def test_iflow_in_github_release_output(self): + """GitHub release script should include iflow template packages.""" + gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") + + assert "spec-kit-template-iflow-sh-" in gh_release_text + assert "spec-kit-template-iflow-ps-" in gh_release_text + + def test_iflow_in_agent_context_scripts(self): + """Agent context scripts should support iflow agent type.""" + bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") + pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + + assert "iflow" in bash_text + assert "IFLOW_FILE" in bash_text + assert "iflow" in pwsh_text + assert "IFLOW_FILE" in pwsh_text + + def test_ai_help_includes_iflow(self): + """CLI help text for --ai should include iflow.""" + assert "iflow" in AI_ASSISTANT_HELP From 2bf655e261d90d907f4014bfb7e1da1eeabcc5ec Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Thu, 19 Mar 2026 13:48:48 +0100 Subject: [PATCH 109/321] feat(presets): add enable/disable toggle and update semantics (#1891) * feat(presets): add enable/disable toggle and update semantics Add preset enable/disable CLI commands and update semantics to match the extension system capabilities. Changes: - Add `preset enable` and `preset disable` CLI commands - Add `restore()` method to PresetRegistry for rollback scenarios - Update `get()` and `list()` to return deep copies (prevents mutation) - Update `list_by_priority()` to filter disabled presets by default - Add input validation to `restore()` for defensive programming - Add 16 new tests covering all functionality and edge cases Closes #1851 Closes #1852 Co-Authored-By: Claude Opus 4.5 * fix: address PR review - deep copy and error message accuracy - Fix error message in restore() to match actual validation ("dict" not "non-empty dict") - Use copy.deepcopy() in restore() to prevent caller mutation - Apply same fixes to ExtensionRegistry for parity - Add /defensive-check command for pre-PR validation - Add tests for restore() validation and deep copy behavior Co-Authored-By: Claude Opus 4.5 * revert: remove defensive-check command from PR * fix: address PR review - clarify messaging and add parity - Add note to enable/disable output clarifying commands/skills remain active - Add include_disabled parameter to ExtensionRegistry.list_by_priority for parity - Add tests for extension disabled filtering Co-Authored-By: Claude Opus 4.5 * fix: address PR review - disabled extension resolution and corrupted entries - Fix _get_all_extensions_by_priority to use include_disabled=True for tracking registered IDs, preventing disabled extensions from being picked up as unregistered directories - Add corrupted entry handling to get() - returns None for non-dict entries - Add integration tests for disabled extension template resolution - Add tests for get() corrupted entry handling in both registries Co-Authored-By: Claude Opus 4.5 * fix: handle corrupted registry in list() methods - Add defensive handling to list() when presets/extensions is not a dict - Return empty dict instead of crashing on corrupted registry - Apply same fix to both PresetRegistry and ExtensionRegistry for parity - Add tests for corrupted registry handling Co-Authored-By: Claude Opus 4.5 * fix: validate top-level registry structure in get() and restore() - get() now validates self.data["presets/extensions"] is a dict before accessing - restore() ensures presets/extensions dict exists before writing - Prevents crashes when registry JSON is parseable but has corrupted structure - Applied same fixes to both PresetRegistry and ExtensionRegistry for parity Co-Authored-By: Claude Opus 4.5 * fix: validate root-level JSON structure in _load() and is_installed() - _load() now validates json.load() result is a dict before returning - is_installed() validates presets/extensions is a dict before checking membership - Prevents crashes when registry file is valid JSON but wrong type (e.g., array) - Applied same fixes to both registries for parity Co-Authored-By: Claude Opus 4.5 * fix: normalize presets/extensions field in _load() - _load() now normalizes the presets/extensions field to {} if not a dict - Makes corrupted registries recoverable for add/update/remove operations - Applied same fix to both registries for parity Co-Authored-By: Claude Opus 4.5 * fix: use raw registry keys to track corrupted extensions - Use registry.list().keys() instead of list_by_priority() for tracking - Corrupted entries are now treated as tracked, not picked up as unregistered - Tighten test assertion for disabled preset resolution - Update test to match new expected behavior for corrupted entries Co-Authored-By: Claude Opus 4.5 * fix: handle None metadata in ExtensionManager.remove() - Add defensive check for corrupted metadata in remove() - Match existing pattern in PresetManager.remove() Co-Authored-By: Claude Opus 4.5 * fix: add keys() method and filter corrupted entries in list() - Add lightweight keys() method that returns IDs without deep copy - Update list() to filter out non-dict entries (match type contract) - Use keys() instead of list().keys() for performance - Fix comment to reflect actual behavior Co-Authored-By: Claude Opus 4.5 * fix: address defensive-check findings - deep copy, corruption guards, parity - Extension enable/disable: use delta pattern matching presets - add(): use copy.deepcopy(metadata) in both registries - remove(): guard outer field for corruption in both registries - update(): guard outer field for corruption in both registries Co-Authored-By: Claude Opus 4.5 * fix: deep copy updates in update() to prevent caller mutation Both PresetRegistry.update() and ExtensionRegistry.update() now deep copy the input updates/metadata dict to prevent callers from mutating nested objects after the call. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- src/specify_cli/__init__.py | 89 +++++++- src/specify_cli/extensions.py | 103 +++++++-- src/specify_cli/presets.py | 128 +++++++++-- tests/test_extensions.py | 127 ++++++++++- tests/test_presets.py | 390 ++++++++++++++++++++++++++++++++++ 5 files changed, 783 insertions(+), 54 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ec00301c5e..4865a93de1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2419,6 +2419,89 @@ def preset_set_priority( console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") +@preset_app.command("enable") +def preset_enable( + pack_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(pack_id): + console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(pack_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(pack_id, {"enabled": True}) + + console.print(f"[green]✓[/green] Preset '{pack_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") + + +@preset_app.command("disable") +def preset_disable( + pack_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(pack_id): + console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(pack_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(pack_id, {"enabled": False}) + + console.print(f"[green]✓[/green] Preset '{pack_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") + console.print(f"To re-enable: specify preset enable {pack_id}") + + # ===== Preset Catalog Commands ===== @@ -3855,8 +3938,7 @@ def extension_enable( console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") raise typer.Exit(0) - metadata["enabled"] = True - manager.registry.update(extension_id, metadata) + manager.registry.update(extension_id, {"enabled": True}) # Enable hooks in extensions.yml config = hook_executor.get_project_config() @@ -3903,8 +3985,7 @@ def extension_disable( console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") raise typer.Exit(0) - metadata["enabled"] = False - manager.registry.update(extension_id, metadata) + manager.registry.update(extension_id, {"enabled": False}) # Disable hooks in extensions.yml config = hook_executor.get_project_config() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 984ca83d64..0dca39a0cb 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -222,7 +222,17 @@ def _load(self) -> dict: try: with open(self.registry_path, 'r') as f: - return json.load(f) + data = json.load(f) + # Validate loaded data is a dict (handles corrupted registry files) + if not isinstance(data, dict): + return { + "schema_version": self.SCHEMA_VERSION, + "extensions": {} + } + # Normalize extensions field (handles corrupted extensions value) + if not isinstance(data.get("extensions"), dict): + data["extensions"] = {} + return data except (json.JSONDecodeError, FileNotFoundError): # Corrupted or missing registry, start fresh return { @@ -244,7 +254,7 @@ def add(self, extension_id: str, metadata: dict): metadata: Extension metadata (version, source, etc.) """ self.data["extensions"][extension_id] = { - **metadata, + **copy.deepcopy(metadata), "installed_at": datetime.now(timezone.utc).isoformat() } self._save() @@ -267,15 +277,16 @@ def update(self, extension_id: str, metadata: dict): Raises: KeyError: If extension is not installed """ - if extension_id not in self.data["extensions"]: + extensions = self.data.get("extensions") + if not isinstance(extensions, dict) or extension_id not in extensions: raise KeyError(f"Extension '{extension_id}' is not installed") # Merge new metadata with existing, preserving original installed_at - existing = self.data["extensions"][extension_id] + existing = extensions[extension_id] # Handle corrupted registry entries (e.g., string/list instead of dict) if not isinstance(existing, dict): existing = {} - # Merge: existing fields preserved, new fields override - merged = {**existing, **metadata} + # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) + merged = {**existing, **copy.deepcopy(metadata)} # Always preserve original installed_at based on key existence, not truthiness, # to handle cases where the field exists but may be falsy (legacy/corruption) if "installed_at" in existing: @@ -283,7 +294,7 @@ def update(self, extension_id: str, metadata: dict): else: # If not present in existing, explicitly remove from merged if caller provided it merged.pop("installed_at", None) - self.data["extensions"][extension_id] = merged + extensions[extension_id] = merged self._save() def restore(self, extension_id: str, metadata: dict): @@ -296,8 +307,16 @@ def restore(self, extension_id: str, metadata: dict): Args: extension_id: Extension ID metadata: Complete extension metadata including installed_at + + Raises: + ValueError: If metadata is None or not a dict """ - self.data["extensions"][extension_id] = dict(metadata) + if metadata is None or not isinstance(metadata, dict): + raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict") + # Ensure extensions dict exists (handle corrupted registry) + if not isinstance(self.data.get("extensions"), dict): + self.data["extensions"] = {} + self.data["extensions"][extension_id] = copy.deepcopy(metadata) self._save() def remove(self, extension_id: str): @@ -306,8 +325,11 @@ def remove(self, extension_id: str): Args: extension_id: Extension ID """ - if extension_id in self.data["extensions"]: - del self.data["extensions"][extension_id] + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return + if extension_id in extensions: + del extensions[extension_id] self._save() def get(self, extension_id: str) -> Optional[dict]: @@ -320,21 +342,49 @@ def get(self, extension_id: str) -> Optional[dict]: extension_id: Extension ID Returns: - Deep copy of extension metadata, or None if not found + Deep copy of extension metadata, or None if not found or corrupted """ - entry = self.data["extensions"].get(extension_id) - return copy.deepcopy(entry) if entry is not None else None + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return None + entry = extensions.get(extension_id) + # Return None for missing or corrupted (non-dict) entries + if entry is None or not isinstance(entry, dict): + return None + return copy.deepcopy(entry) def list(self) -> Dict[str, dict]: - """Get all installed extensions. + """Get all installed extensions with valid metadata. + + Returns a deep copy of extensions with dict metadata only. + Corrupted entries (non-dict values) are filtered out. + + Returns: + Dictionary of extension_id -> metadata (deep copies), empty dict if corrupted + """ + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + return {} + # Filter to only valid dict entries to match type contract + return { + ext_id: copy.deepcopy(meta) + for ext_id, meta in extensions.items() + if isinstance(meta, dict) + } + + def keys(self) -> set: + """Get all extension IDs including corrupted entries. - Returns a deep copy of the extensions mapping to prevent callers - from accidentally mutating nested internal registry state. + Lightweight method that returns IDs without deep-copying metadata. + Use this when you only need to check which extensions are tracked. Returns: - Dictionary of extension_id -> metadata (deep copies) + Set of extension IDs (includes corrupted entries) """ - return copy.deepcopy(self.data["extensions"]) + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + return set() + return set(extensions.keys()) def is_installed(self, extension_id: str) -> bool: """Check if extension is installed. @@ -343,17 +393,23 @@ def is_installed(self, extension_id: str) -> bool: extension_id: Extension ID Returns: - True if extension is installed + True if extension is installed, False if not or registry corrupted """ - return extension_id in self.data["extensions"] + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return False + return extension_id in extensions - def list_by_priority(self) -> List[tuple]: + def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: """Get all installed extensions sorted by priority. Lower priority number = higher precedence (checked first). Extensions with equal priority are sorted alphabetically by ID for deterministic ordering. + Args: + include_disabled: If True, include disabled extensions. Default False. + Returns: List of (extension_id, metadata_copy) tuples sorted by priority. Metadata is deep-copied to prevent accidental mutation. @@ -365,6 +421,9 @@ def list_by_priority(self) -> List[tuple]: for ext_id, meta in extensions.items(): if not isinstance(meta, dict): continue + # Skip disabled extensions unless explicitly requested + if not include_disabled and not meta.get("enabled", True): + continue metadata_copy = copy.deepcopy(meta) metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) sortable_extensions.append((ext_id, metadata_copy)) @@ -633,7 +692,7 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: # Get registered commands before removal metadata = self.registry.get(extension_id) - registered_commands = metadata.get("registered_commands", {}) + registered_commands = metadata.get("registered_commands", {}) if metadata else {} extension_dir = self.extensions_dir / extension_id diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 121d596178..aaa6e52e53 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -238,7 +238,17 @@ def _load(self) -> dict: try: with open(self.registry_path, 'r') as f: - return json.load(f) + data = json.load(f) + # Validate loaded data is a dict (handles corrupted registry files) + if not isinstance(data, dict): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + # Normalize presets field (handles corrupted presets value) + if not isinstance(data.get("presets"), dict): + data["presets"] = {} + return data except (json.JSONDecodeError, FileNotFoundError): return { "schema_version": self.SCHEMA_VERSION, @@ -259,7 +269,7 @@ def add(self, pack_id: str, metadata: dict): metadata: Pack metadata (version, source, etc.) """ self.data["presets"][pack_id] = { - **metadata, + **copy.deepcopy(metadata), "installed_at": datetime.now(timezone.utc).isoformat() } self._save() @@ -270,8 +280,11 @@ def remove(self, pack_id: str): Args: pack_id: Preset ID """ - if pack_id in self.data["presets"]: - del self.data["presets"][pack_id] + packs = self.data.get("presets") + if not isinstance(packs, dict): + return + if pack_id in packs: + del packs[pack_id] self._save() def update(self, pack_id: str, updates: dict): @@ -288,14 +301,15 @@ def update(self, pack_id: str, updates: dict): Raises: KeyError: If preset is not installed """ - if pack_id not in self.data["presets"]: + packs = self.data.get("presets") + if not isinstance(packs, dict) or pack_id not in packs: raise KeyError(f"Preset '{pack_id}' not found in registry") - existing = self.data["presets"][pack_id] + existing = packs[pack_id] # Handle corrupted registry entries (e.g., string/list instead of dict) if not isinstance(existing, dict): existing = {} - # Merge: existing fields preserved, new fields override - merged = {**existing, **updates} + # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) + merged = {**existing, **copy.deepcopy(updates)} # Always preserve original installed_at based on key existence, not truthiness, # to handle cases where the field exists but may be falsy (legacy/corruption) if "installed_at" in existing: @@ -303,35 +317,95 @@ def update(self, pack_id: str, updates: dict): else: # If not present in existing, explicitly remove from merged if caller provided it merged.pop("installed_at", None) - self.data["presets"][pack_id] = merged + packs[pack_id] = merged + self._save() + + def restore(self, pack_id: str, metadata: dict): + """Restore preset metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + pack_id: Preset ID + metadata: Complete preset metadata including installed_at + + Raises: + ValueError: If metadata is None or not a dict + """ + if metadata is None or not isinstance(metadata, dict): + raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict") + # Ensure presets dict exists (handle corrupted registry) + if not isinstance(self.data.get("presets"), dict): + self.data["presets"] = {} + self.data["presets"][pack_id] = copy.deepcopy(metadata) self._save() def get(self, pack_id: str) -> Optional[dict]: """Get preset metadata from registry. + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + Args: pack_id: Preset ID Returns: - Pack metadata or None if not found + Deep copy of preset metadata, or None if not found or corrupted """ - return self.data["presets"].get(pack_id) + packs = self.data.get("presets") + if not isinstance(packs, dict): + return None + entry = packs.get(pack_id) + # Return None for missing or corrupted (non-dict) entries + if entry is None or not isinstance(entry, dict): + return None + return copy.deepcopy(entry) def list(self) -> Dict[str, dict]: - """Get all installed presets. + """Get all installed presets with valid metadata. + + Returns a deep copy of presets with dict metadata only. + Corrupted entries (non-dict values) are filtered out. Returns: - Dictionary of pack_id -> metadata + Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted """ - return self.data["presets"] + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return {} + # Filter to only valid dict entries to match type contract + return { + pack_id: copy.deepcopy(meta) + for pack_id, meta in packs.items() + if isinstance(meta, dict) + } + + def keys(self) -> set: + """Get all preset IDs including corrupted entries. + + Lightweight method that returns IDs without deep-copying metadata. + Use this when you only need to check which presets are tracked. + + Returns: + Set of preset IDs (includes corrupted entries) + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return set() + return set(packs.keys()) - def list_by_priority(self) -> List[tuple]: + def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: """Get all installed presets sorted by priority. Lower priority number = higher precedence (checked first). Presets with equal priority are sorted alphabetically by ID for deterministic ordering. + Args: + include_disabled: If True, include disabled presets. Default False. + Returns: List of (pack_id, metadata_copy) tuples sorted by priority. Metadata is deep-copied to prevent accidental mutation. @@ -343,6 +417,9 @@ def list_by_priority(self) -> List[tuple]: for pack_id, meta in packs.items(): if not isinstance(meta, dict): continue + # Skip disabled presets unless explicitly requested + if not include_disabled and not meta.get("enabled", True): + continue metadata_copy = copy.deepcopy(meta) metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) sortable_packs.append((pack_id, metadata_copy)) @@ -358,9 +435,12 @@ def is_installed(self, pack_id: str) -> bool: pack_id: Preset ID Returns: - True if pack is installed + True if pack is installed, False if not or registry corrupted """ - return pack_id in self.data["presets"] + packs = self.data.get("presets") + if not isinstance(packs, dict): + return False + return pack_id in packs class PresetManager: @@ -1466,12 +1546,20 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: return [] registry = ExtensionRegistry(self.extensions_dir) - registered_extensions = registry.list_by_priority() - registered_extension_ids = {ext_id for ext_id, _ in registered_extensions} + # Use keys() to track ALL extensions (including corrupted entries) without deep copy + # This prevents corrupted entries from being picked up as "unregistered" dirs + registered_extension_ids = registry.keys() + + # Get all registered extensions including disabled; we filter disabled manually below + all_registered = registry.list_by_priority(include_disabled=True) all_extensions: list[tuple[int, str, dict | None]] = [] - for ext_id, metadata in registered_extensions: + # Only include enabled extensions in the result + for ext_id, metadata in all_registered: + # Skip disabled extensions + if not metadata.get("enabled", True): + continue priority = normalize_priority(metadata.get("priority") if metadata else None) all_extensions.append((priority, ext_id, metadata)) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5ab7729787..d9db203b9b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -420,6 +420,48 @@ def test_restore_can_recreate_removed_entry(self, temp_dir): assert registry.is_installed("test-ext") assert registry.get("test-ext")["version"] == "1.0.0" + def test_restore_rejects_none_metadata(self, temp_dir): + """Test restore() raises ValueError for None metadata.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", None) + + def test_restore_rejects_non_dict_metadata(self, temp_dir): + """Test restore() raises ValueError for non-dict metadata.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", "not-a-dict") + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", ["list", "not", "dict"]) + + def test_restore_uses_deep_copy(self, temp_dir): + """Test restore() deep copies metadata to prevent mutation.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + original_metadata = { + "version": "1.0.0", + "nested": {"key": "original"}, + } + registry.restore("test-ext", original_metadata) + + # Mutate the original metadata after restore + original_metadata["version"] = "MUTATED" + original_metadata["nested"]["key"] = "MUTATED" + + # Registry should have the original values + stored = registry.get("test-ext") + assert stored["version"] == "1.0.0" + assert stored["nested"]["key"] == "original" + def test_get_returns_deep_copy(self, temp_dir): """Test that get() returns deep copies for nested structures.""" extensions_dir = temp_dir / "extensions" @@ -439,6 +481,26 @@ def test_get_returns_deep_copy(self, temp_dir): internal = registry.data["extensions"]["test-ext"] assert internal["registered_commands"] == {"claude": ["cmd1"]} + def test_get_returns_none_for_corrupted_entry(self, temp_dir): + """Test that get() returns None for corrupted (non-dict) entries.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + # Directly corrupt the registry with non-dict entries + registry.data["extensions"]["corrupted-string"] = "not a dict" + registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"] + registry.data["extensions"]["corrupted-int"] = 42 + registry._save() + + # All corrupted entries should return None + assert registry.get("corrupted-string") is None + assert registry.get("corrupted-list") is None + assert registry.get("corrupted-int") is None + # Non-existent should also return None + assert registry.get("nonexistent") is None + def test_list_returns_deep_copy(self, temp_dir): """Test that list() returns deep copies for nested structures.""" extensions_dir = temp_dir / "extensions" @@ -458,6 +520,20 @@ def test_list_returns_deep_copy(self, temp_dir): internal = registry.data["extensions"]["test-ext"] assert internal["registered_commands"] == {"claude": ["cmd1"]} + def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): + """Test that list() returns empty dict when extensions is not a dict.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + # Corrupt the registry - extensions is a list instead of dict + registry.data["extensions"] = ["not", "a", "dict"] + registry._save() + + # list() should return empty dict, not crash + result = registry.list() + assert result == {} + # ===== ExtensionManager Tests ===== @@ -2509,6 +2585,40 @@ def test_list_by_priority_invalid_priority_defaults(self, temp_dir): assert [item[0] for item in result] == ["ext-high", "ext-invalid"] assert result[1][1]["priority"] == 10 + def test_list_by_priority_excludes_disabled(self, temp_dir): + """Test that list_by_priority excludes disabled extensions by default.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True + + # Default: exclude disabled + by_priority = registry.list_by_priority() + ext_ids = [p[0] for p in by_priority] + assert "ext-enabled" in ext_ids + assert "ext-default" in ext_ids + assert "ext-disabled" not in ext_ids + + def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): + """Test that list_by_priority includes disabled extensions when requested.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + + # Include disabled + by_priority = registry.list_by_priority(include_disabled=True) + ext_ids = [p[0] for p in by_priority] + assert "ext-enabled" in ext_ids + assert "ext-disabled" in ext_ids + # Disabled ext has lower priority number, so it comes first when included + assert ext_ids[0] == "ext-disabled" + def test_install_with_priority(self, extension_dir, project_dir): """Test that install_from_directory stores priority.""" manager = ExtensionManager(project_dir) @@ -2550,8 +2660,8 @@ def test_priority_preserved_on_update(self, temp_dir): assert updated["priority"] == 5 # Preserved assert updated["enabled"] is False # Updated - def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir): - """Resolution scans unregistered extension dirs after valid registry entries.""" + def test_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_dir): + """Corrupted registry entries are still tracked and NOT picked up as unregistered.""" extensions_dir = project_dir / ".specify" / "extensions" valid_dir = extensions_dir / "valid-ext" / "templates" @@ -2564,20 +2674,21 @@ def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrup registry = ExtensionRegistry(extensions_dir) registry.add("valid-ext", {"version": "1.0.0", "priority": 10}) + # Corrupt the entry - should still be tracked, not picked up as unregistered registry.data["extensions"]["broken-ext"] = "corrupted" registry._save() from specify_cli.presets import PresetResolver resolver = PresetResolver(project_dir) + # Corrupted extension templates should NOT be resolved resolved = resolver.resolve("target-template") - sourced = resolver.resolve_with_source("target-template") + assert resolved is None - assert resolved is not None - assert resolved.name == "target-template.md" - assert "Broken Target" in resolved.read_text() - assert sourced is not None - assert sourced["source"] == "extension:broken-ext (unregistered)" + # Valid extension template should still resolve + valid_resolved = resolver.resolve("other-template") + assert valid_resolved is not None + assert "Valid" in valid_resolved.read_text() class TestExtensionPriorityCLI: diff --git a/tests/test_presets.py b/tests/test_presets.py index b6fe81d5ba..2716b73dc7 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -369,6 +369,172 @@ def test_get_nonexistent(self, temp_dir): registry = PresetRegistry(packs_dir) assert registry.get("nonexistent") is None + def test_restore(self, temp_dir): + """Test restore() preserves timestamps exactly.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Create original entry with a specific timestamp + original_metadata = { + "version": "1.0.0", + "source": "local", + "installed_at": "2025-01-15T10:30:00+00:00", + "enabled": True, + } + registry.restore("test-pack", original_metadata) + + # Verify exact restoration + restored = registry.get("test-pack") + assert restored["installed_at"] == "2025-01-15T10:30:00+00:00" + assert restored["version"] == "1.0.0" + assert restored["enabled"] is True + + def test_restore_rejects_none_metadata(self, temp_dir): + """Test restore() raises ValueError for None metadata.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", None) + + def test_restore_rejects_non_dict_metadata(self, temp_dir): + """Test restore() raises ValueError for non-dict metadata.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", "not-a-dict") + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", ["list", "not", "dict"]) + + def test_restore_uses_deep_copy(self, temp_dir): + """Test restore() deep copies metadata to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + original_metadata = { + "version": "1.0.0", + "nested": {"key": "original"}, + } + registry.restore("test-pack", original_metadata) + + # Mutate the original metadata after restore + original_metadata["version"] = "MUTATED" + original_metadata["nested"]["key"] = "MUTATED" + + # Registry should have the original values + stored = registry.get("test-pack") + assert stored["version"] == "1.0.0" + assert stored["nested"]["key"] == "original" + + def test_get_returns_deep_copy(self, temp_dir): + """Test that get() returns a deep copy to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) + + # Get and mutate the returned copy + metadata = registry.get("test-pack") + metadata["version"] = "MUTATED" + metadata["nested"]["key"] = "MUTATED" + + # Original should be unchanged + fresh = registry.get("test-pack") + assert fresh["version"] == "1.0.0" + assert fresh["nested"]["key"] == "original" + + def test_get_returns_none_for_corrupted_entry(self, temp_dir): + """Test that get() returns None for corrupted (non-dict) entries.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Directly corrupt the registry with non-dict entries + registry.data["presets"]["corrupted-string"] = "not a dict" + registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"] + registry.data["presets"]["corrupted-int"] = 42 + registry._save() + + # All corrupted entries should return None + assert registry.get("corrupted-string") is None + assert registry.get("corrupted-list") is None + assert registry.get("corrupted-int") is None + # Non-existent should also return None + assert registry.get("nonexistent") is None + + def test_list_returns_deep_copy(self, temp_dir): + """Test that list() returns deep copies to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) + + # Get list and mutate + all_packs = registry.list() + all_packs["test-pack"]["version"] = "MUTATED" + all_packs["test-pack"]["nested"]["key"] = "MUTATED" + + # Original should be unchanged + fresh = registry.get("test-pack") + assert fresh["version"] == "1.0.0" + assert fresh["nested"]["key"] == "original" + + def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): + """Test that list() returns empty dict when presets is not a dict.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Corrupt the registry - presets is a list instead of dict + registry.data["presets"] = ["not", "a", "dict"] + registry._save() + + # list() should return empty dict, not crash + result = registry.list() + assert result == {} + + def test_list_by_priority_excludes_disabled(self, temp_dir): + """Test that list_by_priority excludes disabled presets by default.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True + + # Default: exclude disabled + by_priority = registry.list_by_priority() + pack_ids = [p[0] for p in by_priority] + assert "pack-enabled" in pack_ids + assert "pack-default" in pack_ids + assert "pack-disabled" not in pack_ids + + def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): + """Test that list_by_priority includes disabled presets when requested.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + + # Include disabled + by_priority = registry.list_by_priority(include_disabled=True) + pack_ids = [p[0] for p in by_priority] + assert "pack-enabled" in pack_ids + assert "pack-disabled" in pack_ids + # Disabled pack has lower priority number, so it comes first when included + assert pack_ids[0] == "pack-disabled" + # ===== PresetManager Tests ===== @@ -707,6 +873,44 @@ def test_resolve_extension_provided_templates(self, project_dir): assert result is not None assert "Extension Custom Template" in result.read_text() + def test_resolve_disabled_extension_templates_skipped(self, project_dir): + """Test that disabled extension templates are not resolved.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "disabled-template.md" + ext_template.write_text("# Disabled Extension Template\n") + + # Register extension as disabled + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False}) + + # Template should NOT be resolved because extension is disabled + resolver = PresetResolver(project_dir) + result = resolver.resolve("disabled-template") + assert result is None, "Disabled extension template should not be resolved" + + def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir): + """Test that disabled extensions are not picked up via unregistered dir scan.""" + # Create extension directory with templates + ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "unique-disabled-template.md" + ext_template.write_text("# Should Not Resolve\n") + + # Register the extension but disable it + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False}) + + # Verify the template is NOT resolved (even though the directory exists) + resolver = PresetResolver(project_dir) + result = resolver.resolve("unique-disabled-template") + assert result is None, "Disabled extension should not be picked up as unregistered" + def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): """Test that pack templates take priority over extension templates.""" # Create extension with templates @@ -2001,3 +2205,189 @@ def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): "legacy-pack", "low-priority-pack", ] + + +class TestPresetEnableDisable: + """Test preset enable/disable CLI commands.""" + + def test_disable_preset(self, project_dir, pack_dir): + """Test disable command sets enabled=False.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify initially enabled + assert manager.registry.get("test-pack").get("enabled", True) is True + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "disabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is False + + def test_enable_preset(self, project_dir, pack_dir): + """Test enable command sets enabled=True.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + # Verify disabled + assert manager.registry.get("test-pack")["enabled"] is False + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "enabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is True + + def test_disable_already_disabled(self, project_dir, pack_dir): + """Test disable on already disabled preset shows warning.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already disabled" in result.output.lower() + + def test_enable_already_enabled(self, project_dir, pack_dir): + """Test enable on already enabled preset shows warning.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset (enabled by default) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already enabled" in result.output.lower() + + def test_disable_not_installed(self, project_dir): + """Test disable fails for non-installed preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_enable_not_installed(self, project_dir): + """Test enable fails for non-installed preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): + """Test that disabled presets are excluded from template resolution.""" + # Install preset with a template + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create a template in the preset directory + preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md" + preset_template.parent.mkdir(parents=True, exist_ok=True) + preset_template.write_text("# Template from test-pack") + + resolver = PresetResolver(project_dir) + + # Template should be found when enabled + result = resolver.resolve("test-template", "template") + assert result is not None + assert "test-pack" in str(result) + + # Disable the preset + manager.registry.update("test-pack", {"enabled": False}) + + # Template should NOT be found when disabled + resolver2 = PresetResolver(project_dir) + result2 = resolver2.resolve("test-template", "template") + assert result2 is None + + def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test enable fails gracefully for corrupted registry entry.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() + + def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test disable fails gracefully for corrupted registry entry.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() From 7484eb521ae97d668a318619820fa1f200a98125 Mon Sep 17 00:00:00 2001 From: davesharpe13 Date: Thu, 19 Mar 2026 09:11:31 -0400 Subject: [PATCH 110/321] feat(extensions): add verify-tasks extension to community catalog (#1871) * feat(extensions): add verify-tasks extension to community catalog - Extension ID: verify-tasks - Version: 1.0.0 - Detects phantom completions: tasks marked [X] in tasks.md with no real implementation Co-Authored-By: Claude Sonnet 4.6 * Replace email with name in verify-tasks catalog entry Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- extensions/README.md | 1 + extensions/catalog.community.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index e58787c1de..4e86694294 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -92,6 +92,7 @@ The following community-contributed extensions are available in [`catalog.commun | Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | +| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | ## Adding Your Extension diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index d613dfda45..abcd36c228 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -683,6 +683,37 @@ "stars": 0, "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" + }, + "verify-tasks": { + "name": "Verify Tasks Extension", + "id": "verify-tasks", + "description": "Detect phantom completions: tasks marked [X] in tasks.md with no real implementation.", + "author": "Dave Sharpe", + "version": "1.0.0", + "download_url": "https://github.com/datastone-inc/spec-kit-verify-tasks/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/datastone-inc/spec-kit-verify-tasks", + "homepage": "https://github.com/datastone-inc/spec-kit-verify-tasks", + "documentation": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/README.md", + "changelog": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "verification", + "quality", + "phantom-completion", + "tasks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" } } } From 2f25e2d57507451db353dcc3634175f5dcfcb806 Mon Sep 17 00:00:00 2001 From: Li-Xian Chen Date: Thu, 19 Mar 2026 22:12:29 +0900 Subject: [PATCH 111/321] Add conduct extension to community catalog (#1908) - Extension ID: conduct - Version: 1.0.0 - Author: twbrandon7 - Description: Executes a single spec-kit phase via sub-agent delegation to reduce context pollution. --- extensions/README.md | 1 + extensions/catalog.community.json | 32 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index 4e86694294..3aedd60388 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,6 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | +| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index abcd36c228..4ae3512509 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-18T19:53:31Z", + "updated_at": "2026-03-19T12:08:20Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "archive": { @@ -149,6 +149,36 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "conduct": { + "name": "Conduct Extension", + "id": "conduct", + "description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.", + "author": "twbrandon7", + "version": "1.0.0", + "download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/twbrandon7/spec-kit-conduct-ext", + "homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext", + "documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md", + "changelog": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.1" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "conduct", + "workflow", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-19T12:08:20Z", + "updated_at": "2026-03-19T12:08:20Z" + }, "docguard": { "name": "DocGuard \u2014 CDD Enforcement", "id": "docguard", From a4b60aca7ffe2bd5f789cf5971f2595c8555c2e2 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:52:09 -0500 Subject: [PATCH 112/321] chore: bump version to 0.3.2 (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 0.3.2 * fix: correct changelog generation — use tag sort instead of git describe, remove duplicate entries - Replace git describe --tags --abbrev=0 with git tag --sort=-version:refname to find the correct previous tag (git describe misses tags on unmerged release branches) - Change changelog section heading from '### Changed' to '### Changes' - Remove duplicate entries from 0.3.2 that belonged to prior releases - Clean up changelog preamble and stale entries --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/release-trigger.yml | 8 ++++-- CHANGELOG.md | 40 +++++++++++++-------------- pyproject.toml | 2 +- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index 77b727a8c1..e5f62f1740 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -86,8 +86,10 @@ jobs: if [ -f "CHANGELOG.md" ]; then DATE=$(date +%Y-%m-%d) - # Get the previous tag to compare commits - PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + # Get the previous tag by sorting all version tags numerically + # (git describe --tags only finds tags reachable from HEAD, + # which misses tags on unmerged release branches) + PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1) echo "Generating changelog from commits..." if [[ -n "$PREVIOUS_TAG" ]]; then @@ -104,7 +106,7 @@ jobs: echo "" echo "## [${{ steps.version.outputs.version }}] - $DATE" echo "" - echo "### Changed" + echo "### Changes" echo "" echo "$COMMITS" echo "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fab8a3d01..43f93b6ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,25 @@ # Changelog - +## [0.3.2] - 2026-03-19 + +### Changes + +- Add conduct extension to community catalog (#1908) +- feat(extensions): add verify-tasks extension to community catalog (#1871) +- feat(presets): add enable/disable toggle and update semantics (#1891) +- feat: add iFlow CLI support (#1875) +- feat(commands): wire before/after hook events into specify and plan templates (#1886) +- docs(catalog): add speckit-utils to community catalog (#1896) +- docs: Add Extensions & Presets section to README (#1898) +- chore: update DocGuard extension to v0.9.11 (#1899) +- Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884) +- feat: register spec-kit-iterate extension (#1887) +- fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885) +- fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872) +- chore: update DocGuard extension to v0.9.10 (#1890) +- Feature/spec kit add pi coding agent pullrequest (#1853) +- feat: register spec-kit-learn extension (#1883) -Recent changes to the Specify CLI and templates are documented here. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.3.1] - 2026-03-17 @@ -307,19 +321,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - chore(deps): bump actions/stale from 9 to 10 (#1623) - feat: add dependabot configuration for pip and GitHub Actions updates (#1622) - -## [0.0.97] - 2026-02-18 - -- Remove Maintainers section from README.md (#1618) - -## [0.0.96] - 2026-02-17 - -- fix: typo in plan-template.md (#1446) - -## [0.0.95] - 2026-02-12 - -- Feat: add a new agent: Google Anti Gravity (#1220) - -## [0.0.94] - 2026-02-11 - -- Add stale workflow for 180-day inactive issues and PRs (#1594) diff --git a/pyproject.toml b/pyproject.toml index df200d480e..8c40ff3730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.3.1" +version = "0.3.2" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From c8af730b1453d1a108b5f49fd9050c792c34532a Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 19 Mar 2026 22:00:41 +0800 Subject: [PATCH 113/321] feat: migrate Codex/agy init to native skills workflow (#1906) * feat: migrate codex and agy to native skills flow * fix: harden codex skill frontmatter and script fallback * fix: clarify skills separator default expansion * fix: rewrite agent_scripts paths for codex skills * fix: align kimi guidance and platform-aware codex fallback --- .../scripts/create-release-packages.ps1 | 23 +- .../scripts/create-release-packages.sh | 22 +- README.md | 15 +- src/specify_cli/__init__.py | 192 ++++++----- src/specify_cli/agents.py | 149 ++++++++- src/specify_cli/presets.py | 2 - tests/test_agent_config_consistency.py | 20 +- tests/test_ai_skills.py | 156 ++++++++- tests/test_extensions.py | 313 +++++++++++++++++- 9 files changed, 769 insertions(+), 123 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 54698b9ec8..a14e736396 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -201,20 +201,22 @@ agent: $basename } } -# Create Kimi Code skills in .kimi/skills//SKILL.md format. -# Kimi CLI discovers skills as directories containing a SKILL.md file, -# invoked with /skill: (e.g. /skill:speckit.specify). -function New-KimiSkills { +# Create skills in \\SKILL.md format. +# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the +# current dotted-name exception (e.g. speckit.plan). +function New-Skills { param( [string]$SkillsDir, - [string]$ScriptVariant + [string]$ScriptVariant, + [string]$AgentName, + [string]$Separator = '-' ) $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue foreach ($template in $templates) { $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - $skillName = "speckit.$name" + $skillName = "speckit${Separator}$name" $skillDir = Join-Path $SkillsDir $skillName New-Item -ItemType Directory -Force -Path $skillDir | Out-Null @@ -267,7 +269,7 @@ function New-KimiSkills { $body = $outputLines -join "`n" $body = $body -replace '\{ARGS\}', '$ARGUMENTS' - $body = $body -replace '__AGENT__', 'kimi' + $body = $body -replace '__AGENT__', $AgentName $body = Rewrite-Paths -Content $body # Strip existing frontmatter, keep only body @@ -396,8 +398,9 @@ function Build-Variant { Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } 'codex' { - $cmdDir = Join-Path $baseDir ".codex/prompts" - Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script + $skillsDir = Join-Path $baseDir ".agents/skills" + New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null + New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-' } 'kilocode' { $cmdDir = Join-Path $baseDir ".kilocode/workflows" @@ -452,7 +455,7 @@ function Build-Variant { 'kimi' { $skillsDir = Join-Path $baseDir ".kimi/skills" New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script + New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.' } 'trae' { $rulesDir = Join-Path $baseDir ".trae/rules" diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index cb3c0552ba..ab17008543 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -121,18 +121,20 @@ EOF done } -# Create Kimi Code skills in .kimi/skills//SKILL.md format. -# Kimi CLI discovers skills as directories containing a SKILL.md file, -# invoked with /skill: (e.g. /skill:speckit.specify). -create_kimi_skills() { +# Create skills in //SKILL.md format. +# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the +# current dotted-name exception (e.g. speckit.plan). +create_skills() { local skills_dir="$1" local script_variant="$2" + local agent_name="$3" + local separator="${4:-"-"}" for template in templates/commands/*.md; do [[ -f "$template" ]] || continue local name name=$(basename "$template" .md) - local skill_name="speckit.${name}" + local skill_name="speckit${separator}${name}" local skill_dir="${skills_dir}/${skill_name}" mkdir -p "$skill_dir" @@ -175,9 +177,9 @@ create_kimi_skills() { in_frontmatter && skip_scripts && /^[[:space:]]/ { next } { print } ') - body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths) + body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths) - # Strip existing frontmatter and prepend Kimi frontmatter + # Strip existing frontmatter and prepend skills frontmatter. local template_body template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found') @@ -249,8 +251,8 @@ build_variant() { mkdir -p "$base_dir/.windsurf/workflows" generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; codex) - mkdir -p "$base_dir/.codex/prompts" - generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;; + mkdir -p "$base_dir/.agents/skills" + create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;; kilocode) mkdir -p "$base_dir/.kilocode/workflows" generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;; @@ -290,7 +292,7 @@ build_variant() { generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; kimi) mkdir -p "$base_dir/.kimi/skills" - create_kimi_skills "$base_dir/.kimi/skills" "$script" ;; + create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;; trae) mkdir -p "$base_dir/.trae/rules" generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; diff --git a/README.md b/README.md index 883becc305..8e99a9532c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c ### 2. Establish project principles -Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant. +Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. @@ -173,7 +173,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | | [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | -| [Codex CLI](https://github.com/openai/codex) | ✅ | | +| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | | [Cursor](https://cursor.sh/) | ✅ | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | | [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | @@ -258,6 +258,9 @@ specify init my-project --ai bob # Initialize with Pi Coding Agent support specify init my-project --ai pi +# Initialize with Codex CLI support +specify init my-project --ai codex --ai-skills + # Initialize with Antigravity support specify init my-project --ai agy --ai-skills @@ -298,7 +301,9 @@ specify check ### Available Slash Commands -After running `specify init`, your AI coding agent will have access to these slash commands for structured development: +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. + +For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. #### Core Commands @@ -484,11 +489,11 @@ specify init --ai copilot # Or in current directory: specify init . --ai claude -specify init . --ai codex +specify init . --ai codex --ai-skills # or use --here flag specify init --here --ai claude -specify init --here --ai codex +specify init --here --ai codex --ai-skills # Force merge into a non-empty current directory specify init . --force --ai claude diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4865a93de1..5c361d7062 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -31,7 +31,6 @@ import zipfile import tempfile import shutil -import shlex import json import json5 import stat @@ -172,8 +171,8 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) }, "codex": { "name": "Codex CLI", - "folder": ".codex/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ + "folder": ".agents/", + "commands_subdir": "skills", # Codex now uses project skills directly "install_url": "https://github.com/openai/codex", "requires_cli": True, }, @@ -1211,6 +1210,9 @@ def load_init_options(project_path: Path) -> dict[str, Any]: # Default skills directory for agents not in AGENT_CONFIG DEFAULT_SKILLS_DIR = ".agents/skills" +# Agents whose downloaded template already contains skills in the final layout. +NATIVE_SKILLS_AGENTS = {"codex", "kimi"} + # Enhanced descriptions for each spec-kit command skill SKILL_DESCRIPTIONS = { "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", @@ -1339,8 +1341,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker command_name = command_name[len("speckit."):] if command_name.endswith(".agent"): command_name = command_name[:-len(".agent")] - # Kimi CLI discovers skills by directory name and invokes them as - # /skill: — use dot separator to match packaging convention. if selected_ai == "kimi": skill_name = f"speckit.{command_name}" else: @@ -1416,20 +1416,45 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker return installed_count > 0 or skipped_count > 0 -def _handle_agy_deprecation(console: Console) -> None: - """ - Print the deprecation error for the Antigravity (agy) agent and exit. - - Context for future AI reviewers (e.g., GitHub Copilot): - - Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool. - - v1.20.5 removed this "command support" in favor of "Agent Skills". - - `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is - invoked without the accompanying `--ai-skills` flag. Users are instructed to add - the skills flag to generate agent skills templates instead. - """ - console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.") +def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool: + """Return True when a native-skills agent has spec-kit bundled skills.""" + skills_dir = _get_skills_dir(project_path, selected_ai) + if not skills_dir.is_dir(): + return False + + pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md" + return any(skills_dir.glob(pattern)) + + +AGENT_SKILLS_MIGRATIONS = { + "agy": { + "error": "Explicit command support was deprecated in Antigravity version 1.20.5.", + "usage": "specify init --ai agy --ai-skills", + "interactive_note": ( + "'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] " + "automatically for compatibility (explicit .agent/commands usage is deprecated)." + ), + }, + "codex": { + "error": ( + "Custom prompt-based spec-kit initialization is deprecated for Codex CLI; " + "use agent skills instead." + ), + "usage": "specify init --ai codex --ai-skills", + "interactive_note": ( + "'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] " + "automatically for compatibility (.agents/skills is the recommended Codex layout)." + ), + }, +} + + +def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: + """Print a fail-fast migration error for agents that now require skills.""" + migration = AGENT_SKILLS_MIGRATIONS[agent_key] + console.print(f"\n[red]Error:[/red] {migration['error']}") console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") - console.print("[yellow]Usage:[/yellow] specify init --ai agy --ai-skills") + console.print(f"[yellow]Usage:[/yellow] {migration['usage']}") raise typer.Exit(1) @app.command() @@ -1467,7 +1492,7 @@ def init( specify init . --ai claude # Initialize in current directory specify init . # Initialize in current directory (interactive AI selection) specify init --here --ai claude # Alternative syntax for current directory - specify init --here --ai codex + specify init --here --ai codex --ai-skills specify init --here --ai codebuddy specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here @@ -1557,24 +1582,16 @@ def init( "copilot" ) - # [DEPRECATION NOTICE: Antigravity (agy)] - # As of Antigravity v1.20.5, traditional CLI "command" support was fully removed - # in favor of "Agent Skills" (SKILL.md files under /skills//). - # Because 'specify_cli' historically populated .agent/commands/, we now must explicitly - # enforce the `--ai-skills` flag for `agy` to ensure valid template generation. - if selected_ai == "agy" and not ai_skills: - # If agy was selected interactively (no --ai provided), automatically enable + # Agents that have moved from explicit commands/prompts to agent skills. + if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: + # If selected interactively (no --ai provided), automatically enable # ai_skills so the agent remains usable without requiring an extra flag. - # Preserve deprecation behavior only for explicit '--ai agy' without skills. + # Preserve fail-fast behavior only for explicit '--ai ' without skills. if ai_assistant: - _handle_agy_deprecation(console) + _handle_agent_skills_migration(console, selected_ai) else: ai_skills = True - console.print( - "\n[yellow]Note:[/yellow] 'agy' was selected interactively; " - "enabling [cyan]--ai-skills[/cyan] automatically for compatibility " - "(explicit .agent/commands usage is deprecated)." - ) + console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}") # Validate --ai-commands-dir usage if selected_ai == "generic": @@ -1698,28 +1715,41 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) if ai_skills: - skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) - - # When --ai-skills is used on a NEW project and skills were - # successfully installed, remove the command files that the - # template archive just created. Skills replace commands, so - # keeping both would be confusing. For --here on an existing - # repo we leave pre-existing commands untouched to avoid a - # breaking change. We only delete AFTER skills succeed so the - # project always has at least one of {commands, skills}. - if skills_ok and not here: - agent_cfg = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - if cmds_dir.exists(): - try: - shutil.rmtree(cmds_dir) - except OSError: - # Best-effort cleanup: skills are already installed, - # so leaving stale commands is non-fatal. - console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") + if selected_ai in NATIVE_SKILLS_AGENTS: + skills_dir = _get_skills_dir(project_path, selected_ai) + if not _has_bundled_skills(project_path, selected_ai): + raise RuntimeError( + f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, " + "but none were found. Re-run with an up-to-date template." + ) + if tracker: + tracker.start("ai-skills") + tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}") + else: + console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/") + else: + skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) + + # When --ai-skills is used on a NEW project and skills were + # successfully installed, remove the command files that the + # template archive just created. Skills replace commands, so + # keeping both would be confusing. For --here on an existing + # repo we leave pre-existing commands untouched to avoid a + # breaking change. We only delete AFTER skills succeed so the + # project always has at least one of {commands, skills}. + if skills_ok and not here: + agent_cfg = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_cfg.get("folder", "") + commands_subdir = agent_cfg.get("commands_subdir", "commands") + if agent_folder: + cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir + if cmds_dir.exists(): + try: + shutil.rmtree(cmds_dir) + except OSError: + # Best-effort cleanup: skills are already installed, + # so leaving stale commands is non-fatal. + console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") if not no_git: tracker.start("git") @@ -1843,38 +1873,48 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 - # Add Codex-specific setup step if needed - if selected_ai == "codex": - codex_path = project_path / ".codex" - quoted_path = shlex.quote(str(codex_path)) - if os.name == "nt": # Windows - cmd = f"setx CODEX_HOME {quoted_path}" - else: # Unix-like systems - cmd = f"export CODEX_HOME={quoted_path}" - - steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]") + if selected_ai == "codex" and ai_skills: + steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") step_num += 1 - steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:") + codex_skill_mode = selected_ai == "codex" and ai_skills + kimi_skill_mode = selected_ai == "kimi" + native_skill_mode = codex_skill_mode or kimi_skill_mode + usage_label = "skills" if native_skill_mode else "slash commands" - steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles") - steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification") - steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan") - steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks") - steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation") + def _display_cmd(name: str) -> str: + if codex_skill_mode: + return f"$speckit-{name}" + if kimi_skill_mode: + return f"/skill:speckit.{name}" + return f"/speckit.{name}" + + steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") + + steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") + steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") + steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") + steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") + steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) console.print() console.print(steps_panel) + enhancement_intro = ( + "Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + if native_skill_mode + else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + ) enhancement_lines = [ - "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", + enhancement_intro, "", - "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", - "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)", + f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])", + f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])" ] - enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) + enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands" + enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2)) console.print() console.print(enhancements_panel) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 2f71cb188a..d59e841c84 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Dict, List, Any +import platform import yaml @@ -59,10 +60,10 @@ class CommandRegistrar: "extension": ".md" }, "codex": { - "dir": ".codex/prompts", + "dir": ".agents/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md" + "extension": "/SKILL.md", }, "windsurf": { "dir": ".windsurf/workflows", @@ -140,7 +141,7 @@ class CommandRegistrar: "dir": ".kimi/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": "/SKILL.md" + "extension": "/SKILL.md", }, "trae": { "dir": ".trae/rules", @@ -182,6 +183,9 @@ def parse_frontmatter(content: str) -> tuple[dict, str]: except yaml.YAMLError: frontmatter = {} + if not isinstance(frontmatter, dict): + frontmatter = {} + return frontmatter, body @staticmethod @@ -209,11 +213,14 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: 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:]}" + for script_key in ("scripts", "agent_scripts"): + scripts = frontmatter.get(script_key) + if not isinstance(scripts, dict): + continue + + for key, script_path in scripts.items(): + if isinstance(script_path, str) and script_path.startswith("../../scripts/"): + scripts[key] = f".specify/scripts/{script_path[14:]}" return frontmatter def render_markdown_command( @@ -270,6 +277,95 @@ def render_toml_command( return "\n".join(toml_lines) + 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. + """ + if not isinstance(frontmatter, dict): + frontmatter = {} + + if agent_name == "codex": + body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root) + + description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") + skill_frontmatter = { + "name": skill_name, + "description": description, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"{source_id}:{source_file}", + }, + } + return self.render_frontmatter(skill_frontmatter) + "\n" + body + + @staticmethod + def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str: + """Resolve script placeholders for Codex skill overrides. + + This intentionally scopes the fix to Codex, which is the newly + migrated runtime path in this PR. Existing Kimi behavior is left + unchanged for now. + """ + try: + from . import load_init_options + except ImportError: + return body + + if not isinstance(frontmatter, dict): + frontmatter = {} + + scripts = frontmatter.get("scripts", {}) or {} + agent_scripts = frontmatter.get("agent_scripts", {}) or {} + if not isinstance(scripts, dict): + scripts = {} + if not isinstance(agent_scripts, dict): + agent_scripts = {} + + script_variant = load_init_options(project_root).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 or default_variant in agent_scripts: + fallback_order.append(default_variant) + if secondary_variant in scripts or secondary_variant in agent_scripts: + fallback_order.append(secondary_variant) + + for key in scripts: + if key not in fallback_order: + fallback_order.append(key) + for key in agent_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) + + agent_script_command = agent_scripts.get(script_variant) if script_variant else None + if agent_script_command: + agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") + body = body.replace("{AGENT_SCRIPT}", agent_script_command) + + return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex") + def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: """Convert argument placeholder format. @@ -283,6 +379,18 @@ def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_ """ 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."):] + + return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}" + def register_commands( self, agent_name: str, @@ -334,14 +442,20 @@ def register_commands( body, "$ARGUMENTS", agent_config["args"] ) - if agent_config["format"] == "markdown": + 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": output = self.render_markdown_command(frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": output = self.render_toml_command(frontmatter, body, source_id) else: raise ValueError(f"Unsupported format: {agent_config['format']}") - dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + dest_file = commands_dir / f"{output_name}{agent_config['extension']}" dest_file.parent.mkdir(parents=True, exist_ok=True) dest_file.write_text(output, encoding="utf-8") @@ -351,9 +465,15 @@ def register_commands( registered.append(cmd_name) for alias in cmd_info.get("aliases", []): - alias_file = commands_dir / f"{alias}{agent_config['extension']}" + alias_output_name = self._compute_output_name(agent_name, alias, agent_config) + 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']}" alias_file.parent.mkdir(parents=True, exist_ok=True) - alias_file.write_text(output, encoding="utf-8") + alias_file.write_text(alias_output, encoding="utf-8") if agent_name == "copilot": self.write_copilot_prompt(project_root, alias) registered.append(alias) @@ -396,7 +516,7 @@ def register_commands_for_all_agents( results = {} for agent_name, agent_config in self.AGENT_CONFIGS.items(): - agent_dir = project_root / agent_config["dir"].split("/")[0] + agent_dir = project_root / agent_config["dir"] if agent_dir.exists(): try: @@ -430,7 +550,8 @@ def unregister_commands( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + 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() diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index aaa6e52e53..c53915faaf 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -646,8 +646,6 @@ def _register_skills( short_name = cmd_name if short_name.startswith("speckit."): short_name = short_name[len("speckit."):] - # Kimi CLI discovers skills by directory name and invokes them as - # /skill: — use dot separator to match packaging convention. if selected_ai == "kimi": skill_name = f"speckit.{short_name}" else: diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index b748287551..fe5c01cf75 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -29,11 +29,17 @@ def test_extension_registrar_uses_kiro_cli_and_removes_q(self): assert "q" not in cfg def test_extension_registrar_includes_codex(self): - """Extension command registrar should include codex targeting .codex/prompts.""" + """Extension command registrar should include codex targeting .agents/skills.""" cfg = CommandRegistrar.AGENT_CONFIGS assert "codex" in cfg - assert cfg["codex"]["dir"] == ".codex/prompts" + assert cfg["codex"]["dir"] == ".agents/skills" + assert cfg["codex"]["extension"] == "/SKILL.md" + + def test_runtime_codex_uses_native_skills(self): + """Codex runtime config should point at .agents/skills.""" + assert AGENT_CONFIG["codex"]["folder"] == ".agents/" + assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" @@ -71,6 +77,16 @@ def test_release_sh_switch_has_shai_and_agy_generation(self): assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None + def test_release_scripts_generate_codex_skills(self): + """Release scripts should generate Codex skills in .agents/skills.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") + + assert ".agents/skills" in sh_text + assert ".agents/skills" in ps_text + assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None + assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None + def test_init_ai_help_includes_roo_and_kiro_alias(self): """CLI help text for --ai should stay in sync with agent config and alias guidance.""" assert "roo" in AI_ASSISTANT_HELP diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index b2bc01a954..08017430b1 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -471,8 +471,7 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key): skills_dir = _get_skills_dir(proj, agent_key) assert skills_dir.exists() skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation; - # all other agents use hyphen-separator (speckit-specify). + # Kimi uses dotted skill names; other agents use hyphen-separated names. expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify" assert expected_skill_name in skill_dirs assert (skills_dir / expected_skill_name / "SKILL.md").exists() @@ -694,6 +693,82 @@ def fake_download(project_path, *args, **kwargs): prompts_dir = target / ".kiro" / "prompts" assert not prompts_dir.exists() + def test_codex_native_skills_preserved_without_conversion(self, tmp_path): + """Codex should keep bundled .agents/skills and skip install_ai_skills conversion.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "new-codex-proj" + + def fake_download(project_path, *args, **kwargs): + skill_dir = project_path / ".agents" / "skills" / "speckit-specify" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills") as mock_skills, \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 0 + mock_skills.assert_not_called() + assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists() + + def test_codex_native_skills_missing_fails_clearly(self, tmp_path): + """Codex native skills init should fail if bundled skills are missing.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "missing-codex-skills" + + with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills") as mock_skills, \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 1 + mock_skills.assert_not_called() + assert "Expected bundled agent skills" in result.output + + def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path): + """Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "foreign-codex-skills" + + def fake_download(project_path, *args, **kwargs): + skill_dir = project_path / ".agents" / "skills" / "other-tool" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n") + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills") as mock_skills, \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 1 + mock_skills.assert_not_called() + assert "Expected bundled agent skills" in result.output + def test_commands_preserved_when_skills_fail(self, tmp_path): """If skills fail, commands should NOT be removed (safety net).""" from typer.testing import CliRunner @@ -837,6 +912,17 @@ def test_agy_without_ai_skills_fails(self): assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output assert "--ai-skills" in result.output + def test_codex_without_ai_skills_fails(self): + """--ai codex without --ai-skills should fail with exit code 1.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"]) + + assert result.exit_code == 1 + assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output + assert "--ai-skills" in result.output + def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" from typer.testing import CliRunner @@ -879,6 +965,72 @@ def _fake_select_with_arrows(*args, **kwargs): assert result.exit_code == 0 assert "Explicit command support was deprecated" not in result.output + def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch): + """Interactive selector returning codex without --ai-skills should automatically enable --ai-skills.""" + from typer.testing import CliRunner + + def _fake_select_with_arrows(*args, **kwargs): + options = kwargs.get("options") + if options is None and len(args) >= 1: + options = args[0] + + if isinstance(options, dict) and "codex" in options: + return "codex" + if isinstance(options, (list, tuple)) and "codex" in options: + return "codex" + + if isinstance(options, dict) and options: + return next(iter(options.keys())) + if isinstance(options, (list, tuple)) and options: + return options[0] + + return None + + monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) + + def _fake_download(*args, **kwargs): + project_path = Path(args[0]) + skill_dir = project_path / ".agents" / "skills" / "speckit-specify" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"]) + + assert result.exit_code == 0 + assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output + assert ".agents/skills" in result.output + assert "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output + + def test_kimi_next_steps_show_skill_invocation(self, monkeypatch): + """Kimi next-steps guidance should display /skill:speckit.* usage.""" + from typer.testing import CliRunner + + def _fake_download(*args, **kwargs): + project_path = Path(args[0]) + skill_dir = project_path / ".kimi" / "skills" / "speckit.specify" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") + + monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + app, + ["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"], + ) + + assert result.exit_code == 0 + assert "/skill:speckit.constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output + def test_ai_skills_flag_appears_in_help(self): """--ai-skills should appear in init --help output.""" from typer.testing import CliRunner diff --git a/tests/test_extensions.py b/tests/test_extensions.py index d9db203b9b..c0aa00ad7d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -665,9 +665,10 @@ def test_kiro_cli_agent_config_present(self): assert "q" not in CommandRegistrar.AGENT_CONFIGS def test_codex_agent_config_present(self): - """Codex should be mapped to .codex/prompts.""" + """Codex should be mapped to .agents/skills.""" assert "codex" in CommandRegistrar.AGENT_CONFIGS - assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills" + assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md" def test_pi_agent_config_present(self): """Pi should be mapped to .pi/prompts.""" @@ -717,6 +718,21 @@ def test_parse_frontmatter_no_frontmatter(self): assert frontmatter == {} assert body == content + def test_parse_frontmatter_non_mapping_returns_empty_dict(self): + """Non-mapping YAML frontmatter should not crash downstream renderers.""" + content = """--- +- item1 +- item2 +--- + +# Command body +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert frontmatter == {} + assert "Command body" in body + def test_render_frontmatter(self): """Test rendering frontmatter to YAML.""" frontmatter = { @@ -808,6 +824,299 @@ def test_command_with_aliases(self, project_dir, temp_dir): assert (claude_dir / "speckit.alias.cmd.md").exists() assert (claude_dir / "speckit.shortcut.md").exists() + def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): + """Codex skill cleanup should use the same mapped names as registration.""" + skills_dir = project_dir / ".agents" / "skills" + (skills_dir / "speckit-specify").mkdir(parents=True) + (skills_dir / "speckit-specify" / "SKILL.md").write_text("body") + (skills_dir / "speckit-shortcut").mkdir(parents=True) + (skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body") + + registrar = CommandRegistrar() + registrar.unregister_commands( + {"codex": ["speckit.specify", "speckit.shortcut"]}, + project_dir, + ) + + assert not (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists() + + def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir): + """A Codex project under .agents/skills should not implicitly activate Amp.""" + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir) + + assert "codex" in registered + assert "amp" not in registered + assert not (project_dir / ".agents" / "commands").exists() + + def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir): + """Codex SKILL.md output should use skills-oriented frontmatter.""" + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir) + + skill_file = skills_dir / "speckit-test.hello" / "SKILL.md" + assert skill_file.exists() + + content = skill_file.read_text() + assert "name: speckit-test.hello" in content + assert "description: Test hello command" in content + assert "compatibility:" in content + assert "metadata:" in content + assert "source: test-ext:commands/hello.md" in content + assert " -- make c ignores consistent with c++ (#1747) -- chore: bump version to 0.1.13 (#1746) -- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) -- feat: add verify extension to community catalog (#1726) -- Add Retrospective Extension to community catalog README table (#1741) -- fix(scripts): add empty description validation and branch checkout error handling (#1559) -- fix: correct Copilot extension command registration (#1724) -- fix(implement): remove Makefile from C ignore patterns (#1558) -- Add sync extension to community catalog (#1728) -- fix(checklist): clarify file handling behavior for append vs create (#1556) -- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) -- chore: bump version to 0.1.12 (#1737) -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) -- fix: Split release process to sync pyproject.toml version with git tags (#1732) - - -## [Unreleased] - -### Added - -- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks -- feat(presets): Pluggable preset system with preset catalog and template resolver -- Preset manifest (`preset.yml`) with validation for artifact, command, and script types -- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` -- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info` -- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management -- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system -- `--preset` option for `specify init` to install presets during initialization -- Priority-based preset resolution: presets with lower priority number win (`--priority` flag) -- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts -- Template resolution priority stack: overrides → presets → extensions → core -- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) -- Preset scaffold directory (`presets/scaffold/`) -- Scripts updated to use template resolution instead of hardcoded paths -- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init -- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations -- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) +- chore: bump version to 0.3.0 +- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787) +- fix: match 'Last updated' timestamp with or without bold markers (#1836) +- Add specify doctor command for project health diagnostics (#1828) +- fix: harden bash scripts against shell injection and improve robustness (#1809) +- fix: clean up command templates (specify, analyze) (#1810) +- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730) +- fix(cli): deprecate explicit command support for agy (#1798) (#1808) +- Add /selftest.extension core extension to test other extensions (#1758) +- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776) +- Add Java brownfield walkthrough to community walkthroughs (#1820) ## [0.2.1] - 2026-03-11 @@ -312,12 +282,3 @@ - Add pytest and Python linting (ruff) to CI (#1637) - feat: add pull request template for better contribution guidelines (#1634) - -## [0.0.99] - 2026-02-19 - -- Feat/ai skills (#1632) - -## [0.0.98] - 2026-02-19 - -- chore(deps): bump actions/stale from 9 to 10 (#1623) -- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) diff --git a/README.md b/README.md index 7f2175482e..1d93ed4a1f 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,13 @@ Choose your preferred installation method: #### Option 1: Persistent Installation (Recommended) -Install once and use everywhere: +Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): ```bash +# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z + +# Or install latest from main (may include unreleased changes) uv tool install specify-cli --from git+https://github.com/github/spec-kit.git ``` @@ -73,7 +77,7 @@ specify check To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade: ```bash -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z ``` #### Option 2: One-time Usage @@ -81,13 +85,13 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki Run directly without installing: ```bash -# Create new project -uvx --from git+https://github.com/github/spec-kit.git specify init +# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init # Or initialize in existing project -uvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude # or -uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude ``` **Benefits of persistent installation:** @@ -97,6 +101,10 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c - Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall` - Cleaner shell configuration +#### Option 3: Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine. + ### 2. Establish project principles Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. diff --git a/docs/installation.md b/docs/installation.md index 7cb7b1ff9b..5d560b6e33 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,18 +12,22 @@ ### Initialize a New Project -The easiest way to get started is to initialize a new project: +The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): ```bash +# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init + +# Or install latest from main (may include unreleased changes) uvx --from git+https://github.com/github/spec-kit.git specify init ``` Or initialize in the current directory: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init . +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . # or use the --here flag -uvx --from git+https://github.com/github/spec-kit.git specify init --here +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ``` ### Specify AI Agent @@ -31,11 +35,11 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here You can proactively specify your AI agent during initialization: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude -uvx --from git+https://github.com/github/spec-kit.git specify init --ai gemini -uvx --from git+https://github.com/github/spec-kit.git specify init --ai copilot -uvx --from git+https://github.com/github/spec-kit.git specify init --ai codebuddy -uvx --from git+https://github.com/github/spec-kit.git specify init --ai pi +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai gemini +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai pi ``` ### Specify Script Type (Shell vs PowerShell) @@ -51,8 +55,8 @@ Auto behavior: Force a specific script type: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --script sh -uvx --from git+https://github.com/github/spec-kit.git specify init --script ps +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script sh +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script ps ``` ### Ignore Agent Tools Check @@ -60,7 +64,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude --ignore-agent-tools +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude --ignore-agent-tools ``` ## Verification @@ -75,6 +79,52 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ## Troubleshooting +### Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target. + +**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)** + +```bash +# Clone the repository +git clone https://github.com/github/spec-kit.git +cd spec-kit + +# Build the wheel +pip install build +python -m build --wheel --outdir dist/ + +# Download the wheel and all its runtime dependencies +pip download -d dist/ dist/specify_cli-*.whl +``` + +> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version. + +**Step 2: Transfer the `dist/` directory to the air-gapped machine** + +Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method. + +**Step 3: Install on the air-gapped machine** + +```bash +pip install --no-index --find-links=./dist specify-cli +``` + +**Step 4: Initialize a project (no network required)** + +```bash +# Initialize a project — no GitHub access needed +specify init my-project --ai claude --offline +``` + +The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub. + +> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box. + +> **Note:** Python 3.11+ is required. + +> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell. + ### Git Credential Manager on Linux If you're having issues with Git authentication on Linux, you can install Git Credential Manager: diff --git a/docs/upgrade.md b/docs/upgrade.md index 74bf6192c0..cd5cc124fe 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -8,7 +8,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| -| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git` | Get latest CLI features without touching project files | +| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | | **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | @@ -20,16 +20,18 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get ### If you installed with `uv tool install` +Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag): + ```bash -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z ``` ### If you use one-shot `uvx` commands -No upgrade needed—`uvx` always fetches the latest version. Just run your commands as normal: +Specify the desired release tag: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` ### Verify the upgrade diff --git a/pyproject.toml b/pyproject.toml index 8c40ff3730..f3ca76dd9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,23 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/specify_cli"] +[tool.hatch.build.targets.wheel.force-include] +# Bundle core assets so `specify init` works without network access (air-gapped / enterprise) +# Page templates (exclude commands/ — bundled separately below to avoid duplication) +"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" +"templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" +"templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" +"templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" +"templates/spec-template.md" = "specify_cli/core_pack/templates/spec-template.md" +"templates/tasks-template.md" = "specify_cli/core_pack/templates/tasks-template.md" +"templates/vscode-settings.json" = "specify_cli/core_pack/templates/vscode-settings.json" +# Command templates +"templates/commands" = "specify_cli/core_pack/commands" +"scripts/bash" = "specify_cli/core_pack/scripts/bash" +"scripts/powershell" = "specify_cli/core_pack/scripts/powershell" +".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh" +".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1" + [project.optional-dependencies] test = [ "pytest>=7.0", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ae64d19c72..d2bf63eeb9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -315,6 +315,9 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "kiro": "kiro-cli", } +# Agents that use TOML command format (others use Markdown) +_TOML_AGENTS = frozenset({"gemini", "tabnine"}) + def _build_ai_assistant_help() -> str: """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" @@ -1095,6 +1098,241 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +def _locate_core_pack() -> Path | None: + """Return the filesystem path to the bundled core_pack directory, or None. + + Only present in wheel installs: hatchling's force-include copies + templates/, scripts/ etc. into specify_cli/core_pack/ at build time. + + Source-checkout and editable installs do NOT have this directory. + Callers that need to work in both environments must check the repo-root + trees (templates/, scripts/) as a fallback when this returns None. + """ + # Wheel install: core_pack is a sibling directory of this file + candidate = Path(__file__).parent / "core_pack" + if candidate.is_dir(): + return candidate + return None + + +def _locate_release_script() -> tuple[Path, str]: + """Return (script_path, shell_cmd) for the platform-appropriate release script. + + Checks the bundled core_pack first, then falls back to the source checkout. + Returns the bash script on Unix and the PowerShell script on Windows. + Raises FileNotFoundError if neither can be found. + """ + if os.name == "nt": + name = "create-release-packages.ps1" + shell = shutil.which("pwsh") + if not shell: + raise FileNotFoundError( + "'pwsh' (PowerShell 7+) not found on PATH. " + "The bundled release script requires PowerShell 7+ (pwsh), " + "not Windows PowerShell 5.x (powershell.exe). " + "Install from https://aka.ms/powershell to use offline scaffolding." + ) + else: + name = "create-release-packages.sh" + shell = "bash" + + # Wheel install: core_pack/release_scripts/ + candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name + if candidate.is_file(): + return candidate, shell + + # Source-checkout fallback + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / ".github" / "workflows" / "scripts" / name + if candidate.is_file(): + return candidate, shell + + raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") + + +def scaffold_from_core_pack( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + tracker: StepTracker | None = None, +) -> bool: + """Scaffold a project from bundled core_pack assets — no network access required. + + Invokes the bundled create-release-packages script (bash on Unix, PowerShell + on Windows) to generate the full project scaffold for a single agent. This + guarantees byte-for-byte parity between ``specify init`` and the GitHub + release ZIPs because both use the exact same script. + + Returns True on success. Returns False if offline scaffolding failed for + any reason, including missing or unreadable assets, missing required tools + (bash, pwsh, zip), release-script failure or timeout, or unexpected runtime + exceptions. When ``--offline`` is active the caller should treat False as + a hard error rather than falling back to a network download. + """ + # --- Locate asset sources --- + core = _locate_core_pack() + + # Command templates + if core and (core / "commands").is_dir(): + commands_dir = core / "commands" + else: + repo_root = Path(__file__).parent.parent.parent + commands_dir = repo_root / "templates" / "commands" + if not commands_dir.is_dir(): + if tracker: + tracker.error("scaffold", "command templates not found") + return False + + # Scripts directory (parent of bash/ and powershell/) + if core and (core / "scripts").is_dir(): + scripts_dir = core / "scripts" + else: + repo_root = Path(__file__).parent.parent.parent + scripts_dir = repo_root / "scripts" + if not scripts_dir.is_dir(): + if tracker: + tracker.error("scaffold", "scripts directory not found") + return False + + # Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.) + if core and (core / "templates").is_dir(): + templates_dir = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_dir = repo_root / "templates" + if not templates_dir.is_dir(): + if tracker: + tracker.error("scaffold", "page templates not found") + return False + + # Release script + try: + release_script, shell_cmd = _locate_release_script() + except FileNotFoundError as exc: + if tracker: + tracker.error("scaffold", str(exc)) + return False + + # Preflight: verify required external tools are available + if os.name != "nt": + if not shutil.which("bash"): + msg = "'bash' not found on PATH. Required for offline scaffolding." + if tracker: + tracker.error("scaffold", msg) + return False + if not shutil.which("zip"): + msg = "'zip' not found on PATH. Required for offline scaffolding. Install with: apt install zip / brew install zip" + if tracker: + tracker.error("scaffold", msg) + return False + + if tracker: + tracker.start("scaffold", "applying bundled assets") + + try: + if not is_current_dir: + project_path.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + # Set up a repo-like directory layout in the temp dir so the + # release script finds templates/commands/, scripts/, etc. + tmpl_cmds = tmp / "templates" / "commands" + tmpl_cmds.mkdir(parents=True) + for f in commands_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_cmds / f.name) + + # Page templates (needed for vscode-settings.json etc.) + if templates_dir.is_dir(): + tmpl_root = tmp / "templates" + for f in templates_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_root / f.name) + + # Scripts (bash/ and powershell/) + for subdir in ("bash", "powershell"): + src = scripts_dir / subdir + if src.is_dir(): + dst = tmp / "scripts" / subdir + dst.mkdir(parents=True, exist_ok=True) + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, dst / f.name) + + # Run the release script for this single agent + script type + env = os.environ.copy() + # Pin GENRELEASES_DIR inside the temp dir so a user-exported + # value cannot redirect output or cause rm -rf outside the sandbox. + env["GENRELEASES_DIR"] = str(tmp / ".genreleases") + if os.name == "nt": + cmd = [ + shell_cmd, "-File", str(release_script), + "-Version", "v0.0.0", + "-Agents", ai_assistant, + "-Scripts", script_type, + ] + else: + cmd = [shell_cmd, str(release_script), "v0.0.0"] + env["AGENTS"] = ai_assistant + env["SCRIPTS"] = script_type + + try: + result = subprocess.run( + cmd, cwd=str(tmp), env=env, + capture_output=True, text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + msg = "release script timed out after 120 seconds" + if tracker: + tracker.error("scaffold", msg) + else: + console.print(f"[red]Error:[/red] {msg}") + return False + + if result.returncode != 0: + msg = result.stderr.strip() or result.stdout.strip() or "unknown error" + if tracker: + tracker.error("scaffold", f"release script failed: {msg}") + else: + console.print(f"[red]Release script failed:[/red] {msg}") + return False + + # Copy the generated files to the project directory + build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}" + if not build_dir.is_dir(): + if tracker: + tracker.error("scaffold", "release script produced no output") + return False + + for item in build_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(build_dir) + dest = project_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + # When scaffolding into an existing directory (--here), + # use the same merge semantics as the GitHub-download path. + if is_current_dir and dest.name == "settings.json" and dest.parent.name == ".vscode": + handle_vscode_settings(item, dest, rel, verbose=False, tracker=tracker) + else: + shutil.copy2(item, dest) + + if tracker: + tracker.complete("scaffold", "bundled assets applied") + return True + + except Exception as e: + if tracker: + tracker.error("scaffold", str(e)) + else: + console.print(f"[red]Error scaffolding from bundled assets:[/red] {e}") + return False + + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": @@ -1487,20 +1725,31 @@ def init( debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), ): """ - Initialize a new Specify project from the latest template. - + Initialize a new Specify project. + + By default, project files are downloaded from the latest GitHub release. + Use --offline to scaffold from assets bundled inside the specify-cli + package instead (no internet access required, ideal for air-gapped or + enterprise environments). + + NOTE: Starting with v0.6.0, bundled assets will be used by default and + the --offline flag will be removed. The GitHub download path will be + retired because bundled assets eliminate the need for network access, + avoid proxy/firewall issues, and guarantee that templates always match + the installed CLI version. + This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory - 5. Initialize a fresh git repository (if not --no-git and no existing repo) - 6. Optionally set up AI assistant commands - + 3. Download template from GitHub (or use bundled assets with --offline) + 4. Initialize a fresh git repository (if not --no-git and no existing repo) + 5. Optionally set up AI assistant commands + Examples: specify init my-project specify init my-project --ai claude @@ -1517,6 +1766,7 @@ def init( specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --offline # Use bundled assets (no network access) specify init my-project --ai claude --preset healthcare-compliance # With preset """ @@ -1689,12 +1939,37 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) + + # Determine whether to use bundled assets or download from GitHub (default). + # --offline opts in to bundled assets; without it, always use GitHub. + # When --offline is set, scaffold_from_core_pack() will try the wheel's + # core_pack/ first, then fall back to source-checkout paths. If neither + # location has the required assets it returns False and we error out. + _core = _locate_core_pack() + + use_github = not offline + + if use_github and _core is not None: + console.print( + "[yellow]Note:[/yellow] Bundled assets are available in this install. " + "Use [bold]--offline[/bold] to skip the GitHub download — faster, " + "no network required, and guaranteed version match.\n" + "This will become the default in v0.6.0." + ) + + if use_github: + for key, label in [ + ("fetch", "Fetch latest release"), + ("download", "Download template"), + ("extract", "Extract template"), + ("zip-list", "Archive contents"), + ("extracted-summary", "Extraction summary"), + ]: + tracker.add(key, label) + else: + tracker.add("scaffold", "Apply bundled assets") + for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ]: @@ -1716,9 +1991,28 @@ def init( try: verify = not skip_tls local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + if use_github: + with httpx.Client(verify=local_ssl_context) as local_client: + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + else: + scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) + if not scaffold_ok: + # --offline explicitly requested: never attempt a network download + console.print( + "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" + "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" + "Remove --offline to attempt a GitHub download instead." + ) + # Surface the specific failure reason from the tracker + for step in tracker.steps: + if step["key"] == "scaffold" and step["detail"]: + console.print(f"[red]Detail:[/red] {step['detail']}") + break + # Clean up partial project directory (same as the GitHub-download failure path) + if not here and project_path.exists(): + shutil.rmtree(project_path) + raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: @@ -1799,6 +2093,7 @@ def init( "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, + "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), }) @@ -1834,7 +2129,13 @@ def init( except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + # Scaffold path has no zip archive to clean up + if not use_github: + tracker.skip("cleanup", "not needed (no download)") + tracker.complete("final", "project ready") + except (typer.Exit, SystemExit): + raise except Exception as e: tracker.error("final", str(e)) console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 08017430b1..0bccd48d49 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -1050,10 +1050,12 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): target = tmp_path / "kiro-alias-proj" with patch("specify_cli.download_and_extract_template") as mock_download, \ + patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \ patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_constitution_from_template"), \ patch("specify_cli.is_git_repo", return_value=False), \ patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + mock_scaffold.return_value = True result = runner.invoke( app, [ @@ -1069,9 +1071,14 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): ) assert result.exit_code == 0 - assert mock_download.called - # download_and_extract_template(project_path, ai_assistant, script_type, ...) + # Without --offline, the download path should be taken. + assert mock_download.called, ( + "Expected download_and_extract_template to be called (default non-offline path)" + ) assert mock_download.call_args.args[1] == "kiro-cli" + assert not mock_scaffold.called, ( + "scaffold_from_core_pack should not be called without --offline" + ) def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py new file mode 100644 index 0000000000..92848bb163 --- /dev/null +++ b/tests/test_core_pack_scaffold.py @@ -0,0 +1,613 @@ +""" +Validation tests for offline/air-gapped scaffolding (PR #1803). + +For every supported AI agent (except "generic") the scaffold output is verified +against invariants and compared byte-for-byte with the canonical output produced +by create-release-packages.sh. + +Since scaffold_from_core_pack() now invokes the release script at runtime, the +parity test (section 9) runs the script independently and compares the results +to ensure the integration is correct. + +Per-agent invariants verified +────────────────────────────── + • Command files are written to the directory declared in AGENT_CONFIG + • File count matches the number of source templates + • Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest) + • No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__) + • Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others + • Path rewrites applied: scripts/ → .specify/scripts/ etc. + • TOML files have "description" and "prompt" fields + • Markdown files have parseable YAML frontmatter + • Copilot: companion speckit.*.prompt.md files are generated in prompts/ + • .specify/scripts/ contains at least one script file + • .specify/templates/ contains at least one template file + +Parity invariant +──────────────── + Every file produced by scaffold_from_core_pack() must be byte-for-byte + identical to the same file in the ZIP produced by the release script. +""" + +import os +import re +import shutil +import subprocess +import tomllib +import zipfile +from pathlib import Path + +import pytest +import yaml + +from specify_cli import ( + AGENT_CONFIG, + _TOML_AGENTS, + _locate_core_pack, + scaffold_from_core_pack, +) + +_REPO_ROOT = Path(__file__).parent.parent +_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh" + + +def _find_bash() -> str | None: + """Return the path to a usable bash on this machine, or None.""" + # Prefer PATH lookup so non-standard install locations (Nix, CI) are found. + on_path = shutil.which("bash") + if on_path: + return on_path + candidates = [ + "/opt/homebrew/bin/bash", + "/usr/local/bin/bash", + "/bin/bash", + "/usr/bin/bash", + ] + for candidate in candidates: + try: + result = subprocess.run( + [candidate, "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return candidate + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return None + + +def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Path) -> Path: + """Run create-release-packages.sh for *agent*/*script_type* and return the + path to the generated ZIP. *output_dir* receives the build artifacts so + the repo working tree stays clean.""" + env = os.environ.copy() + env["AGENTS"] = agent + env["SCRIPTS"] = script_type + env["GENRELEASES_DIR"] = str(output_dir) + + result = subprocess.run( + [bash, str(_RELEASE_SCRIPT), "v0.0.0"], + capture_output=True, text=True, + cwd=str(_REPO_ROOT), + env=env, + timeout=300, + ) + + if result.returncode != 0: + pytest.fail( + f"Release script failed with exit code {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" + zip_path = output_dir / zip_pattern + if not zip_path.exists(): + pytest.fail( + f"Release script did not produce expected ZIP: {zip_path}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return zip_path + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Number of source command templates (one per .md file in templates/commands/) + + +def _commands_dir() -> Path: + """Return the command templates directory (source-checkout or core_pack).""" + core = _locate_core_pack() + if core and (core / "commands").is_dir(): + return core / "commands" + # Source-checkout fallback + repo_root = Path(__file__).parent.parent + return repo_root / "templates" / "commands" + + +def _get_source_template_stems() -> list[str]: + """Return the stems of source command template files (e.g. ['specify', 'plan', ...]).""" + return sorted(p.stem for p in _commands_dir().glob("*.md")) + + +def _expected_cmd_dir(project_path: Path, agent: str) -> Path: + """Return the expected command-files directory for a given agent.""" + cfg = AGENT_CONFIG[agent] + folder = (cfg.get("folder") or "").rstrip("/") + subdir = cfg.get("commands_subdir", "commands") + if folder: + return project_path / folder / subdir + return project_path / ".speckit" / subdir + + +# Agents whose commands are laid out as //SKILL.md. +# Maps agent -> separator used in skill directory names. +_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."} + + +def _expected_ext(agent: str) -> str: + if agent in _TOML_AGENTS: + return "toml" + if agent == "copilot": + return "agent.md" + if agent in _SKILL_AGENTS: + return "SKILL.md" + return "md" + + +def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: + """List generated command files, handling skills-based directory layouts.""" + if agent in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + return sorted(cmd_dir.glob(f"speckit{sep}*/SKILL.md")) + ext = _expected_ext(agent) + return sorted(cmd_dir.glob(f"speckit.*.{ext}")) + + +def _collect_relative_files(root: Path) -> dict[str, bytes]: + """Walk *root* and return {relative_posix_path: file_bytes}.""" + result: dict[str, bytes] = {} + for p in root.rglob("*"): + if p.is_file(): + result[p.relative_to(root).as_posix()] = p.read_bytes() + return result + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def source_template_stems() -> list[str]: + return _get_source_template_stems() + + +@pytest.fixture(scope="session") +def scaffolded_sh(tmp_path_factory): + """Session-scoped cache: scaffold once per agent with script_type='sh'.""" + cache = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp(f"scaffold_sh_{agent}") + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + cache[agent] = project + return cache[agent] + return _get + + +@pytest.fixture(scope="session") +def scaffolded_ps(tmp_path_factory): + """Session-scoped cache: scaffold once per agent with script_type='ps'.""" + cache = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp(f"scaffold_ps_{agent}") + ok = scaffold_from_core_pack(project, agent, "ps") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + cache[agent] = project + return cache[agent] + return _get + + +# --------------------------------------------------------------------------- +# Parametrize over all agents except "generic" +# --------------------------------------------------------------------------- + +_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"] + + +# --------------------------------------------------------------------------- +# 1. Bundled scaffold — directory structure +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_scripts(agent, scaffolded_sh): + """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" + project = scaffolded_sh(agent) + + scripts_dir = project / ".specify" / "scripts" / "bash" + assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" + assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_templates(agent, scaffolded_sh): + """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" + project = scaffolded_sh(agent) + + tpl_dir = project / ".specify" / "templates" + assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" + assert any(tpl_dir.iterdir()), ".specify/templates/ is empty" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_dir_location(agent, scaffolded_sh): + """Command files land in the directory declared by AGENT_CONFIG.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + assert cmd_dir.is_dir(), ( + f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 2. Bundled scaffold — file count +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems): + """One command file is generated per source template for every agent.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + + if cmd_dir.is_dir(): + dir_listing = list(cmd_dir.iterdir()) + else: + dir_listing = f"" + + assert len(generated) == len(source_template_stems), ( + f"Agent '{agent}': expected {len(source_template_stems)} command files " + f"({_expected_ext(agent)}), found {len(generated)}. Dir: {dir_listing}" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems): + """Each source template stem maps to a corresponding speckit.. file.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for stem in source_template_stems: + if agent in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + expected = cmd_dir / f"speckit{sep}{stem}" / "SKILL.md" + else: + ext = _expected_ext(agent) + expected = cmd_dir / f"speckit.{stem}.{ext}" + assert expected.is_file(), ( + f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'" + ) + + +# --------------------------------------------------------------------------- +# 3. Bundled scaffold — content invariants +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_script_placeholder(agent, scaffolded_sh): + """{SCRIPT} must not appear in any generated command file.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, ( + f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_agent_placeholder(agent, scaffolded_sh): + """__AGENT__ must not appear in any generated command file.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "__AGENT__" not in content, ( + f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_args_placeholder(agent, scaffolded_sh): + """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{ARGS}" not in content, ( + f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +# Build a set of template stems that actually contain {ARGS} in their source. +_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset( + p.stem + for p in _commands_dir().glob("*.md") + if "{ARGS}" in p.read_text(encoding="utf-8") +) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_argument_token_format(agent, scaffolded_sh): + """For templates that carry an {ARGS} token: + - TOML agents must emit {{args}} + - Markdown agents must emit $ARGUMENTS + Templates without {ARGS} (e.g. implement, plan) are skipped. + """ + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + + for f in _list_command_files(cmd_dir, agent): + # Recover the stem from the file path + if agent in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + stem = f.parent.name.removeprefix(f"speckit{sep}") + else: + ext = _expected_ext(agent) + stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}") + if stem not in _TEMPLATES_WITH_ARGS: + continue # this template has no argument token + + content = f.read_text(encoding="utf-8") + if agent in _TOML_AGENTS: + assert "{{args}}" in content, ( + f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'" + ) + else: + assert "$ARGUMENTS" in content, ( + f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_path_rewrites_applied(agent, scaffolded_sh): + """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants. + + YAML frontmatter 'source:' metadata fields are excluded — they reference + the original template path for provenance, not a runtime path. + """ + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if not f.is_file(): + continue + content = f.read_text(encoding="utf-8") + + # Strip YAML frontmatter before checking — source: metadata is not a runtime path + body = content + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + body = parts[2] + + # Should not contain bare (non-.specify/) script paths + assert not re.search(r'(?= 3, f"Incomplete frontmatter in '{f.name}'" + fm = yaml.safe_load(parts[1]) + assert fm is not None, f"Empty frontmatter in '{f.name}'" + assert "description" in fm, ( + f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 6. Copilot-specific: companion .prompt.md files +# --------------------------------------------------------------------------- + +def test_copilot_companion_prompt_files(scaffolded_sh, source_template_stems): + """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" + project = scaffolded_sh("copilot") + + prompts_dir = project / ".github" / "prompts" + assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" + + for stem in source_template_stems: + prompt_file = prompts_dir / f"speckit.{stem}.prompt.md" + assert prompt_file.is_file(), ( + f"Companion prompt file '{prompt_file.name}' missing for copilot" + ) + + +def test_copilot_prompt_file_content(scaffolded_sh, source_template_stems): + """Copilot companion .prompt.md files must reference their parent .agent.md.""" + project = scaffolded_sh("copilot") + + prompts_dir = project / ".github" / "prompts" + for stem in source_template_stems: + f = prompts_dir / f"speckit.{stem}.prompt.md" + content = f.read_text(encoding="utf-8") + assert f"agent: speckit.{stem}" in content, ( + f"Companion '{f.name}' does not reference 'speckit.{stem}'" + ) + + +# --------------------------------------------------------------------------- +# 7. PowerShell script variant +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems): + """scaffold_from_core_pack with script_type='ps' creates correct files.""" + project = scaffolded_ps(agent) + + scripts_dir = project / ".specify" / "scripts" / "powershell" + assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" + assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty" + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + assert len(generated) == len(source_template_stems) + + +# --------------------------------------------------------------------------- +# 8. Parity: bundled vs. real create-release-packages.sh ZIP +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def release_script_trees(tmp_path_factory): + """Session-scoped cache: run release script once per (agent, script_type).""" + cache: dict[tuple[str, str], dict[str, bytes]] = {} + bash = _find_bash() + + def _get(agent: str, script_type: str) -> dict[str, bytes] | None: + if bash is None: + return None + key = (agent, script_type) + if key not in cache: + tmp = tmp_path_factory.mktemp(f"release_{agent}_{script_type}") + gen_dir = tmp / "genreleases" + gen_dir.mkdir() + zip_path = _run_release_script(agent, script_type, bash, gen_dir) + extracted = tmp / "extracted" + extracted.mkdir() + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extracted) + cache[key] = _collect_relative_files(extracted) + return cache[key] + return _get + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_parity_bundled_vs_release_script(agent, script_type, scaffolded_sh, scaffolded_ps, release_script_trees): + """scaffold_from_core_pack() file tree is identical to the ZIP produced by + create-release-packages.sh for every agent and script type. + + This is the true end-to-end parity check: the Python offline path must + produce exactly the same artifacts as the canonical shell release script. + + Both sides are session-cached: each agent/script_type combination is + scaffolded and release-scripted only once across all tests. + """ + script_tree = release_script_trees(agent, script_type) + if script_tree is None: + pytest.skip("bash required to run create-release-packages.sh") + + # Reuse session-cached scaffold output + if script_type == "sh": + bundled_dir = scaffolded_sh(agent) + else: + bundled_dir = scaffolded_ps(agent) + + bundled_tree = _collect_relative_files(bundled_dir) + + only_bundled = set(bundled_tree) - set(script_tree) + only_script = set(script_tree) - set(bundled_tree) + + assert not only_bundled, ( + f"Agent '{agent}' ({script_type}): files only in bundled output (not in release ZIP):\n " + + "\n ".join(sorted(only_bundled)) + ) + assert not only_script, ( + f"Agent '{agent}' ({script_type}): files only in release ZIP (not in bundled output):\n " + + "\n ".join(sorted(only_script)) + ) + + for name in bundled_tree: + assert bundled_tree[name] == script_tree[name], ( + f"Agent '{agent}' ({script_type}): file '{name}' content differs between " + f"bundled output and release script ZIP" + ) + + +# --------------------------------------------------------------------------- +# Section 10 – pyproject.toml force-include covers all template files +# --------------------------------------------------------------------------- + +def test_pyproject_force_include_covers_all_templates(): + """Every file in templates/ (excluding commands/) must be listed in + pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section. + + This prevents new template files from being silently omitted from the + wheel, which would break ``specify init --offline``. + """ + templates_dir = _REPO_ROOT / "templates" + # Collect all files directly in templates/ (not in subdirectories like commands/) + repo_template_files = sorted( + f.name for f in templates_dir.iterdir() + if f.is_file() + ) + assert repo_template_files, "Expected at least one template file in templates/" + + pyproject_path = _REPO_ROOT / "pyproject.toml" + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + force_include = pyproject.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("force-include", {}) + + missing = [ + name for name in repo_template_files + if f"templates/{name}" not in force_include + ] + assert not missing, ( + "Template files not listed in pyproject.toml force-include " + "(offline scaffolding will miss them):\n " + + "\n ".join(missing) + ) From 6223d10d846d4cd2f27a14699a637d320f4d51a2 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Mon, 23 Mar 2026 20:35:11 +0800 Subject: [PATCH 122/321] fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) * fix(codex): skip legacy prompts and fallback when bundled skills missing * fix(skills): allow native fallback to overwrite existing SKILL.md * fix(codex): defer legacy .codex cleanup until after skills fallback * fix(codex): preserve existing .codex while skipping legacy prompt extraction * docs(skills): clarify overwrite_existing behavior * test(codex): cover fresh-dir suppression of legacy .codex layout * docs(codex): clarify skip_legacy_codex_prompts suppresses full .codex dir * security(init): validate zip member paths before extraction --- src/specify_cli/__init__.py | 114 +++++++++++++++++++++++++++----- tests/test_ai_skills.py | 128 +++++++++++++++++++++++++++++++++--- 2 files changed, 215 insertions(+), 27 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d2bf63eeb9..1e4c296e60 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -948,9 +948,26 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri } return zip_path, metadata -def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path: +def download_and_extract_template( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + skip_legacy_codex_prompts: bool = False, + verbose: bool = True, + tracker: StepTracker | None = None, + client: httpx.Client = None, + debug: bool = False, + github_token: str = None, +) -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) + + Note: + ``skip_legacy_codex_prompts`` suppresses the legacy top-level + ``.codex`` directory from older template archives in Codex skills mode. + The name is kept for backward compatibility with existing callers. """ current_dir = Path.cwd() @@ -990,6 +1007,19 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ project_path.mkdir(parents=True) with zipfile.ZipFile(zip_path, 'r') as zip_ref: + def _validate_zip_members_within(root: Path) -> None: + """Validate all ZIP members stay within ``root`` (Zip Slip guard).""" + root_resolved = root.resolve() + for member in zip_ref.namelist(): + member_path = (root / member).resolve() + try: + member_path.relative_to(root_resolved) + except ValueError: + raise RuntimeError( + f"Unsafe path in ZIP archive: {member} " + "(potential path traversal)" + ) + zip_contents = zip_ref.namelist() if tracker: tracker.start("zip-list") @@ -1000,6 +1030,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if is_current_dir: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) + _validate_zip_members_within(temp_path) zip_ref.extractall(temp_path) extracted_items = list(temp_path.iterdir()) @@ -1019,6 +1050,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print("[cyan]Found nested directory structure[/cyan]") for item in source_dir.iterdir(): + # In Codex skills mode, do not materialize the legacy + # top-level .codex directory from older prompt-based + # template archives. + if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex": + continue dest_path = project_path / item.name if item.is_dir(): if dest_path.exists(): @@ -1043,6 +1079,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if verbose and not tracker: console.print("[cyan]Template files merged into current directory[/cyan]") else: + _validate_zip_members_within(project_path) zip_ref.extractall(project_path) extracted_items = list(project_path.iterdir()) @@ -1069,6 +1106,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ elif verbose: console.print("[cyan]Flattened nested directory structure[/cyan]") + # For fresh-directory Codex skills init, suppress legacy + # top-level .codex layout extracted from older archives. + if skip_legacy_codex_prompts and ai_assistant == "codex": + legacy_codex_dir = project_path / ".codex" + if legacy_codex_dir.is_dir(): + shutil.rmtree(legacy_codex_dir, ignore_errors=True) + except Exception as e: if tracker: tracker.error("extract", str(e)) @@ -1499,18 +1543,27 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: return project_path / DEFAULT_SKILLS_DIR -def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool: +def install_ai_skills( + project_path: Path, + selected_ai: str, + tracker: StepTracker | None = None, + *, + overwrite_existing: bool = False, +) -> bool: """Install Prompt.MD files from templates/commands/ as agent skills. Skills are written to the agent-specific skills directory following the `agentskills.io `_ specification. - Installation is additive — existing files are never removed and prompt - command files in the agent's commands directory are left untouched. + Installation is additive by default — existing files are never removed and + prompt command files in the agent's commands directory are left untouched. Args: project_path: Target project directory. selected_ai: AI assistant key from ``AGENT_CONFIG``. tracker: Optional progress tracker. + overwrite_existing: When True, overwrite any existing ``SKILL.md`` file + in the target skills directory (including user-authored content). + Defaults to False. Returns: ``True`` if at least one skill was installed or all skills were @@ -1640,9 +1693,10 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker skill_file = skill_dir / "SKILL.md" if skill_file.exists(): - # Do not overwrite user-customized skills on re-runs - skipped_count += 1 - continue + if not overwrite_existing: + # Default behavior: do not overwrite user-customized skills on re-runs + skipped_count += 1 + continue skill_file.write_text(skill_content, encoding="utf-8") installed_count += 1 @@ -1994,7 +2048,18 @@ def init( if use_github: with httpx.Client(verify=local_ssl_context) as local_client: - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + download_and_extract_template( + project_path, + selected_ai, + selected_script, + here, + skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills), + verbose=False, + tracker=tracker, + client=local_client, + debug=debug, + github_token=github_token, + ) else: scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) if not scaffold_ok: @@ -2013,7 +2078,6 @@ def init( if not here and project_path.exists(): shutil.rmtree(project_path) raise typer.Exit(1) - # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: placeholder_dir = project_path / ".speckit" / "commands" @@ -2033,16 +2097,30 @@ def init( if ai_skills: if selected_ai in NATIVE_SKILLS_AGENTS: skills_dir = _get_skills_dir(project_path, selected_ai) - if not _has_bundled_skills(project_path, selected_ai): - raise RuntimeError( - f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, " - "but none were found. Re-run with an up-to-date template." - ) - if tracker: - tracker.start("ai-skills") - tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}") + bundled_found = _has_bundled_skills(project_path, selected_ai) + if bundled_found: + if tracker: + tracker.start("ai-skills") + tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}") + else: + console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/") else: - console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/") + # Compatibility fallback: convert command templates to skills + # when an older template archive does not include native skills. + # This keeps `specify init --here --ai codex --ai-skills` usable + # in repos that already contain unrelated skills under .agents/skills. + fallback_ok = install_ai_skills( + project_path, + selected_ai, + tracker=tracker, + overwrite_existing=True, + ) + if not fallback_ok: + raise RuntimeError( + f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, " + "but none were found and fallback conversion failed. " + "Re-run with an up-to-date template." + ) else: skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 0bccd48d49..cf2b6b7b9c 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -11,10 +11,12 @@ """ import re +import zipfile import pytest import tempfile import shutil import yaml +import typer from pathlib import Path from unittest.mock import patch @@ -720,8 +722,8 @@ def fake_download(project_path, *args, **kwargs): mock_skills.assert_not_called() assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists() - def test_codex_native_skills_missing_fails_clearly(self, tmp_path): - """Codex native skills init should fail if bundled skills are missing.""" + def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path): + """Codex should attempt fallback conversion when bundled skills are missing.""" from typer.testing import CliRunner runner = CliRunner() @@ -730,7 +732,7 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path): with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \ patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills") as mock_skills, \ + patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \ patch("specify_cli.is_git_repo", return_value=False), \ patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): result = runner.invoke( @@ -739,11 +741,13 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path): ) assert result.exit_code == 1 - mock_skills.assert_not_called() + mock_skills.assert_called_once() + assert mock_skills.call_args.kwargs.get("overwrite_existing") is True assert "Expected bundled agent skills" in result.output + assert "fallback conversion failed" in result.output def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path): - """Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation.""" + """Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail.""" from typer.testing import CliRunner runner = CliRunner() @@ -757,7 +761,7 @@ def fake_download(project_path, *args, **kwargs): with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills") as mock_skills, \ + patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ patch("specify_cli.is_git_repo", return_value=False), \ patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): result = runner.invoke( @@ -765,9 +769,100 @@ def fake_download(project_path, *args, **kwargs): ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], ) - assert result.exit_code == 1 - mock_skills.assert_not_called() - assert "Expected bundled agent skills" in result.output + assert result.exit_code == 0 + mock_skills.assert_called_once() + assert mock_skills.call_args.kwargs.get("overwrite_existing") is True + + def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch): + """Codex --here skills init should not delete a pre-existing .codex directory.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "codex-preserve-here" + target.mkdir() + existing_prompts = target / ".codex" / "prompts" + existing_prompts.mkdir(parents=True) + (existing_prompts / "custom.md").write_text("custom") + monkeypatch.chdir(target) + + with patch("specify_cli.download_and_extract_template", return_value=target), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=True), \ + patch("specify_cli.is_git_repo", return_value=True), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): + result = runner.invoke( + app, + ["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], + input="y\n", + ) + + assert result.exit_code == 0 + assert (target / ".codex").exists() + assert (existing_prompts / "custom.md").exists() + + def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path): + """Fresh-directory Codex skills init should not leave legacy .codex from archive.""" + target = tmp_path / "fresh-codex-proj" + archive = tmp_path / "codex-template.zip" + + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("template-root/.codex/prompts/speckit.specify.md", "legacy") + zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") + + fake_meta = { + "filename": archive.name, + "size": archive.stat().st_size, + "release": "vtest", + "asset_url": "https://example.invalid/template.zip", + } + + with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): + specify_cli.download_and_extract_template( + target, + "codex", + "sh", + is_current_dir=False, + skip_legacy_codex_prompts=True, + verbose=False, + ) + + assert target.exists() + assert (target / ".specify").exists() + assert not (target / ".codex").exists() + + @pytest.mark.parametrize("is_current_dir", [False, True]) + def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir): + """Extraction should reject ZIP members escaping the target directory.""" + target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj") + if is_current_dir: + target.mkdir() + monkeypatch.chdir(target) + + archive = tmp_path / "malicious-template.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../evil.txt", "pwned") + zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") + + fake_meta = { + "filename": archive.name, + "size": archive.stat().st_size, + "release": "vtest", + "asset_url": "https://example.invalid/template.zip", + } + + with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): + with pytest.raises(typer.Exit): + specify_cli.download_and_extract_template( + target, + "codex", + "sh", + is_current_dir=is_current_dir, + skip_legacy_codex_prompts=True, + verbose=False, + ) + + assert not (tmp_path / "evil.txt").exists() def test_commands_preserved_when_skills_fail(self, tmp_path): """If skills fail, commands should NOT be removed (safety net).""" @@ -859,6 +954,21 @@ def test_fresh_install_writes_all_skills(self, project_dir, templates_dir): # All 4 templates should produce skills (specify, plan, tasks, empty_fm) assert len(skill_dirs) == 4 + def test_existing_skill_overwritten_when_enabled(self, project_dir, templates_dir): + """When overwrite_existing=True, pre-existing SKILL.md should be replaced.""" + skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" + skill_dir.mkdir(parents=True) + custom_content = "# My Custom Specify Skill\nUser-modified content\n" + skill_file = skill_dir / "SKILL.md" + skill_file.write_text(custom_content) + + result = install_ai_skills(project_dir, "claude", overwrite_existing=True) + + assert result is True + updated_content = skill_file.read_text() + assert updated_content != custom_content + assert "name: speckit-specify" in updated_content + # ===== SKILL_DESCRIPTIONS Coverage Tests ===== From a351c826ee1d1a8c7bd38974cdfdf3a6678b6914 Mon Sep 17 00:00:00 2001 From: Seiya Kojima Date: Mon, 23 Mar 2026 22:49:10 +0900 Subject: [PATCH 123/321] fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) None of the yaml.dump() calls specify allow_unicode=True, causing non-ASCII characters in extension descriptions to be escaped to \uXXXX sequences in generated .agent.md frontmatter and config files. Add allow_unicode=True to all 6 yaml.dump() call sites, and encoding="utf-8" to all corresponding write_text() and read_text() calls to ensure consistent UTF-8 handling across platforms. --- src/specify_cli/__init__.py | 16 ++++++++-------- src/specify_cli/agents.py | 2 +- src/specify_cli/extensions.py | 15 ++++++++------- src/specify_cli/presets.py | 4 ++-- tests/test_extensions.py | 12 ++++++++++++ 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1e4c296e60..08af651078 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3042,7 +3042,7 @@ def preset_catalog_add( # Load existing config if config_path.exists(): try: - config = yaml.safe_load(config_path.read_text()) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception as e: console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") raise typer.Exit(1) @@ -3070,7 +3070,7 @@ def preset_catalog_add( }) config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") install_label = "install allowed" if install_allowed else "discovery only" console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") @@ -3098,7 +3098,7 @@ def preset_catalog_remove( raise typer.Exit(1) try: - config = yaml.safe_load(config_path.read_text()) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception: console.print("[red]Error:[/red] Failed to read preset catalog config.") raise typer.Exit(1) @@ -3115,7 +3115,7 @@ def preset_catalog_remove( raise typer.Exit(1) config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") console.print(f"[green]✓[/green] Removed catalog '{name}'") if not catalogs: @@ -3384,7 +3384,7 @@ def catalog_add( # Load existing config if config_path.exists(): try: - config = yaml.safe_load(config_path.read_text()) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception as e: console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") raise typer.Exit(1) @@ -3412,7 +3412,7 @@ def catalog_add( }) config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") install_label = "install allowed" if install_allowed else "discovery only" console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") @@ -3440,7 +3440,7 @@ def catalog_remove( raise typer.Exit(1) try: - config = yaml.safe_load(config_path.read_text()) or {} + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception: console.print("[red]Error:[/red] Failed to read catalog config.") raise typer.Exit(1) @@ -3457,7 +3457,7 @@ def catalog_remove( raise typer.Exit(1) config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") console.print(f"[green]✓[/green] Removed catalog '{name}'") if not catalogs: diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4f1ab728fb..7fe5316066 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -207,7 +207,7 @@ def render_frontmatter(fm: dict) -> str: if not fm: return "" - yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False) + 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: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 0dca39a0cb..b26b1e9310 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -975,8 +975,8 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry] if not config_path.exists(): return None try: - data = yaml.safe_load(config_path.read_text()) or {} - except (yaml.YAMLError, OSError) as e: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as e: raise ValidationError( f"Failed to read catalog config {config_path}: {e}" ) @@ -1467,8 +1467,8 @@ def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]: return {} try: - return yaml.safe_load(file_path.read_text()) or {} - except (yaml.YAMLError, OSError): + return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError): return {} def _get_extension_defaults(self) -> Dict[str, Any]: @@ -1659,8 +1659,8 @@ def get_project_config(self) -> Dict[str, Any]: } try: - return yaml.safe_load(self.config_file.read_text()) or {} - except (yaml.YAMLError, OSError): + return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError): return { "installed": [], "settings": {"auto_execute_hooks": True}, @@ -1675,7 +1675,8 @@ def save_project_config(self, config: Dict[str, Any]): """ self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config_file.write_text( - yaml.dump(config, default_flow_style=False, sort_keys=False) + yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", ) def register_hooks(self, manifest: ExtensionManifest): diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index c53915faaf..24d523aa89 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1062,8 +1062,8 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog if not config_path.exists(): return None try: - data = yaml.safe_load(config_path.read_text()) or {} - except (yaml.YAMLError, OSError) as e: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as e: raise PresetValidationError( f"Failed to read catalog config {config_path}: {e}" ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c0aa00ad7d..cd0f9ba443 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -747,6 +747,18 @@ def test_render_frontmatter(self): assert output.endswith("---\n") assert "description: Test command" in output + def test_render_frontmatter_unicode(self): + """Test rendering frontmatter preserves non-ASCII characters.""" + frontmatter = { + "description": "Prüfe Konformität der Implementierung" + } + + registrar = CommandRegistrar() + output = registrar.render_frontmatter(frontmatter) + + assert "Prüfe Konformität" in output + assert "\\u" not in output + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory From b72a5850fe7e77629e2bd69033c70d484a405ae5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:53:19 -0500 Subject: [PATCH 124/321] chore: bump version to 0.4.0 (#1937) * chore: bump version to 0.4.0 * fix: restore newest-first ordering in CHANGELOG.md for v0.4.0 (#1938) * Initial plan * fix: move 0.4.0 section above 0.3.2 to restore newest-first ordering in CHANGELOG.md Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1c1787ad-64df-4b8c-bd32-0bc5198f5029 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- CHANGELOG.md | 24 +++++++++++++++++++++--- pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc967feec..8757488b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # Changelog -## [0.3.2] - 2026-03-19 +## [0.4.0] - 2026-03-23 ### Changes -- chore: bump version to 0.3.2 -- Add conduct extension to community catalog (#1908) +- fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) +- fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) +- feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803) +- ci: increase stale workflow operations-per-run to 250 (#1922) +- docs: update publishing guide with Category and Effect columns (#1913) +- fix: Align native skills frontmatter with install_ai_skills (#1920) +- feat: add timestamp-based branch naming option for `specify init` (#1911) +- docs: add Extension Comparison Guide for community extensions (#1897) +- docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910) +- Add support for Junie (#1831) +- feat: migrate Codex/agy init to native skills workflow (#1906) +- chore: bump version to 0.3.2 (#1909) + - feat(extensions): add verify-tasks extension to community catalog (#1871) - feat(presets): add enable/disable toggle and update semantics (#1891) - feat: add iFlow CLI support (#1875) @@ -21,6 +32,13 @@ - Feature/spec kit add pi coding agent pullrequest (#1853) - feat: register spec-kit-learn extension (#1883) +## [0.3.2] - 2026-03-19 + +### Changes + +- chore: bump version to 0.3.2 +- Add conduct extension to community catalog (#1908) + ## [0.3.1] - 2026-03-17 ### Changed diff --git a/pyproject.toml b/pyproject.toml index f3ca76dd9d..dfc3c92e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.3.2" +version = "0.4.0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From dc7f09a71187bb863bedfe31f4b483cb19e7e27c Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:30:39 +0100 Subject: [PATCH 125/321] fix(templates): add missing Assumptions section to spec template (#1939) --- templates/spec-template.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/templates/spec-template.md b/templates/spec-template.md index c67d914980..4581e40529 100644 --- a/templates/spec-template.md +++ b/templates/spec-template.md @@ -113,3 +113,16 @@ - **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] - **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] - **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] From 24247c24c919f6f751c42c12d30ef726172c382a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:14:29 -0500 Subject: [PATCH 126/321] docs: add AIDE extension demo to community projects (#1943) Add the AIDE extension demo to the community projects section, showcasing a Spring Boot + React project that uses a custom extension with an alternative spec-driven workflow featuring a 7-step iterative lifecycle. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1d93ed4a1f..f82c62c8ce 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ See Spec-Driven Development in action across different scenarios with these comm - **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. +- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From b1ba972978156a1c1a93c3c092d3391680700bf9 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Tue, 24 Mar 2026 14:55:21 +0100 Subject: [PATCH 127/321] fix(scripts): prioritize .specify over git for repo root detection (#1933) * fix(scripts): prioritize .specify over git for repo root detection When spec-kit is initialized in a subdirectory that doesn't have its own .git, but a parent directory does, spec-kit was incorrectly using the parent's git repository root. This caused specs to be created in the wrong location. The fix changes repo root detection to prioritize .specify directory over git rev-parse, ensuring spec-kit respects its own initialization boundary rather than inheriting a parent git repo. Fixes #1932 Co-Authored-By: Claude Opus 4.5 * fix: address code review feedback - Normalize paths in find_specify_root to prevent infinite loop with relative paths - Use -PathType Container in PowerShell to only match .specify directories - Improve has_git/Test-HasGit to check git command availability and validate work tree - Handle git worktrees/submodules where .git can be a file - Remove dead fallback code in create-new-feature scripts Co-Authored-By: Claude Opus 4.5 * fix: check .specify before termination in find_specify_root Fixes edge case where project root is at filesystem root (common in containers). The loop now checks for .specify before checking the termination condition. Co-Authored-By: Claude Opus 4.5 * fix: scope git operations to spec-kit root & remove unused helpers - get_current_branch now uses has_git check and runs git with -C to prevent using parent git repo branch names in .specify-only projects - Same fix applied to PowerShell Get-CurrentBranch - Removed unused find_repo_root() from create-new-feature.sh - Removed unused Find-RepositoryRoot from create-new-feature.ps1 Co-Authored-By: Claude Opus 4.5 * fix: use cd -- to handle paths starting with dash Prevents cd from interpreting directory names like -P or -L as options. Co-Authored-By: Claude Opus 4.5 * fix: check git command exists before calling get_repo_root in has_git Avoids unnecessary work when git isn't installed since get_repo_root may internally call git rev-parse. Co-Authored-By: Claude Opus 4.5 * fix(powershell): use LiteralPath and check git before Get-RepoRoot - Use -LiteralPath in Find-SpecifyRoot to handle paths with wildcard characters ([, ], *, ?) - Check Get-Command git before calling Get-RepoRoot in Test-HasGit to avoid unnecessary work when git isn't installed Co-Authored-By: Claude Opus 4.5 * fix(powershell): use LiteralPath for .git check in Test-HasGit Prevents Test-Path from treating wildcard characters in paths as globs. Co-Authored-By: Claude Opus 4.5 * fix(powershell): use LiteralPath in Get-RepoRoot fallback Prevents Resolve-Path from treating wildcard characters as patterns. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- scripts/bash/common.sh | 63 +++++++++++++++---- scripts/bash/create-new-feature.sh | 28 ++------- scripts/powershell/common.ps1 | 76 ++++++++++++++++++----- scripts/powershell/create-new-feature.ps1 | 48 ++------------ 4 files changed, 125 insertions(+), 90 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index c332ceb882..416fcadfc2 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -1,15 +1,48 @@ #!/usr/bin/env bash # Common functions and variables for all scripts -# Get repository root, with fallback for non-git repositories +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found if git rev-parse --show-toplevel >/dev/null 2>&1; then git rev-parse --show-toplevel - else - # Fall back to script location for non-git repos - local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "$script_dir/../../.." && pwd) + return fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) } # Get current branch, with fallback for non-git repositories @@ -20,14 +53,14 @@ get_current_branch() { return fi - # Then check git if available - if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then - git rev-parse --abbrev-ref HEAD + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD return fi # For non-git repos, try to find the latest feature directory - local repo_root=$(get_repo_root) local specs_dir="$repo_root/specs" if [[ -d "$specs_dir" ]]; then @@ -68,9 +101,17 @@ get_current_branch() { echo "main" # Final fallback } -# Check if we have git available +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) has_git() { - git rev-parse --show-toplevel >/dev/null 2>&1 + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } check_feature_branch() { diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 0df4adc3ef..579d347523 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -80,19 +80,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then exit 1 fi -# Function to find the repository root by searching for existing project markers -find_repo_root() { - local dir="$1" - while [ "$dir" != "/" ]; do - if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then - echo "$dir" - return 0 - fi - dir="$(dirname "$dir")" - done - return 1 -} - # Function to get highest number from specs directory get_highest_from_specs() { local specs_dir="$1" @@ -171,21 +158,16 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialised with --no-git. +# Resolve repository root using common.sh functions which prioritize .specify over git SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -if git rev-parse --show-toplevel >/dev/null 2>&1; then - REPO_ROOT=$(git rev-parse --show-toplevel) +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then HAS_GIT=true else - REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" - if [ -z "$REPO_ROOT" ]; then - echo "Error: Could not determine repository root. Please run this script from within the repository." >&2 - exit 1 - fi HAS_GIT=false fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 1595bd8a09..c670977736 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -1,7 +1,38 @@ #!/usr/bin/env pwsh # Common PowerShell functions analogous to common.sh +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +function Find-SpecifyRoot { + param([string]$StartDir = (Get-Location).Path) + + # Normalize to absolute path to prevent issues with relative paths + # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?) + $current = (Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue)?.Path + if (-not $current) { return $null } + + while ($true) { + if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) { + return $current + } + $parent = Split-Path $current -Parent + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { + return $null + } + $current = $parent + } +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory function Get-RepoRoot { + # First, look for .specify directory (spec-kit's own marker) + $specifyRoot = Find-SpecifyRoot + if ($specifyRoot) { + return $specifyRoot + } + + # Fallback to git if no .specify found try { $result = git rev-parse --show-toplevel 2>$null if ($LASTEXITCODE -eq 0) { @@ -10,9 +41,10 @@ function Get-RepoRoot { } catch { # Git command failed } - - # Fall back to script location for non-git repos - return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path + + # Final fallback to script location for non-git repos + # Use -LiteralPath to handle paths with wildcard characters + return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path } function Get-CurrentBranch { @@ -20,19 +52,21 @@ function Get-CurrentBranch { if ($env:SPECIFY_FEATURE) { return $env:SPECIFY_FEATURE } - - # Then check git if available - try { - $result = git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0) { - return $result + + # Then check git if available at the spec-kit root (not parent) + $repoRoot = Get-RepoRoot + if (Test-HasGit) { + try { + $result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null + if ($LASTEXITCODE -eq 0) { + return $result + } + } catch { + # Git command failed } - } catch { - # Git command failed } - + # For non-git repos, try to find the latest feature directory - $repoRoot = Get-RepoRoot $specsDir = Join-Path $repoRoot "specs" if (Test-Path $specsDir) { @@ -69,9 +103,23 @@ function Get-CurrentBranch { return "main" } +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) function Test-HasGit { + # First check if git command is available (before calling Get-RepoRoot which may use git) + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + return $false + } + $repoRoot = Get-RepoRoot + # Check if .git exists (directory or file for worktrees/submodules) + # Use -LiteralPath to handle paths with wildcard characters + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) { + return $false + } + # Verify it's actually a valid git work tree try { - git rev-parse --show-toplevel 2>$null | Out-Null + $null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null return ($LASTEXITCODE -eq 0) } catch { return $false diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 473c925b9e..9adae131d5 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -45,30 +45,6 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) { exit 1 } -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialized with --no-git. -function Find-RepositoryRoot { - param( - [string]$StartDir, - [string[]]$Markers = @('.git', '.specify') - ) - $current = Resolve-Path $StartDir - while ($true) { - foreach ($marker in $Markers) { - if (Test-Path (Join-Path $current $marker)) { - return $current - } - } - $parent = Split-Path $current -Parent - if ($parent -eq $current) { - # Reached filesystem root without finding markers - return $null - } - $current = $parent - } -} - function Get-HighestNumberFromSpecs { param([string]$SpecsDir) @@ -139,26 +115,14 @@ function ConvertTo-CleanBranchName { return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } -$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) -if (-not $fallbackRoot) { - Write-Error "Error: Could not determine repository root. Please run this script from within the repository." - exit 1 -} - -# Load common functions (includes Resolve-Template) +# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) . "$PSScriptRoot/common.ps1" -try { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - $hasGit = $true - } else { - throw "Git not available" - } -} catch { - $repoRoot = $fallbackRoot - $hasGit = $false -} +# Use common.ps1 functions which prioritize .specify over git +$repoRoot = Get-RepoRoot + +# Check if git is available at this repo root (not a parent) +$hasGit = Test-HasGit Set-Location $repoRoot From a01180955dc51a7a7f4dcd736fda8d13ace00690 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Tue, 24 Mar 2026 06:59:43 -0700 Subject: [PATCH 128/321] Add checkpoint extension (#1947) Co-authored-by: Aaron Sun --- extensions/README.md | 1 + extensions/catalog.community.json | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/extensions/README.md b/extensions/README.md index a8bda89d4c..25660e286b 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -78,6 +78,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|----------|--------|-----| | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4ae3512509..ce5b867f9c 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -73,6 +73,35 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "checkpoint": { + "name": "Checkpoint Extension", + "id": "checkpoint", + "description": "An extension to commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end.", + "author": "aaronrsun", + "version": "1.0.0", + "download_url": "https://github.com/aaronrsun/spec-kit-checkpoint/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-checkpoint", + "homepage": "https://github.com/aaronrsun/spec-kit-checkpoint", + "documentation": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "checkpoint", + "commit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-22T00:00:00Z", + "updated_at": "2026-03-22T00:00:00Z" + }, "cleanup": { "name": "Cleanup Extension", "id": "cleanup", From ee65758e2ba731ca262ebd19ba20c6fe49bace7c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:53:19 -0500 Subject: [PATCH 129/321] chore: bump version to 0.4.1 (#1953) * chore: bump version to 0.4.1 * fix(changelog): correct 0.4.1 section ordering and version reference (#1954) * Initial plan * fix(changelog): correct 0.4.1 section ordering and version reference Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/98bc10bc-f444-4833-bd3a-ab8ea0f5e192 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8757488b3e..ab78ca9492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # Changelog +## [0.4.1] - 2026-03-24 + +### Changes + +- Add checkpoint extension (#1947) +- fix(scripts): prioritize .specify over git for repo root detection (#1933) +- docs: add AIDE extension demo to community projects (#1943) +- fix(templates): add missing Assumptions section to spec template (#1939) +- chore: bump version to 0.4.1 (#1937) + ## [0.4.0] - 2026-03-23 ### Changes - fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) - fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) + - feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803) - ci: increase stale workflow operations-per-run to 250 (#1922) - docs: update publishing guide with Category and Effect columns (#1913) diff --git a/pyproject.toml b/pyproject.toml index dfc3c92e53..de12614c06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.0" +version = "0.4.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From f132f748e3efae19a3f52d77fbfab15445d7eb99 Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Tue, 24 Mar 2026 13:17:51 -0300 Subject: [PATCH 130/321] docs: add Community Friends section with Spec Kit Assistant VS Code extension (#1944) * docs: add Community Tools section to README.md * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * docs: rename "Community Tools" section to "Community Friends" in README.md * docs: rename "Community Tools" section to "Community Friends" in README.md --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index f82c62c8ce..e18ff7da18 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) - [🚶 Community Walkthroughs](#-community-walkthroughs) +- [🛠️ Community Friends](#️-community-friends) - [🤖 Supported AI Agents](#-supported-ai-agents) - [🔧 Specify CLI Reference](#-specify-cli-reference) - [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) @@ -173,6 +174,12 @@ See Spec-Driven Development in action across different scenarios with these comm - **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. +## 🛠️ Community Friends + +Community-built tools that integrate with Spec Kit to enhance the SDD workflow: + +- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From bc766c3101df1f03dfbb2560345fa398aa948052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Tue, 24 Mar 2026 17:36:47 +0100 Subject: [PATCH 131/321] Add Community Friends section to README (#1956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Community Friends section with cc-sdd Adds a new "Community Friends" section to the README for projects that extend or build on Spec Kit. Starts with cc-sdd, a Claude Code plugin that layers composable traits (quality gates, worktree isolation, agent teams) on top of Spec Kit's core workflow. Suggested by @mnriem in discussion #1889. Assisted-By: 🤖 Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) * Update cc-sdd repo URL after rename Assisted-By: 🤖 Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) * Mention Superpowers explicitly in cc-sdd description Assisted-By: 🤖 Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index e18ff7da18..f927481028 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - [📋 Detailed Process](#-detailed-process) - [🔍 Troubleshooting](#-troubleshooting) - [💬 Support](#-support) +- [🤝 Community Friends](#-community-friends) - [🙏 Acknowledgements](#-acknowledgements) - [📄 License](#-license) @@ -783,6 +784,14 @@ rm gcm-linux_amd64.2.6.1.deb For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development. +## 🤝 Community Friends + +Projects that extend, visualize, or build on Spec Kit: + +| Project | Description | +|---------|-------------| +| [cc-sdd](https://github.com/rhuss/cc-sdd) | Claude Code plugin that adds composable traits on top of Spec Kit: [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. | + ## 🙏 Acknowledgements This project is heavily influenced by and based on the work and research of [John Lam](https://github.com/jflam). From 816c1160e92a2c972dc82edb2b0b3b092709a2ac Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:37:54 +0100 Subject: [PATCH 132/321] fix(commands): rename NFR references to success criteria in analyze and clarify (#1935) * fix(commands): rename NFR references to success criteria in analyze and clarify * fix(analyze): align Success Criteria description and inventory keys with spec template - Reword "non-functional targets" to "measurable outcomes" to match the spec template's broader scope (performance, user success, business impact) - Use explicit FR-/SC- identifiers as primary stable keys in the requirements inventory instead of derived slugs alone --- templates/commands/analyze.md | 6 +++--- templates/commands/clarify.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 827d4e4caf..b3174338d9 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -44,7 +44,7 @@ Load only the minimal necessary context from each artifact: - Overview/Context - Functional Requirements -- Non-Functional Requirements +- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact) - User Stories - Edge Cases (if present) @@ -71,7 +71,7 @@ Load only the minimal necessary context from each artifact: Create internal representations (do not include raw artifacts in output): -- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`) +- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%"). - **User story/action inventory**: Discrete user actions with acceptance criteria - **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases) - **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements @@ -105,7 +105,7 @@ Focus on high-signal findings. Limit to 50 findings total; aggregate remainder i - Requirements with zero associated tasks - Tasks with no mapped requirement/story -- Non-functional requirements not reflected in tasks (e.g., performance, security) +- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks #### F. Inconsistency diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 196fa2c0f7..26efb5aedb 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -145,7 +145,7 @@ Execution steps: - Functional ambiguity → Update or add a bullet in Functional Requirements. - User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario. - Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly. - - Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target). + - Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target). - Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it). - Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once. - If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text. From 2c2936022c23844fa6dde5d224e6d2e51b1249db Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:05:43 -0500 Subject: [PATCH 133/321] docs(readme): consolidate Community Friends sections and fix ToC anchors (#1958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(readme): consolidate Community Friends sections and fix ToC anchors - Merge duplicate 🤝 Community Friends section (table format near bottom) into the existing 🛠️ Community Friends section (bullet list) - Add cc-sdd entry alongside Spec Kit Assistant - Update intro text to 'Community projects that extend, visualize, or build on Spec Kit' - Fix ToC anchors for Video Overview and Community Friends (remove variation selector from fragment) * docs(readme): remove stale ToC entry for deleted Community Friends section --- README.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f927481028..36a6dfa4db 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ - [📋 Detailed Process](#-detailed-process) - [🔍 Troubleshooting](#-troubleshooting) - [💬 Support](#-support) -- [🤝 Community Friends](#-community-friends) - [🙏 Acknowledgements](#-acknowledgements) - [📄 License](#-license) @@ -177,7 +176,9 @@ See Spec-Driven Development in action across different scenarios with these comm ## 🛠️ Community Friends -Community-built tools that integrate with Spec Kit to enhance the SDD workflow: +Community projects that extend, visualize, or build on Spec Kit: + +- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. - **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. @@ -784,14 +785,6 @@ rm gcm-linux_amd64.2.6.1.deb For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development. -## 🤝 Community Friends - -Projects that extend, visualize, or build on Spec Kit: - -| Project | Description | -|---------|-------------| -| [cc-sdd](https://github.com/rhuss/cc-sdd) | Claude Code plugin that adds composable traits on top of Spec Kit: [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. | - ## 🙏 Acknowledgements This project is heavily influenced by and based on the work and research of [John Lam](https://github.com/jflam). From ebc61067e8d9913ec2a0b37d8b6c032b7c4c511b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:34:32 -0500 Subject: [PATCH 134/321] docs: move community extensions table to main README for discoverability (#1959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 🧩 Community Extensions section to README.md before Community Walkthroughs - Add table of contents entry for the new section - Replace extensions/README.md table with a link back to the main README - Update EXTENSION-PUBLISHING-GUIDE.md references to point to README.md - Update EXTENSION-DEVELOPMENT-GUIDE.md references to point to README.md --- README.md | 39 ++++++++++++++++++++++- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 2 +- extensions/EXTENSION-PUBLISHING-GUIDE.md | 8 ++--- extensions/README.md | 34 ++------------------ 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 36a6dfa4db..a407d43e90 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development) - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) +- [🧩 Community Extensions](#-community-extensions) - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🛠️ Community Friends](#️-community-friends) - [🤖 Supported AI Agents](#-supported-ai-agents) @@ -156,6 +157,42 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c [![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv) +## 🧩 Community Extensions + +The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json): + +**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress + +**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs + +| Extension | Purpose | Category | Effect | URL | +|-----------|---------|----------|--------|-----| +| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | +| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | +| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | +| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | +| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | +| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | +| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | +| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | +| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | +| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | +| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | +| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | +| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | +| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | +| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | +| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) | +| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | +| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | +| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | + +To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). + ## 🚶 Community Walkthroughs See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: @@ -391,7 +428,7 @@ specify extension add For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. -See the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own. +See the [Extensions README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available. ### Presets — Customize Existing Workflows diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index feea7b2782..c4af7ed15e 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -523,7 +523,7 @@ Submit to the community catalog for public discovery: 1. **Fork** spec-kit repository 2. **Add entry** to `extensions/catalog.community.json` -3. **Update** `extensions/README.md` with your extension +3. **Update** the Community Extensions table in `README.md` with your extension 4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) 5. **After merge**, your extension becomes available: - Users can browse `catalog.community.json` to discover your extension diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index fe381e308e..25801ca176 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -204,9 +204,9 @@ Edit `extensions/catalog.community.json` and add your extension: - Use current timestamp for `created_at` and `updated_at` - Update the top-level `updated_at` to current time -### 3. Update Extensions README +### 3. Update Community Extensions Table -Add your extension to the Available Extensions table in `extensions/README.md`: +Add your extension to the Community Extensions table in the project root `README.md`: ```markdown | Your Extension Name | Brief description of what it does | `` | | [repo-name](https://github.com/your-org/spec-kit-your-extension) | @@ -234,7 +234,7 @@ Insert your extension in alphabetical order in the table. git checkout -b add-your-extension # Commit your changes -git add extensions/catalog.community.json extensions/README.md +git add extensions/catalog.community.json README.md git commit -m "Add your-extension to community catalog - Extension ID: your-extension @@ -273,7 +273,7 @@ Brief description of what your extension does. - [x] All commands working - [x] No security vulnerabilities - [x] Added to extensions/catalog.community.json -- [x] Added to extensions/README.md Available Extensions table +- [x] Added to Community Extensions table in README.md ### Testing Tested on: diff --git a/extensions/README.md b/extensions/README.md index 25660e286b..eb8c3c782f 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -68,37 +68,9 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta ## Available Community Extensions -The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json): - -**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress - -**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs - -| Extension | Purpose | Category | Effect | URL | -|-----------|---------|----------|--------|-----| -| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | -| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | -| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | -| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | -| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | -| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | -| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | -| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | -| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | -| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | -| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | -| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | -| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | -| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | -| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | -| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | -| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | -| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | -| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | -| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) | -| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | -| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | -| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | +See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions. + +For the raw catalog data, see [`catalog.community.json`](catalog.community.json). ## Adding Your Extension From eeda669c19a32711546b8b8ec4060e7540365ac0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:56:36 -0500 Subject: [PATCH 135/321] docs: add community presets section to main README (#1960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 🎨 Community Presets section between Community Extensions and Community Walkthroughs - Add ToC entry for the new section - Populate presets/catalog.community.json with pirate and aide-in-place presets - Entries alphabetized: catalog by id, README table by name --- README.md | 12 ++++++++ presets/catalog.community.json | 56 ++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a407d43e90..ff909d7d24 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [⚡ Get Started](#-get-started) - [📽️ Video Overview](#️-video-overview) - [🧩 Community Extensions](#-community-extensions) +- [🎨 Community Presets](#-community-presets) - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🛠️ Community Friends](#️-community-friends) - [🤖 Supported AI Agents](#-supported-ai-agents) @@ -193,6 +194,17 @@ The following community-contributed extensions are available in [`catalog.commun To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). +## 🎨 Community Presets + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | + +To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). + ## 🚶 Community Walkthroughs See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 368f208b7b..9f7f045b31 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,58 @@ { "schema_version": "1.0", - "updated_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", - "presets": {} + "presets": { + "aide-in-place": { + "name": "AIDE In-Place Migration", + "id": "aide-in-place", + "version": "1.0.0", + "description": "Adapts the AIDE workflow for in-place technology migrations (X → Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.", + "author": "mnriem", + "repository": "https://github.com/mnriem/spec-kit-presets", + "download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip", + "homepage": "https://github.com/mnriem/spec-kit-presets", + "documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/aide-in-place/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0", + "extensions": ["aide"] + }, + "provides": { + "templates": 2, + "commands": 8 + }, + "tags": [ + "migration", + "in-place", + "brownfield", + "aide" + ] + }, + "pirate": { + "name": "Pirate Speak (Full)", + "id": "pirate", + "version": "1.0.0", + "description": "Arrr! Transforms all Spec Kit output into pirate speak. Specs, plans, and tasks be written fer scallywags.", + "author": "mnriem", + "repository": "https://github.com/mnriem/spec-kit-presets", + "download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/pirate-v1.0.0/pirate.zip", + "homepage": "https://github.com/mnriem/spec-kit-presets", + "documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/pirate/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 6, + "commands": 9 + }, + "tags": [ + "pirate", + "theme", + "fun", + "experimental" + ] + } + } } From 00e5dc1f91b475e50d3bd82bc099498388b5582c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:18:30 -0500 Subject: [PATCH 136/321] Add AIDE, Extensify, and Presetify to community extensions (#1961) * Add AIDE, Extensify, and Presetify to community extensions Add three extensions from the mnriem/spec-kit-extensions repository: - AI-Driven Engineering (AIDE): structured 7-step workflow for building new projects from scratch with AI assistants - Extensify: create and validate extensions and extension catalogs - Presetify: create and validate presets and preset catalogs Updates both the README community extensions table and catalog.community.json with entries in alphabetical order. * fix(tests): isolate preset search test from community catalog growth Mock get_active_catalogs to return only the default catalog entry so the test uses only its own cached data and won't break as the community preset catalog grows. --- README.md | 3 + extensions/catalog.community.json | 95 +++++++++++++++++++++++++++++++ tests/test_presets.py | 39 +++++++------ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ff909d7d24..1b866380ac 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| +| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | @@ -175,10 +176,12 @@ The following community-contributed extensions are available in [`catalog.commun | Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | +| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | +| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ce5b867f9c..846254c484 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3,6 +3,39 @@ "updated_at": "2026-03-19T12:08:20Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { + "aide": { + "name": "AI-Driven Engineering (AIDE)", + "id": "aide", + "description": "A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 7, + "hooks": 0 + }, + "tags": [ + "workflow", + "project-management", + "ai-driven", + "new-project", + "planning", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, "archive": { "name": "Archive Extension", "id": "archive", @@ -281,6 +314,37 @@ "created_at": "2026-03-13T00:00:00Z", "updated_at": "2026-03-13T00:00:00Z" }, + "extensify": { + "name": "Extensify", + "id": "extensify", + "description": "Create and validate extensions and extension catalogs.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "extensions", + "workflow", + "validation", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, "fleet": { "name": "Fleet Orchestrator", "id": "fleet", @@ -373,6 +437,37 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "presetify": { + "name": "Presetify", + "id": "presetify", + "description": "Create and validate presets and preset catalogs.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/presetify-v1.0.0/presetify.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "presets", + "workflow", + "templates", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, "ralph": { "name": "Ralph Loop", "id": "ralph", diff --git a/tests/test_presets.py b/tests/test_presets.py index 2716b73dc7..50f6502161 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1172,6 +1172,8 @@ def test_clear_cache(self, project_dir): def test_search_with_cached_data(self, project_dir): """Test search with cached catalog data.""" + from unittest.mock import patch + catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) @@ -1200,23 +1202,26 @@ def test_search_with_cached_data(self, project_dir): "cached_at": datetime.now(timezone.utc).isoformat(), })) - # Search by query - results = catalog.search(query="agile") - assert len(results) == 1 - assert results[0]["id"] == "safe-agile" - - # Search by tag - results = catalog.search(tag="hipaa") - assert len(results) == 1 - assert results[0]["id"] == "healthcare" - - # Search by author - results = catalog.search(author="agile-community") - assert len(results) == 1 - - # Search all - results = catalog.search() - assert len(results) == 2 + # Isolate from community catalog so results are deterministic + default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)] + with patch.object(catalog, "get_active_catalogs", return_value=default_only): + # Search by query + results = catalog.search(query="agile") + assert len(results) == 1 + assert results[0]["id"] == "safe-agile" + + # Search by tag + results = catalog.search(tag="hipaa") + assert len(results) == 1 + assert results[0]["id"] == "healthcare" + + # Search by author + results = catalog.search(author="agile-community") + assert len(results) == 1 + + # Search all + results = catalog.search() + assert len(results) == 2 def test_get_pack_info(self, project_dir): """Test getting info for a specific pack.""" From fb152eb8249340e4f333aa2daa1a74038b9357a5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:22:36 -0500 Subject: [PATCH 137/321] docs: add manual testing guide for slash command validation (#1955) * docs: add manual testing guide for slash command validation Adds a top-level TESTING.md that describes the manual test process PR submitters must follow when their changes affect slash commands. Includes: - Process overview (identify affected commands, setup, run, report) - Local setup instructions using editable install - Reporting template for PR submissions - Agent prompt that analyzes changed files and determines which commands need testing, including transitive script dependencies and extension hook mappings * Update TESTING.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update TESTING.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TESTING.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 TESTING.md diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000000..95b1bde847 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,79 @@ +# Manual Testing Guide + +Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. + +## Process + +1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. +2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)). +3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). +4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. +5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. + +## Setup + +```bash +# Install the CLI from your local branch +cd +uv venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv pip install -e . + +# Initialize a test project using your local changes +specify init /tmp/speckit-test --ai --offline +cd /tmp/speckit-test + +# Open in your agent +``` + +## Reporting results + +Paste this into your PR: + +~~~markdown +## Manual test results + +**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] + +| Command tested | Notes | +|----------------|-------| +| `/speckit.command` | | +~~~ + +## Determining which tests to run + +Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. + +~~~text +Read TESTING.md, then run `git diff --name-only main` to get my changed files. +For each changed file, determine which slash commands it affects by reading +the command templates in templates/commands/ to understand what each command +invokes. Use these mapping rules: + +- templates/commands/X.md → the command it defines +- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected +- templates/Z-template.md → every command that consumes that template during execution +- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify +- extensions/X/commands/* → the extension command it defines +- extensions/X/scripts/* → every extension command that invokes that script +- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected +- presets/*/* → test preset scaffolding via `specify init` with the preset +- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets + +Include prerequisite tests (e.g., T5 requires T3 requires T1). + +Output in this format: + +### Test selection reasoning + +| Changed file | Affects | Test | Why | +|---|---|---|---| +| (path) | (command) | T# | (reason) | + +### Required tests + +Number each test sequentially (T1, T2, ...). List prerequisite tests first. + +- T1: /speckit.command — (reason) +- T2: /speckit.command — (reason) +~~~ From 36019ebf1baf6a0a7e11f0705d25fc62b1e9b1aa Mon Sep 17 00:00:00 2001 From: Dhilip Date: Wed, 25 Mar 2026 08:48:36 -0400 Subject: [PATCH 138/321] feat: Auto-register ai-skills for extensions whenever applicable (#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Auto-register ai-skills for extensions whenever applicable * fix: failing test * fix: address copilot review comments – path traversal guard and use short_name in title * fix: address remaining copilot review comments – is_file guard, skills type-validation, and exact extension ownership check on fallback rmtree * fix: address copilot round-3 comments – align skill naming with presets.py convention, safe rmdir on fail, require SKILL.md for fallback rmtree, normalize skill_count in CLI * fix: is_dir() guard in fast-path rmtree and fix ghost-skill assertion naming * fix: path-traversal guard on skill_name in both rmtree paths of _unregister_extension_skills * fix: add SKILL.md ownership check to fast-path rmtree and alias shadowed _get_skills_dir import --- README.md | 2 +- extensions/EXTENSION-USER-GUIDE.md | 15 + src/specify_cli/__init__.py | 16 +- src/specify_cli/extensions.py | 300 +++++++++++++- tests/test_extension_skills.py | 640 +++++++++++++++++++++++++++++ tests/test_presets.py | 4 +- 6 files changed, 972 insertions(+), 5 deletions(-) create mode 100644 tests/test_extension_skills.py diff --git a/README.md b/README.md index 1b866380ac..4d83483036 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) | +| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | | `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | ### Examples diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 21313c0aca..2fd28191ca 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -187,6 +187,21 @@ Provided commands: Check: .specify/extensions/jira/ ``` +### Automatic Agent Skill Registration + +If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. + +```text +✓ Extension installed successfully! + +Jira Integration (v1.0.0) + ... + +✓ 3 agent skill(s) auto-registered +``` + +When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten. + --- ## Using Extensions diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 08af651078..d78609ad66 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3594,6 +3594,15 @@ def extension_add( for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + # Normalize to guard against corrupted registry entries + if not isinstance(reg_skills, list): + reg_skills = [] + if reg_skills: + console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{manifest.id}/") @@ -3632,14 +3641,19 @@ def extension_remove( installed = manager.list_installed() extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - # Get extension info for command count + # Get extension info for command and skill counts ext_manifest = manager.get_extension(extension_id) cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + reg_meta = manager.registry.get(extension_id) + raw_skills = reg_meta.get("registered_skills") if reg_meta else None + skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") if not keep_config: console.print(" • Config files (will be backed up)") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b26b1e9310..d71480ac47 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -510,6 +510,288 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]: return _ignore + def _get_skills_dir(self) -> Optional[Path]: + """Return the skills directory if ``--ai-skills`` was used during init. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled or the init-options file is missing. + """ + from . import load_init_options, _get_skills_dir as resolve_skills_dir + + opts = load_init_options(self.project_root) + if not opts.get("ai_skills"): + return None + + agent = opts.get("ai") + if not agent: + return None + + skills_dir = resolve_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_extension_skills( + self, + manifest: ExtensionManifest, + extension_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for extension commands as agent skills. + + For every command in the extension manifest, creates a SKILL.md + file in the agent's skills directory following the agentskills.io + specification. This is only done when ``--ai-skills`` was used + during project initialisation. + + Args: + manifest: Extension manifest. + extension_dir: Installed extension directory. + + Returns: + List of skill names that were created (for registry storage). + """ + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import load_init_options + import yaml + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai", "") + + written: List[str] = [] + + for cmd_info in manifest.commands: + cmd_name = cmd_info["name"] + cmd_file_rel = cmd_info["file"] + + # Guard against path traversal: reject absolute paths and ensure + # the resolved file stays within the extension directory. + cmd_path = Path(cmd_file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = extension_dir.resolve() + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + + if not source_file.is_file(): + continue + + # Derive skill name from command name, matching the convention used by + # presets.py: strip the leading "speckit." prefix, then form: + # Kimi → "speckit.{short_name}" (dot preserved for Kimi agent) + # other → "speckit-{short_name}" (hyphen separator) + short_name_raw = cmd_name + if short_name_raw.startswith("speckit."): + short_name_raw = short_name_raw[len("speckit."):] + if selected_ai == "kimi": + skill_name = f"speckit.{short_name_raw}" + else: + skill_name = f"speckit-{short_name_raw}" + + # Check if skill already exists before creating the directory + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if skill_file.exists(): + # Do not overwrite user-customized skills + continue + + # Create skill directory; track whether we created it so we can clean + # up safely if reading the source file subsequently fails. + created_now = not skill_subdir.exists() + skill_subdir.mkdir(parents=True, exist_ok=True) + + # Parse the command file — guard against IsADirectoryError / decode errors + try: + content = source_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + if created_now: + try: + skill_subdir.rmdir() # undo the mkdir; dir is empty at this point + except OSError: + pass # best-effort cleanup + continue + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + try: + frontmatter = yaml.safe_load(parts[1]) + except yaml.YAMLError: + frontmatter = {} + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + description = original_desc or f"Extension command: {cmd_name}" + + frontmatter_data = { + "name": skill_name, + "description": description, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"extension:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + + # Derive a human-friendly title from the command name + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + title_name = short_name.replace(".", " ").replace("-", " ").title() + + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {title_name} Skill\n\n" + f"{body}\n" + ) + + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None: + """Remove SKILL.md directories for extension skills. + + Called during extension removal to clean up skill files that + were created by ``_register_extension_skills()``. + + If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed + init-options.json or toggled ai_skills after installation), we + fall back to scanning all known agent skills directories so that + orphaned skill directories are still cleaned up. In that case + each candidate directory is verified against the SKILL.md + ``metadata.source`` field before removal to avoid accidentally + deleting user-created skills with the same name. + + Args: + skill_names: List of skill names to remove. + extension_id: Extension ID used to verify ownership during + fallback candidate scanning. + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + + if skills_dir: + # Fast path: we know the exact skills directory + for skill_name in skill_names: + # Guard against path traversal from a corrupted registry entry: + # reject names that are absolute, contain path separators, or + # resolve to a path outside the skills directory. + sn_path = Path(skill_name) + if sn_path.is_absolute() or len(sn_path.parts) != 1: + continue + try: + skill_subdir = (skills_dir / skill_name).resolve() + skill_subdir.relative_to(skills_dir.resolve()) # raises if outside + except (OSError, ValueError): + continue + if not skill_subdir.is_dir(): + continue + # Safety check: only delete if SKILL.md exists and its + # metadata.source matches exactly this extension — mirroring + # the fallback branch — so a corrupted registry entry cannot + # delete an unrelated user skill. + skill_md = skill_subdir / "SKILL.md" + if not skill_md.is_file(): + continue + try: + import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") + source = "" + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + source = ( + fm.get("metadata", {}).get("source", "") + if isinstance(fm, dict) + else "" + ) + if source != f"extension:{extension_id}": + continue + except (OSError, UnicodeDecodeError, Exception): + continue + shutil.rmtree(skill_subdir) + else: + # Fallback: scan all possible agent skills directories + from . import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + + candidate_dirs: set[Path] = set() + for override_path in AGENT_SKILLS_DIR_OVERRIDES.values(): + candidate_dirs.add(self.project_root / override_path) + for cfg in AGENT_CONFIG.values(): + folder = cfg.get("folder", "") + if folder: + candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills") + candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR) + + for skills_candidate in candidate_dirs: + if not skills_candidate.is_dir(): + continue + for skill_name in skill_names: + # Same path-traversal guard as the fast path above + sn_path = Path(skill_name) + if sn_path.is_absolute() or len(sn_path.parts) != 1: + continue + try: + skill_subdir = (skills_candidate / skill_name).resolve() + skill_subdir.relative_to(skills_candidate.resolve()) # raises if outside + except (OSError, ValueError): + continue + if not skill_subdir.is_dir(): + continue + # Safety check: only delete if SKILL.md exists and its + # metadata.source matches exactly this extension. If the + # file is missing or unreadable we skip to avoid deleting + # unrelated user-created directories. + skill_md = skill_subdir / "SKILL.md" + if not skill_md.is_file(): + continue + try: + import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") + source = "" + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + source = ( + fm.get("metadata", {}).get("source", "") + if isinstance(fm, dict) + else "" + ) + # Only remove skills explicitly created by this extension + if source != f"extension:{extension_id}": + continue + except (OSError, UnicodeDecodeError, Exception): + # If we can't verify, skip to avoid accidental deletion + continue + shutil.rmtree(skill_subdir) + def check_compatibility( self, manifest: ExtensionManifest, @@ -601,6 +883,10 @@ def install_from_directory( manifest, dest_dir, self.project_root ) + # Auto-register extension commands as agent skills when --ai-skills + # was used during project initialisation (feature parity). + registered_skills = self._register_extension_skills(manifest, dest_dir) + # Register hooks hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) @@ -612,7 +898,8 @@ def install_from_directory( "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, - "registered_commands": registered_commands + "registered_commands": registered_commands, + "registered_skills": registered_skills, }) return manifest @@ -690,9 +977,15 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if not self.registry.is_installed(extension_id): return False - # Get registered commands before removal + # Get registered commands and skills before removal metadata = self.registry.get(extension_id) registered_commands = metadata.get("registered_commands", {}) if metadata else {} + raw_skills = metadata.get("registered_skills", []) if metadata else [] + # Normalize: must be a list of plain strings to avoid corrupted-registry errors + if isinstance(raw_skills, list): + registered_skills = [s for s in raw_skills if isinstance(s, str)] + else: + registered_skills = [] extension_dir = self.extensions_dir / extension_id @@ -701,6 +994,9 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + # Unregister agent skills + self._unregister_extension_skills(registered_skills, extension_id) + if keep_config: # Preserve config files, only remove non-config files if extension_dir.exists(): diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py new file mode 100644 index 0000000000..b8d5202f52 --- /dev/null +++ b/tests/test_extension_skills.py @@ -0,0 +1,640 @@ +""" +Unit tests for extension skill auto-registration. + +Tests cover: +- SKILL.md generation when --ai-skills was used during init +- No skills created when ai_skills not active +- SKILL.md content correctness +- Existing user-modified skills not overwritten +- Skill cleanup on extension removal +- Registry metadata includes registered_skills +""" + +import json +import pytest +import tempfile +import shutil +import yaml +from pathlib import Path + +from specify_cli.extensions import ( + ExtensionManifest, + ExtensionManager, + ExtensionError, +) + + +# ===== Helpers ===== + +def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True): + """Write a .specify/init-options.json file.""" + opts_dir = project_root / ".specify" + opts_dir.mkdir(parents=True, exist_ok=True) + opts_file = opts_dir / "init-options.json" + opts_file.write_text(json.dumps({ + "ai": ai, + "ai_skills": ai_skills, + "script": "sh", + })) + + +def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: + """Create and return the expected skills directory for the given agent.""" + # Match the logic in _get_skills_dir() from specify_cli + from specify_cli import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + + if ai in AGENT_SKILLS_DIR_OVERRIDES: + skills_dir = project_root / AGENT_SKILLS_DIR_OVERRIDES[ai] + else: + agent_config = AGENT_CONFIG.get(ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + skills_dir = project_root / agent_folder.rstrip("/") / "skills" + else: + skills_dir = project_root / DEFAULT_SKILLS_DIR + + skills_dir.mkdir(parents=True, exist_ok=True) + return skills_dir + + +def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path: + """Create a complete extension directory with manifest and command files.""" + ext_dir = temp_dir / ext_id + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension for skill registration", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test hello command", + }, + { + "name": f"speckit.{ext_id}.world", + "file": "commands/world.md", + "description": "Test world command", + }, + ] + }, + } + + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + + (commands_dir / "hello.md").write_text( + "---\n" + "description: \"Test hello command\"\n" + "---\n" + "\n" + "# Hello Command\n" + "\n" + "Run this to say hello.\n" + "$ARGUMENTS\n" + ) + + (commands_dir / "world.md").write_text( + "---\n" + "description: \"Test world command\"\n" + "---\n" + "\n" + "# World Command\n" + "\n" + "Run this to greet the world.\n" + ) + + return ext_dir + + +# ===== Fixtures ===== + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + # Create .specify directory + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + return proj_dir + + +@pytest.fixture +def extension_dir(temp_dir): + """Create a complete extension directory.""" + return _create_extension_dir(temp_dir) + + +@pytest.fixture +def skills_project(project_dir): + """Create a project with --ai-skills enabled and skills directory.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + return project_dir, skills_dir + + +@pytest.fixture +def no_skills_project(project_dir): + """Create a project without --ai-skills.""" + _create_init_options(project_dir, ai="claude", ai_skills=False) + return project_dir + + +# ===== ExtensionManager._get_skills_dir Tests ===== + +class TestExtensionManagerGetSkillsDir: + """Test _get_skills_dir() on ExtensionManager.""" + + def test_returns_skills_dir_when_active(self, skills_project): + """Should return skills dir when ai_skills is true and dir exists.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result == skills_dir + + def test_returns_none_when_no_ai_skills(self, no_skills_project): + """Should return None when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_no_init_options(self, project_dir): + """Should return None when init-options.json is missing.""" + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_skills_dir_missing(self, project_dir): + """Should return None when skills dir doesn't exist on disk.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + # Don't create the skills directory + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + +# ===== Extension Skill Registration Tests ===== + +class TestExtensionSkillRegistration: + """Test _register_extension_skills() on ExtensionManager.""" + + def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir): + """Skills should be created when ai_skills is enabled.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Check that skill directories were created + skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) + assert "speckit-test-ext.hello" in skill_dirs + assert "speckit-test-ext.world" in skill_dirs + + def test_skill_md_content_correct(self, skills_project, extension_dir): + """SKILL.md should have correct agentskills.io structure.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Check structure + assert content.startswith("---\n") + assert "name: speckit-test-ext.hello" in content + assert "description:" in content + assert "Test hello command" in content + assert "source: extension:test-ext" in content + assert "author: github-spec-kit" in content + assert "compatibility:" in content + assert "Run this to say hello." in content + + def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): + """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md" + content = skill_file.read_text() + + assert content.startswith("---\n") + parts = content.split("---", 2) + assert len(parts) >= 3 + parsed = yaml.safe_load(parts[1]) + assert isinstance(parsed, dict) + assert parsed["name"] == "speckit-test-ext.hello" + assert "description" in parsed + + def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): + """No skills should be created when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify registry + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_no_skills_when_init_options_missing(self, project_dir, extension_dir): + """No skills should be created when init-options.json is absent.""" + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_existing_skill_not_overwritten(self, skills_project, extension_dir): + """Pre-existing SKILL.md should not be overwritten.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "speckit-test-ext.hello" + custom_dir.mkdir(parents=True) + custom_content = "# My Custom Hello Skill\nUser-modified content\n" + (custom_dir / "SKILL.md").write_text(custom_content) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Custom skill should be untouched + assert (custom_dir / "SKILL.md").read_text() == custom_content + + # But the other skill should still be created + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext.world" in metadata["registered_skills"] + # The pre-existing one should NOT be in registered_skills (it was skipped) + assert "speckit-test-ext.hello" not in metadata["registered_skills"] + + def test_registered_skills_in_registry(self, skills_project, extension_dir): + """Registry should contain registered_skills list.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "registered_skills" in metadata + assert len(metadata["registered_skills"]) == 2 + assert "speckit-test-ext.hello" in metadata["registered_skills"] + assert "speckit-test-ext.world" in metadata["registered_skills"] + + def test_kimi_uses_dot_notation(self, project_dir, temp_dir): + """Kimi agent should use dot notation for skill names.""" + _create_init_options(project_dir, ai="kimi", ai_skills=True) + _create_skills_dir(project_dir, ai="kimi") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + # Kimi should use dots, not hyphens + assert "speckit.test-ext.hello" in metadata["registered_skills"] + assert "speckit.test-ext.world" in metadata["registered_skills"] + + def test_missing_command_file_skipped(self, skills_project, temp_dir): + """Commands with missing source files should be skipped gracefully.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "missing-cmd-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "missing-cmd-ext", + "name": "Missing Cmd Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.missing-cmd-ext.exists", + "file": "commands/exists.md", + "description": "Exists", + }, + { + "name": "speckit.missing-cmd-ext.ghost", + "file": "commands/ghost.md", + "description": "Does not exist", + }, + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "exists.md").write_text( + "---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n" + ) + # Intentionally do NOT create ghost.md + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-missing-cmd-ext.exists" in metadata["registered_skills"] + assert "speckit-missing-cmd-ext.ghost" not in metadata["registered_skills"] + + +# ===== Extension Skill Unregistration Tests ===== + +class TestExtensionSkillUnregistration: + """Test _unregister_extension_skills() on ExtensionManager.""" + + def test_skills_removed_on_extension_remove(self, skills_project, extension_dir): + """Removing an extension should clean up its skill directories.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists() + + # Remove extension + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + # Skills should be gone + assert not (skills_dir / "speckit-test-ext.hello").exists() + assert not (skills_dir / "speckit-test-ext.world").exists() + + def test_other_skills_preserved_on_remove(self, skills_project, extension_dir): + """Non-extension skills should not be affected by extension removal.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "my-custom-skill" + custom_dir.mkdir(parents=True) + (custom_dir / "SKILL.md").write_text("# My Custom Skill\n") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + manager.remove(manifest.id, keep_config=False) + + # Custom skill should still exist + assert (custom_dir / "SKILL.md").exists() + assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n" + + def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir): + """Gracefully handle case where skill dirs were already deleted.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Manually delete skill dirs before calling remove + shutil.rmtree(skills_dir / "speckit-test-ext.hello") + shutil.rmtree(skills_dir / "speckit-test-ext.world") + + # Should not raise + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir): + """Removal without active skills should not attempt skill cleanup.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Should not raise even though no skills exist + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + +# ===== Command File Without Frontmatter ===== + +class TestExtensionSkillEdgeCases: + """Test edge cases in extension skill registration.""" + + def test_command_without_frontmatter(self, skills_project, temp_dir): + """Commands without YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "nofm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "nofm-ext", + "name": "No Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.nofm-ext.plain", + "file": "commands/plain.md", + "description": "Plain command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "plain.md").write_text( + "# Plain Command\n\nBody without frontmatter.\n" + ) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-nofm-ext.plain" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: speckit-nofm-ext.plain" in content + # Fallback description when no frontmatter description + assert "Extension command: speckit.nofm-ext.plain" in content + assert "Body without frontmatter." in content + + def test_gemini_agent_skills(self, project_dir, temp_dir): + """Gemini agent should use .gemini/skills/ for skill directory.""" + _create_init_options(project_dir, ai="gemini", ai_skills=True) + _create_skills_dir(project_dir, ai="gemini") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skills_dir = project_dir / ".gemini" / "skills" + assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists() + + def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): + """Installing and removing different extensions should be independent.""" + project_dir, skills_dir = skills_project + + ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a") + ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b") + + manager = ExtensionManager(project_dir) + manifest_a = manager.install_from_directory( + ext_dir_a, "0.1.0", register_commands=False + ) + manifest_b = manager.install_from_directory( + ext_dir_b, "0.1.0", register_commands=False + ) + + # Both should have skills + assert (skills_dir / "speckit-ext-a.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists() + + # Remove ext-a + manager.remove("ext-a", keep_config=False) + + # ext-a skills gone, ext-b skills preserved + assert not (skills_dir / "speckit-ext-a.hello").exists() + assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists() + + def test_malformed_frontmatter_handled(self, skills_project, temp_dir): + """Commands with invalid YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "badfm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "badfm-ext", + "name": "Bad Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.badfm-ext.broken", + "file": "commands/broken.md", + "description": "Broken frontmatter", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + # Malformed YAML: invalid key-value syntax + (ext_dir / "commands" / "broken.md").write_text( + "---\n" + "description: [invalid yaml\n" + " unclosed: bracket\n" + "---\n" + "\n" + "# Broken Command\n" + "\n" + "This body should still be used.\n" + ) + + manager = ExtensionManager(project_dir) + # Should not raise + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-badfm-ext.broken" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + # Fallback description since frontmatter was invalid + assert "Extension command: speckit.badfm-ext.broken" in content + assert "This body should still be used." in content + + def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir): + """Skills should be cleaned up even if init-options.json is deleted after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + + # Delete init-options.json to simulate user change + init_opts = project_dir / ".specify" / "init-options.json" + init_opts.unlink() + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext.hello").exists() + assert not (skills_dir / "speckit-test-ext.world").exists() + + def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir): + """Skills should be cleaned up even if ai_skills is toggled to false after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + + # Toggle ai_skills to false + _create_init_options(project_dir, ai="claude", ai_skills=False) + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext.hello").exists() + assert not (skills_dir / "speckit-test-ext.world").exists() diff --git a/tests/test_presets.py b/tests/test_presets.py index 50f6502161..95dca41224 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1170,10 +1170,12 @@ def test_clear_cache(self, project_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() - def test_search_with_cached_data(self, project_dir): + def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch + # Only use the default catalog to prevent fetching the community catalog from the network + monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) From 4b4bd735a399a2104061a2ca5fae425cdc6e90f2 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:28:29 -0500 Subject: [PATCH 139/321] chore: bump version to 0.4.2 (#1973) * chore: bump version to 0.4.2 * chore: clean up CHANGELOG and fix release workflow --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/release-trigger.yml | 22 +- CHANGELOG.md | 1066 +++++++++++++++++++++---- pyproject.toml | 2 +- 3 files changed, 941 insertions(+), 149 deletions(-) diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index e5f62f1740..2b70d89e54 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -100,18 +100,16 @@ jobs: COMMITS="- Initial release" fi - # Create new changelog entry - { - head -n 8 CHANGELOG.md - echo "" - echo "## [${{ steps.version.outputs.version }}] - $DATE" - echo "" - echo "### Changes" - echo "" - echo "$COMMITS" - echo "" - tail -n +9 CHANGELOG.md - } > CHANGELOG.md.tmp + # Create new changelog entry — insert after the marker comment + NEW_ENTRY=$(printf '%s\n' \ + "" \ + "## [${{ steps.version.outputs.version }}] - $DATE" \ + "" \ + "### Changed" \ + "" \ + "$COMMITS") + + awk -v entry="$NEW_ENTRY" '// { print; print entry; next } {print}' CHANGELOG.md > CHANGELOG.md.tmp mv CHANGELOG.md.tmp CHANGELOG.md echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index ab78ca9492..cf55b42372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,36 @@ # Changelog + + +## [0.4.2] - 2026-03-25 + +### Changed + +- feat: Auto-register ai-skills for extensions whenever applicable (#1840) +- docs: add manual testing guide for slash command validation (#1955) +- Add AIDE, Extensify, and Presetify to community extensions (#1961) +- docs: add community presets section to main README (#1960) +- docs: move community extensions table to main README for discoverability (#1959) +- docs(readme): consolidate Community Friends sections and fix ToC anchors (#1958) +- fix(commands): rename NFR references to success criteria in analyze and clarify (#1935) +- Add Community Friends section to README (#1956) +- docs: add Community Friends section with Spec Kit Assistant VS Code extension (#1944) + ## [0.4.1] - 2026-03-24 -### Changes +### Changed - Add checkpoint extension (#1947) - fix(scripts): prioritize .specify over git for repo root detection (#1933) - docs: add AIDE extension demo to community projects (#1943) - fix(templates): add missing Assumptions section to spec template (#1939) -- chore: bump version to 0.4.1 (#1937) ## [0.4.0] - 2026-03-23 -### Changes +### Changed - fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) - fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) - - feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803) - ci: increase stale workflow operations-per-run to 250 (#1922) - docs: update publishing guide with Category and Effect columns (#1913) @@ -26,8 +40,12 @@ - docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910) - Add support for Junie (#1831) - feat: migrate Codex/agy init to native skills workflow (#1906) -- chore: bump version to 0.3.2 (#1909) +## [0.3.2] - 2026-03-19 + +### Changed + +- Add conduct extension to community catalog (#1908) - feat(extensions): add verify-tasks extension to community catalog (#1871) - feat(presets): add enable/disable toggle and update semantics (#1891) - feat: add iFlow CLI support (#1875) @@ -43,18 +61,10 @@ - Feature/spec kit add pi coding agent pullrequest (#1853) - feat: register spec-kit-learn extension (#1883) -## [0.3.2] - 2026-03-19 - -### Changes - -- chore: bump version to 0.3.2 -- Add conduct extension to community catalog (#1908) - ## [0.3.1] - 2026-03-17 ### Changed -- chore: bump version to 0.3.1 - docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) - fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867) - feat: add Trae IDE support as a new agent (#1817) @@ -74,7 +84,6 @@ ### Changed -- chore: bump version to 0.3.0 - feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787) - fix: match 'Last updated' timestamp with or without bold markers (#1836) - Add specify doctor command for project health diagnostics (#1828) @@ -97,44 +106,11 @@ - fix: use quiet checkout to avoid exception on git checkout (#1792) - feat(extensions): support .extensionignore to exclude files during install (#1781) - feat: add Codex support for extension command registration (#1767) -- chore: bump version to 0.2.0 (#1786) -- fix: sync agent list comments with actual supported agents (#1785) -- feat(extensions): support multiple active catalogs simultaneously (#1720) -- Pavel/add tabnine cli support (#1503) -- Add Understanding extension to community catalog (#1778) -- Add ralph extension to community catalog (#1780) -- Update README with project initialization instructions (#1772) -- feat: add review extension to community catalog (#1775) -- Add fleet extension to community catalog (#1771) -- Integration of Mistral vibe support into speckit (#1725) -- fix: Remove duplicate options in specify.md (#1765) -- fix: use global branch numbering instead of per-short-name detection (#1757) -- Add Community Walkthroughs section to README (#1766) -- feat(extensions): add Jira Integration to community catalog (#1764) -- Add Azure DevOps Integration extension to community catalog (#1734) -- Fix docs: update Antigravity link and add initialization example (#1748) -- fix: wire after_tasks and after_implement hook events into command templates (#1702) -- make c ignores consistent with c++ (#1747) -- chore: bump version to 0.1.13 (#1746) -- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) -- feat: add verify extension to community catalog (#1726) -- Add Retrospective Extension to community catalog README table (#1741) -- fix(scripts): add empty description validation and branch checkout error handling (#1559) -- fix: correct Copilot extension command registration (#1724) -- fix(implement): remove Makefile from C ignore patterns (#1558) -- Add sync extension to community catalog (#1728) -- fix(checklist): clarify file handling behavior for append vs create (#1556) -- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) -- chore: bump version to 0.1.12 (#1737) -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) -- fix: Split release process to sync pyproject.toml version with git tags (#1732) ## [0.2.0] - 2026-03-09 ### Changed -- feat: add Kimi Code CLI agent support - fix: sync agent list comments with actual supported agents (#1785) - feat(extensions): support multiple active catalogs simultaneously (#1720) - Pavel/add tabnine cli support (#1503) @@ -152,40 +128,6 @@ - Fix docs: update Antigravity link and add initialization example (#1748) - fix: wire after_tasks and after_implement hook events into command templates (#1702) - make c ignores consistent with c++ (#1747) -- chore: bump version to 0.1.13 (#1746) -- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) -- feat: add verify extension to community catalog (#1726) -- Add Retrospective Extension to community catalog README table (#1741) -- fix(scripts): add empty description validation and branch checkout error handling (#1559) -- fix: correct Copilot extension command registration (#1724) -- fix(implement): remove Makefile from C ignore patterns (#1558) -- Add sync extension to community catalog (#1728) -- fix(checklist): clarify file handling behavior for append vs create (#1556) -- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) -- chore: bump version to 0.1.12 (#1737) -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) -- fix: Split release process to sync pyproject.toml version with git tags (#1732) - -## [0.1.14] - 2026-03-09 - -### Added - -- feat: add Tabnine CLI agent support -- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack - - New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status - - New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management - - Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box - - `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog - - `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly - - Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence - - `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog) - - All catalog URLs require HTTPS (HTTP allowed for localhost development) - - New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation - - Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog - - Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs) - - 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement - - Updated RFC, Extension User Guide, and Extension API Reference documentation ## [0.1.13] - 2026-03-03 @@ -200,114 +142,966 @@ - Add sync extension to community catalog (#1728) - fix(checklist): clarify file handling behavior for append vs create (#1556) - fix(clarify): correct conflicting question limit from 10 to 5 (#1557) -- chore: bump version to 0.1.12 (#1737) -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) -- fix: Split release process to sync pyproject.toml version with git tags (#1732) +## [0.1.12] - 2026-03-02 -## [0.1.13] - 2026-03-03 +### Changed -### Fixed +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- **Copilot Extension Commands Not Visible**: Fixed extension commands not appearing in GitHub Copilot when installed via `specify extension add --dev` - - Changed Copilot file extension from `.md` to `.agent.md` in `CommandRegistrar.AGENT_CONFIGS` so Copilot recognizes agent files - - Added generation of companion `.prompt.md` files in `.github/prompts/` during extension command registration, matching the release packaging behavior - - Added cleanup of `.prompt.md` companion files when removing extensions via `specify extension remove` -- Fixed a syntax regression in `src/specify_cli/__init__.py` in `_build_ai_assistant_help()` that broke `ruff` and `pytest` collection in CI. -## [0.1.12] - 2026-03-02 +## [0.1.11] - 2026-03-02 ### Changed -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) +## [0.1.10] - 2026-02-27 -## [0.1.10] - 2026-03-02 - -### Fixed +### Changed -- **Version Sync Issue (#1721)**: Fixed version mismatch between `pyproject.toml` and git release tags - - Split release process into two workflows: `release-trigger.yml` for version management and `release.yml` for artifact building - - Version bump now happens BEFORE tag creation, ensuring tags point to commits with correct version - - Supports both manual version specification and auto-increment (patch version) - - Git tags now accurately reflect the version in `pyproject.toml` at that commit - - Prevents confusion when installing from source +- fix: prepend YAML frontmatter to Cursor .mdc files (#1699) ## [0.1.9] - 2026-02-28 ### Changed -- Updated dependency: bumped astral-sh/setup-uv from 6 to 7 +- chore(deps): bump astral-sh/setup-uv from 6 to 7 (#1709) ## [0.1.8] - 2026-02-28 ### Changed -- Updated dependency: bumped actions/setup-python from 5 to 6 +- chore(deps): bump actions/setup-python from 5 to 6 (#1710) ## [0.1.7] - 2026-02-27 ### Changed -- Updated outdated GitHub Actions versions -- Documented dual-catalog system for extensions - -### Fixed - -- Fixed version command in documentation - -### Added - -- Added Cleanup Extension to README -- Added retrospective extension to community catalog +- chore: Update outdated GitHub Actions versions (#1706) +- docs: Document dual-catalog system for extensions (#1689) +- Fix version command in documentation (#1685) +- Add Cleanup Extension to README (#1678) +- Add retrospective extension to community catalog (#1681) ## [0.1.6] - 2026-02-23 -### Fixed +### Changed -- **Parameter Ordering Issues (#1641)**: Fixed CLI parameter parsing issue where option flags were incorrectly consumed as values for preceding options - - Added validation to detect when `--ai` or `--ai-commands-dir` incorrectly consume following flags like `--here` or `--ai-skills` - - Now provides clear error messages: "Invalid value for --ai: '--here'" - - Includes helpful hints suggesting proper usage and listing available agents - - Commands like `specify init --ai-skills --ai --here` now fail with actionable feedback instead of confusing "Must specify project name" errors - - Added comprehensive test suite (5 new tests) to prevent regressions +- Add Cleanup Extension to catalog (#1617) +- Fix parameter ordering issues in CLI (#1669) +- Update V-Model Extension Pack to v0.4.0 (#1665) +- docs: Fix doc missing step (#1496) +- Update V-Model Extension Pack to v0.3.0 (#1661) ## [0.1.5] - 2026-02-21 -### Fixed +### Changed -- **AI Skills Installation Bug (#1658)**: Fixed `--ai-skills` flag not generating skill files for GitHub Copilot and other agents with non-standard command directory structures - - Added `commands_subdir` field to `AGENT_CONFIG` to explicitly specify the subdirectory name for each agent - - Affected agents now work correctly: copilot (`.github/agents/`), opencode (`.opencode/command/`), windsurf (`.windsurf/workflows/`), codex (`.codex/prompts/`), kilocode (`.kilocode/workflows/`), q (`.amazonq/prompts/`), and agy (`.agent/workflows/`) - - The `install_ai_skills()` function now uses the correct path for all agents instead of assuming `commands/` for everyone +- Fix #1658: Add commands_subdir field to support non-standard agent directory structures (#1660) +- feat: add GitHub issue templates (#1655) +- Update V-Model Extension Pack to v0.2.0 in community catalog (#1656) +- Add V-Model Extension Pack to catalog (#1640) +- refactor: remove OpenAPI/GraphQL bias from templates (#1652) ## [0.1.4] - 2026-02-20 -### Fixed +### Changed -- **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `"qoder"` to `"qodercli"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures +- fix: rename Qoder AGENT_CONFIG key from 'qoder' to 'qodercli' to match actual CLI executable (#1651) ## [0.1.3] - 2026-02-20 -### Added +### Changed + +- Add generic agent support with customizable command directories (#1639) + +## [0.1.2] - 2026-02-20 + +### Changed -- **Generic Agent Support**: Added `--ai generic` option for unsupported AI agents ("bring your own agent") - - Requires `--ai-commands-dir ` to specify where the agent reads commands from - - Generates Markdown commands with `$ARGUMENTS` format (compatible with most agents) - - Example: `specify init my-project --ai generic --ai-commands-dir .myagent/commands/` - - Enables users to start with Spec Kit immediately while their agent awaits formal support +- fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation crash (#1648) ## [0.0.102] - 2026-02-20 +### Changed + - fix: include 'src/**' path in release workflow triggers (#1646) ## [0.0.101] - 2026-02-19 +### Changed + - chore(deps): bump github/codeql-action from 3 to 4 (#1635) ## [0.0.100] - 2026-02-19 +### Changed + - Add pytest and Python linting (ruff) to CI (#1637) - feat: add pull request template for better contribution guidelines (#1634) + +## [0.0.99] - 2026-02-19 + +### Changed + +- Feat/ai skills (#1632) + +## [0.0.98] - 2026-02-19 + +### Changed + +- chore(deps): bump actions/stale from 9 to 10 (#1623) +- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) + +## [0.0.97] - 2026-02-18 + +### Changed + +- Remove Maintainers section from README.md (#1618) + +## [0.0.96] - 2026-02-17 + +### Changed + +- fix: typo in plan-template.md (#1446) + +## [0.0.95] - 2026-02-12 + +### Changed + +- Feat: add a new agent: Google Anti Gravity (#1220) + +## [0.0.94] - 2026-02-11 + +### Changed + +- Add stale workflow for 180-day inactive issues and PRs (#1594) + +## [0.0.93] - 2026-02-10 + +### Changed + +- Add modular extension system (#1551) + +## [0.0.92] - 2026-02-10 + +### Changed + +- Fixes #1586 - .specify.specify path error (#1588) + +## [0.0.91] - 2026-02-09 + +### Changed + +- fix: preserve constitution.md during reinitialization (#1541) (#1553) +- fix: resolve markdownlint errors across documentation (#1571) + +## [0.0.90] - 2025-12-04 + +### Changed + +- Update Markdown formatting +- Update Markdown formatting +- docs: Add existing project initialization to getting started + +## [0.0.89] - 2025-12-02 + +### Changed + +- Update scripts/bash/create-new-feature.sh +- fix(scripts): prevent octal interpretation in feature number parsing +- fix: remove unused short_name parameter from branch numbering functions +- Update scripts/powershell/create-new-feature.ps1 +- Update scripts/bash/create-new-feature.sh +- fix: use global maximum for branch numbering to prevent collisions + +## [0.0.88] - 2025-12-01 + +### Changed + +- fix the incorrect task-template file path + +## [0.0.87] - 2025-12-01 + +### Changed + +- Limit width and height to 200px to match the small logo +- docs: Switch readme logo to logo_large.webp +- fix:merge +- fix +- fix +- feat:qoder agent +- docs: Enhance quickstart guide with admonitions and examples +- docs: add constitution step to quickstart guide (fixes #906) +- Update supported AI agents in README.md +- cancel:test +- test +- fix:literal bug +- fix:test +- test +- fix:qoder url +- fix:download owner +- test +- feat:support Qoder CLI + +## [0.0.86] - 2025-11-26 + +### Changed + +- feat: add bob to new update-agent-context.ps1 + consistency in comments +- feat: add support for IBM Bob IDE + +## [0.0.85] - 2025-11-14 + +### Changed + +- Unset CDPATH while getting SCRIPT_DIR + +## [0.0.84] - 2025-11-14 + +### Changed + +- docs: fix broken link and improve agent reference +- docs: reorganize upgrade documentation structure +- docs: remove related documentation section from upgrading guide +- fix: remove broken link to existing project guide +- docs: Add comprehensive upgrading guide for Spec Kit +- Refactor ESLint configuration checks in implement.md to address deprecation + +## [0.0.83] - 2025-11-14 + +### Changed + +- feat: Add OVHcloud SHAI AI Agent + +## [0.0.82] - 2025-11-14 + +### Changed + +- fix: incorrect logic to create release packages with subset AGENTS or SCRIPTS + +## [0.0.81] - 2025-11-14 + +### Changed + +- Fix tasktoissues.md to use the 'github/github-mcp-server/issue_write' tool + +## [0.0.80] - 2025-11-14 + +### Changed + +- Refactor feature script logic and update agent context scripts +- Update templates/commands/taskstoissues.md +- Update CHANGELOG.md +- Update agent configuration +- Update scripts/powershell/create-new-feature.ps1 +- Update src/specify_cli/__init__.py +- Create create-release-packages.ps1 +- Script changes +- Update taskstoissues.md +- Create taskstoissues.md +- Update src/specify_cli/__init__.py +- Update CONTRIBUTING.md +- Potential fix for code scanning alert no. 3: Workflow does not contain permissions +- Update src/specify_cli/__init__.py +- Update CHANGELOG.md +- Fixes #970 +- Fixes #975 +- Support for version command +- Exclude generated releases +- Lint fixes +- Prompt updates +- Hand offs with prompts +- Chatmodes are back in vogue +- Let's switch to proper prompts +- Update prompts +- Update with prompt +- Testing hand-offs +- Use VS Code handoffs + +## [0.0.79] - 2025-10-23 + +### Changed + +- docs: restore important note about JSON output in specify command +- fix: improve branch number detection to check all sources +- feat: check remote branches to prevent duplicate branch numbers + +## [0.0.78] - 2025-10-21 + +### Changed + +- Update CONTRIBUTING.md +- docs: add steps for testing template and command changes locally +- update specify to make "short-name" argu for create-new-feature.sh in the right position + +## [0.0.77] - 2025-10-21 + +### Changed + +- fix: include the latest changelog in the `GitHub Release`'s body + +## [0.0.76] - 2025-10-21 + +### Changed + +- Fix update-agent-context.sh to handle files without Active Technologies/Recent Changes sections + +## [0.0.75] - 2025-10-21 + +### Changed + +- Fixed indentation. +- Added correct `install_url` for Amp agent CLI script. +- Added support for Amp code agent. + +## [0.0.74] - 2025-10-21 + +### Changed + +- feat(ci): add markdownlint-cli2 for consistent markdown formatting + +## [0.0.73] - 2025-10-21 + +### Changed + +- revert vscode auto remove extra space +- fix: correct command references in implement.md +- fix regarding copilot suggestion +- fix: correct command references in speckit.analyze.md +- Support more lang/Devops of Common Patterns by Technology +- chore: replace `bun` by `node/npm` in the `devcontainer` (as many CLI-based agents actually require a `node` runtime) +- chore: add Claude Code extension to devcontainer configuration +- chore: add installation of `codebuddy` CLI in the `devcontainer` +- chore: fix path to powershell script in vscode settings +- fix: correct `run_command` exit behavior and improve installation instructions (for `Amazon Q`) in `post-create.sh` + fix typos in `CONTRIBUTING.md` +- chore: add `specify`'s github copilot chat settings to `devcontainer` +- chore: add `devcontainer` support to ease developer workstation setup + +## [0.0.72] - 2025-10-18 + +### Changed + +- fix: correct argument parsing in create-new-feature.sh script + +## [0.0.71] - 2025-10-18 + +### Changed + +- fix: Skip CLI checks for IDE-based agents in check command +- Change loop condition to include last argument + +## [0.0.70] - 2025-10-18 + +### Changed + +- fix: broken media files +- Update README.md +- The function parameters lack type hints. Consider adding type annotations for better code clarity and IDE support. +- - **Smart JSON Merging for VS Code Settings**: `.vscode/settings.json` is now intelligently merged instead of being overwritten during `specify init --here` or `specify init .` - Existing settings are preserved - New Spec Kit settings are added - Nested objects are merged recursively - Prevents accidental loss of custom VS Code workspace configurations +- Fix: incorrect command formatting in agent context file, refix #895 + +## [0.0.69] - 2025-10-15 + +### Changed + +- Update scripts/bash/create-new-feature.sh +- Update create-new-feature.sh +- Update files +- Update files +- Create .gitattributes +- Update wording +- Update logic for arguments +- Update script logic + +## [0.0.68] - 2025-10-15 + +### Changed + +- format content as copilot suggest +- Ruby, PHP, Rust, Kotlin, C, C++ + +## [0.0.67] - 2025-10-15 + +### Changed + +- Use the number prefix to find the right spec + +## [0.0.66] - 2025-10-15 + +### Changed + +- Update CodeBuddy agent name to 'CodeBuddy CLI' +- Rename CodeBuddy to CodeBuddy CLI in update script +- Update AI coding agent references in installation guide +- Rename CodeBuddy to CodeBuddy CLI in AGENTS.md +- Update README.md +- Update CodeBuddy link in README.md +- update codebuddyCli + +## [0.0.65] - 2025-10-15 + +### Changed + +- Fix: Fix incorrect command formatting in agent context file +- docs: fix heading capitalization for consistency +- Update README.md + +## [0.0.64] - 2025-10-14 + +### Changed + +- Update tasks.md +- Update README.md + +## [0.0.63] - 2025-10-14 + +### Changed + +- fix: update CODEBUDDY file path in agent context scripts +- docs(readme): add /speckit.tasks step and renumber walkthrough + +## [0.0.62] - 2025-10-11 + +### Changed + +- A few more places to update from code review +- fix: align Cursor agent naming to use 'cursor-agent' consistently + +## [0.0.61] - 2025-10-10 + +### Changed + +- Update clarify.md +- add how to upgrade specify installation + +## [0.0.60] - 2025-10-10 + +### Changed + +- Update vscode-settings.json +- Update instructions and bug fix + +## [0.0.59] - 2025-10-10 + +### Changed + +- Update __init__.py +- Consolidate Cursor naming +- Update CHANGELOG.md +- Git errors are now highlighted. +- Update __init__.py +- Refactor agent configuration +- Update src/specify_cli/__init__.py +- Update scripts/powershell/update-agent-context.ps1 +- Update AGENTS.md +- Update templates/commands/implement.md +- Update templates/commands/implement.md +- Update CHANGELOG.md +- Update changelog +- Update plan.md +- Add ignore file verification step to /speckit.implement command +- Escape backslashes in TOML outputs +- update CodeBuddy to international site +- feat: support codebuddy ai +- feat: support codebuddy ai + +## [0.0.58] - 2025-10-08 + +### Changed + +- Add escaping guidelines to command templates +- Update README.md +- Update README.md + +## [0.0.57] - 2025-10-06 + +### Changed + +- Update CHANGELOG.md +- Update command reference +- Package up VS Code settings for Copilot +- Update tasks-template.md +- Update templates/tasks-template.md +- Cleanup +- Update CLI changes +- Update template and docs +- Update checklist.md +- Update templates +- Cleanup redundancies +- Update checklist.md +- Codex CLI is now fully supported +- Update specify.md +- Prompt updates +- Update prompt prefix +- Update .github/workflows/scripts/create-release-packages.sh +- Consistency updates to commands +- Update commands. +- Update logs +- Template cleanup and reorganization +- Remove Codex named args limitation warning +- Remove Codex named args limitation from README.md + +## [0.0.56] - 2025-10-02 + +### Changed + +- docs(readme): link Amazon Q slash command limitation issue +- docs: clarify Amazon Q limitation and update init docstring +- feat(agent): Added Amazon Q Developer CLI Integration + +## [0.0.55] - 2025-09-30 + +### Changed + +- Update URLs to Contributing and Support Guides in Docs +- fix: add UTF-8 encoding to file read/write operations in update-agent-context.ps1 +- Update __init__.py +- Update src/specify_cli/__init__.py +- docs: fix the paths of generated files (moved under a `.specify/` folder) +- Update src/specify_cli/__init__.py +- feat: support 'specify init .' for current directory initialization +- feat: Add emacs-style up/down keys + +## [0.0.54] - 2025-09-25 + +### Changed + +- Update CONTRIBUTING.md +- Refine `plan-template.md` with improved project type detection, clarified structure decision process, and enhanced research task guidance. +- Update __init__.py + +## [0.0.53] - 2025-09-24 + +### Changed + +- Update template path for spec file creation +- Update template path for spec file creation +- docs: remove constitution_update_checklist from README + +## [0.0.52] - 2025-09-22 + +### Changed + +- Update analyze.md +- Update templates/commands/analyze.md +- Update templates/commands/clarify.md +- Update templates/commands/plan.md +- Update with extra commands +- Update with --force flag +- feat: add uv tool install instructions to README + +## [0.0.51] - 2025-09-21 + +### Changed + +- Update with Roo Code support + +## [0.0.50] - 2025-09-21 + +### Changed + +- Update generate-release-notes.sh +- Update error messages +- Auggie folder fix + +## [0.0.49] - 2025-09-21 + +### Changed + +- Update scripts/powershell/update-agent-context.ps1 +- Update templates/commands/implement.md +- Cleanup the check command +- Add support for Auggie +- Update AGENTS.md +- Updates with Kilo Code support +- Update README.md +- Update templates/commands/constitution.md +- Update templates/commands/implement.md +- Update templates/commands/plan.md +- Update templates/commands/specify.md +- Update templates/commands/tasks.md +- Update README.md +- Stop splitting the warning over multiple lines +- Update templates based on #419 +- docs: Update README with codex in check command + +## [0.0.48] - 2025-09-21 + +### Changed + +- Update scripts/powershell/check-prerequisites.ps1 +- Update CHANGELOG.md +- Update CHANGELOG.md +- Update changelog +- Update scripts/bash/update-agent-context.sh +- Fix script config +- Update scripts/bash/common.sh +- Update scripts/powershell/update-agent-context.ps1 +- Update scripts/powershell/update-agent-context.ps1 +- Clarification +- Update prompts +- Update update-agent-context.ps1 +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update contribution guidelines. +- Root detection logic +- Update templates/plan-template.md +- Update scripts/bash/update-agent-context.sh +- Update scripts/powershell/create-new-feature.ps1 +- Simplification +- Script and template tweaks +- Update config +- Update scripts/powershell/check-prerequisites.ps1 +- Update scripts/bash/check-prerequisites.sh +- Fix script path +- Script cleanup +- Update scripts/bash/check-prerequisites.sh +- Update scripts/powershell/check-prerequisites.ps1 +- Update script delegation from GitHub Action +- Cleanup the setup for generated packages +- Use proper line endings +- Consolidate scripts + +## [0.0.47] - 2025-09-20 + +### Changed + +- Updating agent context files + +## [0.0.46] - 2025-09-20 + +### Changed + +- Update update-agent-context.ps1 +- Update package release +- Update config +- Update __init__.py +- Update __init__.py +- Remove Codex-specific logic in the initialization script +- Update version rev +- Update __init__.py +- Enhance Codex support by auto-syncing prompt files, allowing spec generation without git, and documenting clearer /specify usage. +- Consistency tweaks +- Consistent step coloring +- Update __init__.py +- Update __init__.py +- Quick UI tweak +- Update package release +- Limit workspace command seeding to Codex init and update Codex documentation accordingly. +- Clarify Codex-specific README note with rationale for its different workflow. +- Bump to 0.0.7 and document Codex support +- Normalize Codex command templates to the scripts-based schema and auto-upgrade generated commands. +- Fix remaining merge conflict markers in __init__.py +- Add Codex CLI support with AGENTS.md and commands bootstrap + +## [0.0.45] - 2025-09-19 + +### Changed + +- Update with Windsurf support +- expose token as an argument through cli --github-token +- add github auth headers if there are GITHUB_TOKEN/GH_TOKEN set + +## [0.0.44] - 2025-09-18 + +### Changed + +- Update specify.md +- Update __init__.py + +## [0.0.43] - 2025-09-18 + +### Changed + +- Update with support for /implement + +## [0.0.42] - 2025-09-18 + +### Changed + +- Update constitution.md + +## [0.0.41] - 2025-09-18 + +### Changed + +- Update constitution.md + +## [0.0.40] - 2025-09-18 + +### Changed + +- Update constitution command + +## [0.0.39] - 2025-09-18 + +### Changed + +- Cleanup +- fix: commands format for qwen + +## [0.0.38] - 2025-09-18 + +### Changed + +- Fix template path in update-agent-context.sh +- docs: fix grammar mistakes in markdown files + +## [0.0.37] - 2025-09-17 + +### Changed + +- fix: add missing Qwen support to release workflow and agent scripts + +## [0.0.36] - 2025-09-17 + +### Changed + +- feat: Add opencode ai agent +- Fix --no-git argument resolution. + +## [0.0.35] - 2025-09-17 + +### Changed + +- chore(release): bump version to 0.0.5 and update changelog +- chore: address review feedback - remove comment and fix numbering +- feat: add Qwen Code support to Spec Kit + +## [0.0.34] - 2025-09-15 + +### Changed + +- Update template. + +## [0.0.33] - 2025-09-15 + +### Changed + +- Update scripts + +## [0.0.32] - 2025-09-15 + +### Changed + +- Update template paths + +## [0.0.31] - 2025-09-15 + +### Changed + +- Update for Cursor rules & script path +- Update Specify definition +- Update README.md +- Update with video header +- fix(docs): remove redundant white space + +## [0.0.30] - 2025-09-12 + +### Changed + +- Update update-agent-context.ps1 + +## [0.0.29] - 2025-09-12 + +### Changed + +- Update create-release-packages.sh +- Update with check changes + +## [0.0.28] - 2025-09-12 + +### Changed + +- Update wording +- Update release.yml + +## [0.0.27] - 2025-09-12 + +### Changed + +- Support Cursor + +## [0.0.26] - 2025-09-12 + +### Changed + +- Saner approach to scripts + +## [0.0.25] - 2025-09-12 + +### Changed + +- Update packaging + +## [0.0.24] - 2025-09-12 + +### Changed + +- Fix package logic + +## [0.0.23] - 2025-09-12 + +### Changed + +- Update config +- Update __init__.py +- Refactor with platform-specific constraints +- Update README.md +- Update CLI reference +- Update __init__.py +- refactor: extract Claude local path to constant for maintainability +- fix: support Claude CLI installed via migrate-installer + +## [0.0.22] - 2025-09-11 + +### Changed + +- Update release.yml +- Update create-release-packages.sh +- Update create-release-packages.sh +- Update release file + +## [0.0.21] - 2025-09-11 + +### Changed + +- Consolidate script creation +- Update how Copilot prompts are created +- Update local-development.md +- Local dev guide and script updates +- Update CONTRIBUTING.md +- Enhance HTTP client initialization with optional SSL verification and bump version to 0.0.3 +- Complete Gemini CLI command instructions +- Refactor HTTP client usage to utilize truststore for SSL context +- docs: Update Commands sections renaming to match implementation +- docs: Fix formatting issues in README.md for consistency +- Update docs and release + +## [0.0.20] - 2025-09-08 + +### Changed + +- Update docs/quickstart.md +- Docs setup + +## [0.0.19] - 2025-09-08 + +### Changed + +- Update README.md + +## [0.0.18] - 2025-09-08 + +### Changed + +- Update README.md + +## [0.0.17] - 2025-09-08 + +### Changed + +- Remove trailing whitespace from tasks.md template + +## [0.0.16] - 2025-09-07 + +### Changed + +- Fix release workflow to work with repository rules + +## [0.0.15] - 2025-09-07 + +### Changed + +- Use `/usr/bin/env bash` instead of `/bin/bash` for shebang + +## [0.0.14] - 2025-09-04 + +### Changed + +- fix: correct typos in spec-driven.md + +## [0.0.13] - 2025-09-04 + +### Changed + +- Fix formatting in usage instructions + +## [0.0.12] - 2025-09-04 + +### Changed + +- Fix template path in plan command documentation + +## [0.0.11] - 2025-09-04 + +### Changed + +- fix: incorrect tree structure in examples + +## [0.0.10] - 2025-09-04 + +### Changed + +- fix minor typo in Article I + +## [0.0.9] - 2025-09-03 + +### Changed + +- Update CLI commands from '/spec' to '/specify' + +## [0.0.8] - 2025-09-02 + +### Changed + +- adding executable permission to the scripts so they execute when the coding agent launches them + +## [0.0.7] - 2025-09-02 + +### Changed + +- doco(spec-driven): Fix small typo in document + +## [0.0.6] - 2025-08-25 + +### Changed + +- Update README.md + +## [0.0.5] - 2025-08-25 + +### Changed + +- Update .github/workflows/release.yml +- Fix release workflow to work with repository rules + +## [0.0.4] - 2025-08-25 + +### Changed + +- Add John Lam as contributor and release badge + +## [0.0.3] - 2025-08-22 + +### Changed + +- Update requirements + +## [0.0.2] - 2025-08-22 + +### Changed + +- Update README.md + +## [0.0.1] - 2025-08-22 + +### Changed + +- Update release.yml + diff --git a/pyproject.toml b/pyproject.toml index de12614c06..a7b27109a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.1" +version = "0.4.2" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 2c2fea8783f33085652b8c87e839bae84a6eb78d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:54:49 -0500 Subject: [PATCH 140/321] fix(ps1): replace null-conditional operator for PowerShell 5.1 compatibility (#1975) The `?.` (null-conditional member access) operator requires PowerShell 7.1+, but Windows ships with PowerShell 5.1 by default. When AI agents invoke .ps1 scripts on Windows, they typically use the system-associated handler (5.1), causing a ParseException: Unexpected token '?.Path'. Replace the single `?.` usage with a 5.1-compatible two-step pattern that preserves the same null-safety behavior. Fixes #1972 --- scripts/powershell/common.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index c670977736..7a96d3fac8 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -8,7 +8,8 @@ function Find-SpecifyRoot { # Normalize to absolute path to prevent issues with relative paths # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?) - $current = (Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue)?.Path + $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue + $current = if ($resolved) { $resolved.Path } else { $null } if (-not $current) { return $null } while ($true) { From ccc44dd00aa9e075ea92ec573f31dd06560cce79 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 26 Mar 2026 23:53:30 +0800 Subject: [PATCH 141/321] Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971) * fix: unify hyphenated skills and migrate legacy kimi dotted dirs * fix: preserve legacy kimi dotted preset skill overrides * fix: migrate kimi legacy dotted skills without ai-skills flag * fix: harden kimi migration and cache hook init options * fix: apply kimi preset skill overrides without ai-skills flag * fix: keep sequential branch numbering beyond 999 * test: align kimi scaffold skill path with hyphen naming * chore: align hook typing and preset skill comment * fix: restore AGENT_SKILLS_DIR_OVERRIDES compatibility export * refactor: remove AGENT_SKILLS_DIR_OVERRIDES and update callers * fix(ps1): support sequential branch numbers above 999 * fix: resolve preset skill placeholders for skills agents * Fix legacy kimi migration safety and preset skill dir checks * Harden TOML rendering and consolidate preset skill restore parsing * Fix PowerShell overflow and hook message fallback for empty invocations * Restore preset skills from extensions * Refine preset skill restore helpers * Harden skill path and preset checks * Guard non-dict init options * Avoid deleting unmanaged preset skill dirs * Unify extension skill naming with hooks * Harden extension native skill registration * Normalize preset skill titles --- .../scripts/create-release-packages.ps1 | 5 +- .../scripts/create-release-packages.sh | 5 +- scripts/bash/create-new-feature.sh | 12 +- scripts/powershell/create-new-feature.ps1 | 26 +- src/specify_cli/__init__.py | 104 +++-- src/specify_cli/agents.py | 85 +++- src/specify_cli/extensions.py | 127 ++++-- src/specify_cli/presets.py | 272 +++++++++---- tests/test_ai_skills.py | 112 +++++- tests/test_core_pack_scaffold.py | 2 +- tests/test_extension_skills.py | 197 +++++++--- tests/test_extensions.py | 271 ++++++++++++- tests/test_presets.py | 368 +++++++++++++++++- tests/test_timestamp_branches.py | 19 + 14 files changed, 1356 insertions(+), 249 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 8f3cfec36b..5bd600e5ff 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -202,8 +202,7 @@ agent: $basename } # Create skills in \\SKILL.md format. -# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the -# current dotted-name exception (e.g. speckit.plan). +# Skills use hyphenated names (e.g. speckit-plan). # # Technical debt note: # Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension @@ -463,7 +462,7 @@ function Build-Variant { 'kimi' { $skillsDir = Join-Path $baseDir ".kimi/skills" New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.' + New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' } 'trae' { $rulesDir = Join-Path $baseDir ".trae/rules" diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index d07e4a2df2..a83494c3a0 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -140,8 +140,7 @@ EOF } # Create skills in //SKILL.md format. -# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the -# current dotted-name exception (e.g. speckit.plan). +# Skills use hyphenated names (e.g. speckit-plan). # # Technical debt note: # Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension @@ -321,7 +320,7 @@ build_variant() { generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; kimi) mkdir -p "$base_dir/.kimi/skills" - create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;; + create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;; trae) mkdir -p "$base_dir/.trae/rules" generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 579d347523..a393edd320 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -89,9 +89,9 @@ get_highest_from_specs() { for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") - # Only match sequential prefixes (###-*), skip timestamp dirs - if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then - number=$(echo "$dirname" | grep -o '^[0-9]\{3\}') + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number @@ -115,9 +115,9 @@ get_highest_from_branches() { # Clean branch name: remove leading markers and remote prefixes clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') - # Extract feature number if branch matches pattern ###-* - if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then - number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + # Extract sequential feature number (>=3 digits), skip timestamp branches. + if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0") number=$((10#$number)) if [ "$number" -gt "$highest" ]; then highest=$number diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 9adae131d5..b1ca0ac82a 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -5,7 +5,7 @@ param( [switch]$Json, [string]$ShortName, [Parameter()] - [int]$Number = 0, + [long]$Number = 0, [switch]$Timestamp, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -48,12 +48,15 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) { function Get-HighestNumberFromSpecs { param([string]$SpecsDir) - $highest = 0 + [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } } } } @@ -63,7 +66,7 @@ function Get-HighestNumberFromSpecs { function Get-HighestNumberFromBranches { param() - $highest = 0 + [long]$highest = 0 try { $branches = git branch -a 2>$null if ($LASTEXITCODE -eq 0) { @@ -71,10 +74,12 @@ function Get-HighestNumberFromBranches { # Clean branch name: remove leading markers and remote prefixes $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - # Extract feature number if branch matches pattern ###-* - if ($cleanBranch -match '^(\d{3})-') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } + # Extract sequential feature number (>=3 digits), skip timestamp branches. + if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } } } } @@ -290,4 +295,3 @@ if ($Json) { Write-Output "HAS_GIT: $hasGit" Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } - diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d78609ad66..1f0eaf475d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1490,12 +1490,6 @@ def load_init_options(project_path: Path) -> dict[str, Any]: return {} -# Agent-specific skill directory overrides for agents whose skills directory -# doesn't follow the standard /skills/ pattern -AGENT_SKILLS_DIR_OVERRIDES = { - "codex": ".agents/skills", # Codex agent layout override -} - # Default skills directory for agents not in AGENT_CONFIG DEFAULT_SKILLS_DIR = ".agents/skills" @@ -1528,13 +1522,9 @@ def load_init_options(project_path: Path) -> dict[str, Any]: def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory for the given AI assistant. - Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to - ``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to - ``DEFAULT_SKILLS_DIR``. + Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to + ``DEFAULT_SKILLS_DIR`` for unknown agents. """ - if selected_ai in AGENT_SKILLS_DIR_OVERRIDES: - return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai] - agent_config = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: @@ -1648,10 +1638,7 @@ def install_ai_skills( command_name = command_name[len("speckit."):] if command_name.endswith(".agent"): command_name = command_name[:-len(".agent")] - if selected_ai == "kimi": - skill_name = f"speckit.{command_name}" - else: - skill_name = f"speckit-{command_name}" + skill_name = f"speckit-{command_name.replace('.', '-')}" # Create skill directory (additive — never removes existing content) skill_dir = skills_dir / skill_name @@ -1730,8 +1717,64 @@ def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool: if not skills_dir.is_dir(): return False - pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md" - return any(skills_dir.glob(pattern)) + return any(skills_dir.glob("speckit-*/SKILL.md")) + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + Temporary migration helper: + - Intended removal window: after 2026-06-25. + - Purpose: one-time cleanup for projects initialized before Kimi moved to + hyphenated skills (speckit-xxx). + + Returns: + Tuple[migrated_count, removed_count] + - migrated_count: old dotted dir renamed to hyphenated dir + - removed_count: old dotted dir deleted when equivalent hyphenated dir existed + """ + if not skills_dir.is_dir(): + return (0, 0) + + migrated_count = 0 + removed_count = 0 + + for legacy_dir in sorted(skills_dir.glob("speckit.*")): + if not legacy_dir.is_dir(): + continue + if not (legacy_dir / "SKILL.md").exists(): + continue + + suffix = legacy_dir.name[len("speckit."):] + if not suffix: + continue + + target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + + if not target_dir.exists(): + shutil.move(str(legacy_dir), str(target_dir)) + migrated_count += 1 + continue + + # If the new target already exists, avoid destructive cleanup unless + # both SKILL.md files are byte-identical. + target_skill = target_dir / "SKILL.md" + legacy_skill = legacy_dir / "SKILL.md" + if target_skill.is_file(): + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + # Preserve legacy directory when it contains extra user files. + has_extra_entries = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra_entries: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + # Best-effort migration: preserve legacy dir on read failures. + pass + + return (migrated_count, removed_count) AGENT_SKILLS_MIGRATIONS = { @@ -2094,16 +2137,33 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + # Determine skills directory and migrate any legacy Kimi dotted skills. + migrated_legacy_kimi_skills = 0 + removed_legacy_kimi_skills = 0 + skills_dir: Optional[Path] = None + if selected_ai in NATIVE_SKILLS_AGENTS: + skills_dir = _get_skills_dir(project_path, selected_ai) + if selected_ai == "kimi" and skills_dir.is_dir(): + ( + migrated_legacy_kimi_skills, + removed_legacy_kimi_skills, + ) = _migrate_legacy_kimi_dotted_skills(skills_dir) + if ai_skills: if selected_ai in NATIVE_SKILLS_AGENTS: - skills_dir = _get_skills_dir(project_path, selected_ai) bundled_found = _has_bundled_skills(project_path, selected_ai) if bundled_found: + detail = f"bundled skills → {skills_dir.relative_to(project_path)}" + if migrated_legacy_kimi_skills or removed_legacy_kimi_skills: + detail += ( + f" (migrated {migrated_legacy_kimi_skills}, " + f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)" + ) if tracker: tracker.start("ai-skills") - tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}") + tracker.complete("ai-skills", detail) else: - console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/") + console.print(f"[green]✓[/green] Using {detail}") else: # Compatibility fallback: convert command templates to skills # when an older template archive does not include native skills. @@ -2288,7 +2348,7 @@ def _display_cmd(name: str) -> str: if codex_skill_mode: return f"$speckit-{name}" if kimi_skill_mode: - return f"/skill:speckit.{name}" + return f"/skill:speckit-{name}" return f"/speckit.{name}" steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 7fe5316066..64617e8431 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -10,6 +10,8 @@ from typing import Dict, List, Any import platform +import re +from copy import deepcopy import yaml @@ -211,24 +213,52 @@ def render_frontmatter(fm: dict) -> str: return f"---\n{yaml_str}---\n" def _adjust_script_paths(self, frontmatter: dict) -> dict: - """Adjust script paths from extension-relative to repo-relative. + """Normalize script paths in frontmatter to generated project locations. + + Rewrites known repo-relative and top-level script paths under the + `scripts` and `agent_scripts` keys (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 adjusted paths + Modified frontmatter with normalized project paths """ + frontmatter = deepcopy(frontmatter) + for script_key in ("scripts", "agent_scripts"): scripts = frontmatter.get(script_key) if not isinstance(scripts, dict): continue for key, script_path in scripts.items(): - if isinstance(script_path, str) and script_path.startswith("../../scripts/"): - scripts[key] = f".specify/scripts/{script_path[14:]}" + 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//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, @@ -277,9 +307,25 @@ def render_toml_command( toml_lines.append(f"# Source: {source_id}") toml_lines.append("") - toml_lines.append('prompt = """') - toml_lines.append(body) - 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: + escaped_body = ( + body.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + toml_lines.append(f'prompt = "{escaped_body}"') return "\n".join(toml_lines) @@ -308,8 +354,8 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} - if agent_name == "codex": - body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root) + if agent_name in {"codex", "kimi"}: + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") skill_frontmatter = { @@ -324,13 +370,8 @@ def render_skill_command( return self.render_frontmatter(skill_frontmatter) + "\n" + body @staticmethod - def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str: - """Resolve script placeholders for Codex skill overrides. - - This intentionally scopes the fix to Codex, which is the newly - migrated runtime path in this PR. Existing Kimi behavior is left - unchanged for now. - """ + 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: @@ -346,7 +387,11 @@ def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root if not isinstance(agent_scripts, dict): agent_scripts = {} - script_variant = load_init_options(project_root).get("script") + 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" @@ -376,7 +421,8 @@ def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{AGENT_SCRIPT}", agent_script_command) - return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex") + body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) + 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. @@ -400,8 +446,9 @@ def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, 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}" if agent_name == "kimi" else f"speckit-{short_name}" + return f"speckit-{short_name}" def register_commands( self, diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d71480ac47..ed2d187ba4 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -511,24 +511,32 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]: return _ignore def _get_skills_dir(self) -> Optional[Path]: - """Return the skills directory if ``--ai-skills`` was used during init. + """Return the active skills directory for extension skill registration. Reads ``.specify/init-options.json`` to determine whether skills are enabled and which agent was selected, then delegates to the module-level ``_get_skills_dir()`` helper for the concrete path. + Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and + ``.kimi/skills`` exists, extension installs should still propagate + command skills even when ``ai_skills`` is false. + Returns: The skills directory ``Path``, or ``None`` if skills were not - enabled or the init-options file is missing. + enabled and no native-skills fallback applies. """ from . import load_init_options, _get_skills_dir as resolve_skills_dir opts = load_init_options(self.project_root) - if not opts.get("ai_skills"): - return None + if not isinstance(opts, dict): + opts = {} agent = opts.get("ai") - if not agent: + if not isinstance(agent, str) or not agent: + return None + + ai_skills_enabled = bool(opts.get("ai_skills")) + if not ai_skills_enabled and agent != "kimi": return None skills_dir = resolve_skills_dir(self.project_root, agent) @@ -561,12 +569,17 @@ def _register_extension_skills( return [] from . import load_init_options + from .agents import CommandRegistrar import yaml - opts = load_init_options(self.project_root) - selected_ai = opts.get("ai", "") - written: List[str] = [] + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + opts = {} + selected_ai = opts.get("ai") + if not isinstance(selected_ai, str) or not selected_ai: + return [] + registrar = CommandRegistrar() for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -587,17 +600,12 @@ def _register_extension_skills( if not source_file.is_file(): continue - # Derive skill name from command name, matching the convention used by - # presets.py: strip the leading "speckit." prefix, then form: - # Kimi → "speckit.{short_name}" (dot preserved for Kimi agent) - # other → "speckit-{short_name}" (hyphen separator) + # Derive skill name from command name using the same hyphenated + # convention as hook rendering and preset skill registration. short_name_raw = cmd_name if short_name_raw.startswith("speckit."): short_name_raw = short_name_raw[len("speckit."):] - if selected_ai == "kimi": - skill_name = f"speckit.{short_name_raw}" - else: - skill_name = f"speckit-{short_name_raw}" + skill_name = f"speckit-{short_name_raw.replace('.', '-')}" # Check if skill already exists before creating the directory skill_subdir = skills_dir / skill_name @@ -621,22 +629,11 @@ def _register_extension_skills( except OSError: pass # best-effort cleanup continue - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - try: - frontmatter = yaml.safe_load(parts[1]) - except yaml.YAMLError: - frontmatter = {} - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - frontmatter = {} - body = content - else: - frontmatter = {} - body = content + frontmatter, body = registrar.parse_frontmatter(content) + frontmatter = registrar._adjust_script_paths(frontmatter) + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" @@ -738,11 +735,9 @@ def _unregister_extension_skills(self, skill_names: List[str], extension_id: str shutil.rmtree(skill_subdir) else: # Fallback: scan all possible agent skills directories - from . import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR candidate_dirs: set[Path] = set() - for override_path in AGENT_SKILLS_DIR_OVERRIDES.values(): - candidate_dirs.add(self.project_root / override_path) for cfg in AGENT_CONFIG.values(): folder = cfg.get("folder", "") if folder: @@ -1940,6 +1935,52 @@ def __init__(self, project_root: Path): self.project_root = project_root self.extensions_dir = project_root / ".specify" / "extensions" self.config_file = project_root / ".specify" / "extensions.yml" + self._init_options_cache: Optional[Dict[str, Any]] = None + + def _load_init_options(self) -> Dict[str, Any]: + """Load persisted init options used to determine invocation style. + + Uses the shared helper from specify_cli and caches values per executor + instance to avoid repeated filesystem reads during hook rendering. + """ + if self._init_options_cache is None: + from . import load_init_options + + payload = load_init_options(self.project_root) + self._init_options_cache = payload if isinstance(payload, dict) else {} + return self._init_options_cache + + @staticmethod + def _skill_name_from_command(command: Any) -> str: + """Map a command id like speckit.plan to speckit-plan skill name.""" + if not isinstance(command, str): + return "" + command_id = command.strip() + if not command_id.startswith("speckit."): + return "" + return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}" + + def _render_hook_invocation(self, command: Any) -> str: + """Render an agent-specific invocation string for a hook command.""" + if not isinstance(command, str): + return "" + + command_id = command.strip() + if not command_id: + return "" + + init_options = self._load_init_options() + selected_ai = init_options.get("ai") + codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) + kimi_skill_mode = selected_ai == "kimi" + + skill_name = self._skill_name_from_command(command_id) + if codex_skill_mode and skill_name: + return f"${skill_name}" + if kimi_skill_mode and skill_name: + return f"/skill:{skill_name}" + + return f"/{command_id}" def get_project_config(self) -> Dict[str, Any]: """Load project-level extension configuration. @@ -2183,21 +2224,27 @@ def format_hook_message( for hook in hooks: extension = hook.get("extension") command = hook.get("command") + invocation = self._render_hook_invocation(command) + command_text = command if isinstance(command, str) and command.strip() else "" + display_invocation = invocation or ( + f"/{command_text}" if command_text != "" else "/" + ) optional = hook.get("optional", True) prompt = hook.get("prompt", "") description = hook.get("description", "") if optional: lines.append(f"\n**Optional Hook**: {extension}") - lines.append(f"Command: `/{command}`") + lines.append(f"Command: `{display_invocation}`") if description: lines.append(f"Description: {description}") lines.append(f"\nPrompt: {prompt}") - lines.append(f"To execute: `/{command}`") + lines.append(f"To execute: `{display_invocation}`") else: lines.append(f"\n**Automatic Hook**: {extension}") - lines.append(f"Executing: `/{command}`") - lines.append(f"EXECUTE_COMMAND: {command}") + lines.append(f"Executing: `{display_invocation}`") + lines.append(f"EXECUTE_COMMAND: {command_text}") + lines.append(f"EXECUTE_COMMAND_INVOCATION: {display_invocation}") return "\n".join(lines) @@ -2261,6 +2308,7 @@ def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]: """ return { "command": hook.get("command"), + "invocation": self._render_hook_invocation(hook.get("command")), "extension": hook.get("extension"), "optional": hook.get("optional", True), "description": hook.get("description", ""), @@ -2304,4 +2352,3 @@ def disable_hooks(self, extension_id: str): hook["enabled"] = False self.save_project_config(config) - diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 24d523aa89..a3f6406287 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -556,24 +556,31 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar.unregister_commands(registered_commands, self.project_root) def _get_skills_dir(self) -> Optional[Path]: - """Return the skills directory if ``--ai-skills`` was used during init. + """Return the active skills directory for preset skill overrides. Reads ``.specify/init-options.json`` to determine whether skills are enabled and which agent was selected, then delegates to the module-level ``_get_skills_dir()`` helper for the concrete path. + Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and + ``.kimi/skills`` exists, presets should still propagate command + overrides to skills even when ``ai_skills`` is false. + Returns: The skills directory ``Path``, or ``None`` if skills were not - enabled or the init-options file is missing. + enabled and no native-skills fallback applies. """ from . import load_init_options, _get_skills_dir opts = load_init_options(self.project_root) - if not opts.get("ai_skills"): + if not isinstance(opts, dict): + opts = {} + agent = opts.get("ai") + if not isinstance(agent, str) or not agent: return None - agent = opts.get("ai") - if not agent: + ai_skills_enabled = bool(opts.get("ai_skills")) + if not ai_skills_enabled and agent != "kimi": return None skills_dir = _get_skills_dir(self.project_root, agent) @@ -582,6 +589,76 @@ def _get_skills_dir(self) -> Optional[Path]: return skills_dir + @staticmethod + def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: + """Return the modern and legacy skill directory names for a command.""" + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + + modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}" + legacy_skill_name = f"speckit.{raw_short_name}" + return modern_skill_name, legacy_skill_name + + @staticmethod + def _skill_title_from_command(cmd_name: str) -> str: + """Return a human-friendly title for a skill command name.""" + title_name = cmd_name + if title_name.startswith("speckit."): + title_name = title_name[len("speckit."):] + return title_name.replace(".", " ").replace("-", " ").title() + + def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: + """Index extension-backed skill restore data by skill directory name.""" + from .extensions import ExtensionManifest, ValidationError + + resolver = PresetResolver(self.project_root) + extensions_dir = self.project_root / ".specify" / "extensions" + restore_index: Dict[str, Dict[str, Any]] = {} + + for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority(): + ext_dir = extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + + try: + manifest = ExtensionManifest(manifest_path) + except ValidationError: + continue + + ext_root = ext_dir.resolve() + for cmd_info in manifest.commands: + cmd_name = cmd_info.get("name") + cmd_file_rel = cmd_info.get("file") + if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str): + continue + + cmd_path = Path(cmd_file_rel) + if cmd_path.is_absolute(): + continue + + try: + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) + except (OSError, ValueError): + continue + + if not source_file.is_file(): + continue + + restore_info = { + "command_name": cmd_name, + "source_file": source_file, + "source": f"extension:{manifest.id}", + } + modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + restore_index.setdefault(modern_skill_name, restore_info) + if legacy_skill_name != modern_skill_name: + restore_index.setdefault(legacy_skill_name, restore_info) + + return restore_index + def _register_skills( self, manifest: "PresetManifest", @@ -629,9 +706,15 @@ def _register_skills( return [] from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar - opts = load_init_options(self.project_root) - selected_ai = opts.get("ai", "") + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + if not isinstance(selected_ai, str): + return [] + registrar = CommandRegistrar() written: List[str] = [] @@ -643,62 +726,61 @@ def _register_skills( continue # Derive the short command name (e.g. "specify" from "speckit.specify") - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] - if selected_ai == "kimi": - skill_name = f"speckit.{short_name}" - else: - skill_name = f"speckit-{short_name}" - - # Only overwrite if the skill already exists (i.e. --ai-skills was used) - skill_subdir = skills_dir / skill_name - if not skill_subdir.exists(): + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + short_name = raw_short_name.replace(".", "-") + skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + skill_title = self._skill_title_from_command(cmd_name) + + # Only overwrite skills that already exist under skills_dir, + # including Kimi native skills when ai_skills is false. + # If both modern and legacy directories exist, update both. + target_skill_names: List[str] = [] + if (skills_dir / skill_name).is_dir(): + target_skill_names.append(skill_name) + if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): + target_skill_names.append(legacy_skill_name) + if not target_skill_names: continue # Parse the command file content = source_file.read_text(encoding="utf-8") - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - frontmatter = {} - body = content - else: - frontmatter = {} - body = content + frontmatter, body = registrar.parse_frontmatter(content) original_desc = frontmatter.get("description", "") enhanced_desc = SKILL_DESCRIPTIONS.get( short_name, original_desc or f"Spec-kit workflow command: {short_name}", ) - - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"preset:{manifest.id}", - }, - } - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {short_name.title()} Skill\n\n" - f"{body}\n" + frontmatter = dict(frontmatter) + frontmatter["description"] = enhanced_desc + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root ) - skill_file = skill_subdir / "SKILL.md" - skill_file.write_text(skill_content, encoding="utf-8") - written.append(skill_name) + for target_skill_name in target_skill_names: + frontmatter_data = { + "name": target_skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"preset:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + + skill_file = skills_dir / target_skill_name / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + written.append(target_skill_name) return written @@ -720,10 +802,17 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: if not skills_dir: return - from . import SKILL_DESCRIPTIONS + from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + registrar = CommandRegistrar() + extension_restore_index = self._build_extension_skill_restore_index() for skill_name in skill_names: # Derive command name from skill name (speckit-specify -> specify) @@ -735,7 +824,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: skill_subdir = skills_dir / skill_name skill_file = skill_subdir / "SKILL.md" - if not skill_file.exists(): + if not skill_subdir.is_dir(): + continue + if not skill_file.is_file(): + # Only manage directories that contain the expected skill entrypoint. continue # Try to find the core command template @@ -746,19 +838,11 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: if core_file: # Restore from core template content = core_file.read_text(encoding="utf-8") - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - frontmatter = {} - body = content - else: - frontmatter = {} - body = content + frontmatter, body = registrar.parse_frontmatter(content) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) original_desc = frontmatter.get("description", "") enhanced_desc = SKILL_DESCRIPTIONS.get( @@ -776,16 +860,49 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: }, } frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(short_name) skill_content = ( f"---\n" f"{frontmatter_text}\n" f"---\n\n" - f"# Speckit {short_name.title()} Skill\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + skill_file.write_text(skill_content, encoding="utf-8") + continue + + extension_restore = extension_restore_index.get(skill_name) + if extension_restore: + content = extension_restore["source_file"].read_text(encoding="utf-8") + frontmatter, body = registrar.parse_frontmatter(content) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + + command_name = extension_restore["command_name"] + title_name = self._skill_title_from_command(command_name) + + frontmatter_data = { + "name": skill_name, + "description": frontmatter.get("description", f"Extension command: {command_name}"), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": extension_restore["source"], + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {title_name} Skill\n\n" f"{body}\n" ) skill_file.write_text(skill_content, encoding="utf-8") else: - # No core template — remove the skill entirely + # No core or extension template — remove the skill entirely shutil.rmtree(skill_subdir) def install_from_directory( @@ -915,17 +1032,26 @@ def remove(self, pack_id: str) -> bool: if not self.registry.is_installed(pack_id): return False - # Unregister commands from AI agents metadata = self.registry.get(pack_id) - registered_commands = metadata.get("registered_commands", {}) if metadata else {} - if registered_commands: - self._unregister_commands(registered_commands) - # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] + registered_commands = metadata.get("registered_commands", {}) if metadata else {} pack_dir = self.presets_dir / pack_id if registered_skills: self._unregister_skills(registered_skills, pack_dir) + try: + from . import NATIVE_SKILLS_AGENTS + except ImportError: + NATIVE_SKILLS_AGENTS = set() + registered_commands = { + agent_name: cmd_names + for agent_name, cmd_names in registered_commands.items() + if agent_name not in NATIVE_SKILLS_AGENTS + } + + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) if pack_dir.exists(): shutil.rmtree(pack_dir) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index cf2b6b7b9c..f0e220e26a 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -24,8 +24,8 @@ from specify_cli import ( _get_skills_dir, + _migrate_legacy_kimi_dotted_skills, install_ai_skills, - AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR, SKILL_DESCRIPTIONS, AGENT_CONFIG, @@ -169,8 +169,8 @@ def test_copilot_skills_dir(self, project_dir): result = _get_skills_dir(project_dir, "copilot") assert result == project_dir / ".github" / "skills" - def test_codex_uses_override(self, project_dir): - """Codex should use the AGENT_SKILLS_DIR_OVERRIDES value.""" + def test_codex_skills_dir_from_agent_config(self, project_dir): + """Codex should resolve skills directory from AGENT_CONFIG folder.""" result = _get_skills_dir(project_dir, "codex") assert result == project_dir / ".agents" / "skills" @@ -203,12 +203,71 @@ def test_all_configured_agents_resolve(self, project_dir): # Should always end with "skills" assert result.name == "skills" - def test_override_takes_precedence_over_config(self, project_dir): - """AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG.""" - for agent_key in AGENT_SKILLS_DIR_OVERRIDES: - result = _get_skills_dir(project_dir, agent_key) - expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key] - assert result == expected +class TestKimiLegacySkillMigration: + """Test temporary migration from Kimi dotted skill names to hyphenated names.""" + + def test_migrates_legacy_dotted_skill_directory(self, project_dir): + skills_dir = project_dir / ".kimi" / "skills" + legacy_dir = skills_dir / "speckit.plan" + legacy_dir.mkdir(parents=True) + (legacy_dir / "SKILL.md").write_text("legacy") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 1 + assert removed == 0 + assert not legacy_dir.exists() + assert (skills_dir / "speckit-plan" / "SKILL.md").exists() + + def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir): + skills_dir = project_dir / ".kimi" / "skills" + legacy_dir = skills_dir / "speckit.plan" + legacy_dir.mkdir(parents=True) + (legacy_dir / "SKILL.md").write_text("legacy") + target_dir = skills_dir / "speckit-plan" + target_dir.mkdir(parents=True) + (target_dir / "SKILL.md").write_text("legacy") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 1 + assert not legacy_dir.exists() + assert (target_dir / "SKILL.md").read_text() == "legacy" + + def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir): + skills_dir = project_dir / ".kimi" / "skills" + legacy_dir = skills_dir / "speckit.plan" + legacy_dir.mkdir(parents=True) + (legacy_dir / "SKILL.md").write_text("legacy") + target_dir = skills_dir / "speckit-plan" + target_dir.mkdir(parents=True) + (target_dir / "SKILL.md").write_text("new") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy_dir.exists() + assert (legacy_dir / "SKILL.md").read_text() == "legacy" + assert (target_dir / "SKILL.md").read_text() == "new" + + def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir): + skills_dir = project_dir / ".kimi" / "skills" + legacy_dir = skills_dir / "speckit.plan" + legacy_dir.mkdir(parents=True) + (legacy_dir / "SKILL.md").write_text("legacy") + (legacy_dir / "notes.txt").write_text("custom") + target_dir = skills_dir / "speckit-plan" + target_dir.mkdir(parents=True) + (target_dir / "SKILL.md").write_text("legacy") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy_dir.exists() + assert (legacy_dir / "notes.txt").read_text() == "custom" # ===== install_ai_skills Tests ===== @@ -473,8 +532,7 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key): skills_dir = _get_skills_dir(proj, agent_key) assert skills_dir.exists() skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # Kimi uses dotted skill names; other agents use hyphen-separated names. - expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify" + expected_skill_name = "speckit-specify" assert expected_skill_name in skill_dirs assert (skills_dir / expected_skill_name / "SKILL.md").exists() @@ -773,6 +831,32 @@ def fake_download(project_path, *args, **kwargs): mock_skills.assert_called_once() assert mock_skills.call_args.kwargs.get("overwrite_existing") is True + def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path): + """Kimi init should migrate dotted legacy skills even when --ai-skills is not set.""" + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "kimi-legacy-no-ai-skills" + + def fake_download(project_path, *args, **kwargs): + legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan" + legacy_dir.mkdir(parents=True, exist_ok=True) + (legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n") + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 0 + assert not (target / ".kimi" / "skills" / "speckit.plan").exists() + assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists() + def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch): """Codex --here skills init should not delete a pre-existing .codex directory.""" from typer.testing import CliRunner @@ -1118,12 +1202,12 @@ def _fake_download(*args, **kwargs): assert "Optional skills that you can use for your specs" in result.output def test_kimi_next_steps_show_skill_invocation(self, monkeypatch): - """Kimi next-steps guidance should display /skill:speckit.* usage.""" + """Kimi next-steps guidance should display /skill:speckit-* usage.""" from typer.testing import CliRunner def _fake_download(*args, **kwargs): project_path = Path(args[0]) - skill_dir = project_path / ".kimi" / "skills" / "speckit.specify" + skill_dir = project_path / ".kimi" / "skills" / "speckit-specify" skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") @@ -1137,7 +1221,7 @@ def _fake_download(*args, **kwargs): ) assert result.exit_code == 0 - assert "/skill:speckit.constitution" in result.output + assert "/skill:speckit-constitution" in result.output assert "/speckit.constitution" not in result.output assert "Optional skills that you can use for your specs" in result.output diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 92848bb163..92b747a296 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -142,7 +142,7 @@ def _expected_cmd_dir(project_path: Path, agent: str) -> Path: # Agents whose commands are laid out as //SKILL.md. # Maps agent -> separator used in skill directory names. -_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."} +_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"} def _expected_ext(agent: str) -> str: diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index b8d5202f52..47d40a3b93 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -41,17 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: """Create and return the expected skills directory for the given agent.""" # Match the logic in _get_skills_dir() from specify_cli - from specify_cli import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR - if ai in AGENT_SKILLS_DIR_OVERRIDES: - skills_dir = project_root / AGENT_SKILLS_DIR_OVERRIDES[ai] + agent_config = AGENT_CONFIG.get(ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + skills_dir = project_root / agent_folder.rstrip("/") / "skills" else: - agent_config = AGENT_CONFIG.get(ai, {}) - agent_folder = agent_config.get("folder", "") - if agent_folder: - skills_dir = project_root / agent_folder.rstrip("/") / "skills" - else: - skills_dir = project_root / DEFAULT_SKILLS_DIR + skills_dir = project_root / DEFAULT_SKILLS_DIR skills_dir.mkdir(parents=True, exist_ok=True) return skills_dir @@ -195,6 +192,24 @@ def test_returns_none_when_skills_dir_missing(self, project_dir): result = manager._get_skills_dir() assert result is None + def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir): + """Kimi should still use its native skills dir when ai_skills is false.""" + _create_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = _create_skills_dir(project_dir, ai="kimi") + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result == skills_dir + + def test_returns_none_for_non_dict_init_options(self, project_dir): + """Corrupted-but-parseable init-options should not crash skill-dir lookup.""" + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("[]") + _create_skills_dir(project_dir, ai="claude") + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + # ===== Extension Skill Registration Tests ===== @@ -211,8 +226,8 @@ def test_skills_created_when_ai_skills_active(self, skills_project, extension_di # Check that skill directories were created skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) - assert "speckit-test-ext.hello" in skill_dirs - assert "speckit-test-ext.world" in skill_dirs + assert "speckit-test-ext-hello" in skill_dirs + assert "speckit-test-ext-world" in skill_dirs def test_skill_md_content_correct(self, skills_project, extension_dir): """SKILL.md should have correct agentskills.io structure.""" @@ -222,13 +237,13 @@ def test_skill_md_content_correct(self, skills_project, extension_dir): extension_dir, "0.1.0", register_commands=False ) - skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md" + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() # Check structure assert content.startswith("---\n") - assert "name: speckit-test-ext.hello" in content + assert "name: speckit-test-ext-hello" in content assert "description:" in content assert "Test hello command" in content assert "source: extension:test-ext" in content @@ -244,7 +259,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): extension_dir, "0.1.0", register_commands=False ) - skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md" + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" content = skill_file.read_text() assert content.startswith("---\n") @@ -252,7 +267,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): assert len(parts) >= 3 parsed = yaml.safe_load(parts[1]) assert isinstance(parsed, dict) - assert parsed["name"] == "speckit-test-ext.hello" + assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): @@ -281,7 +296,7 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir): project_dir, skills_dir = skills_project # Pre-create a custom skill - custom_dir = skills_dir / "speckit-test-ext.hello" + custom_dir = skills_dir / "speckit-test-ext-hello" custom_dir.mkdir(parents=True) custom_content = "# My Custom Hello Skill\nUser-modified content\n" (custom_dir / "SKILL.md").write_text(custom_content) @@ -296,9 +311,9 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir): # But the other skill should still be created metadata = manager.registry.get(manifest.id) - assert "speckit-test-ext.world" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] # The pre-existing one should NOT be in registered_skills (it was skipped) - assert "speckit-test-ext.hello" not in metadata["registered_skills"] + assert "speckit-test-ext-hello" not in metadata["registered_skills"] def test_registered_skills_in_registry(self, skills_project, extension_dir): """Registry should contain registered_skills list.""" @@ -311,11 +326,11 @@ def test_registered_skills_in_registry(self, skills_project, extension_dir): metadata = manager.registry.get(manifest.id) assert "registered_skills" in metadata assert len(metadata["registered_skills"]) == 2 - assert "speckit-test-ext.hello" in metadata["registered_skills"] - assert "speckit-test-ext.world" in metadata["registered_skills"] + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] - def test_kimi_uses_dot_notation(self, project_dir, temp_dir): - """Kimi agent should use dot notation for skill names.""" + def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir): + """Kimi agent should use the same hyphenated skill names as hooks.""" _create_init_options(project_dir, ai="kimi", ai_skills=True) _create_skills_dir(project_dir, ai="kimi") ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") @@ -326,9 +341,80 @@ def test_kimi_uses_dot_notation(self, project_dir, temp_dir): ) metadata = manager.registry.get(manifest.id) - # Kimi should use dots, not hyphens - assert "speckit.test-ext.hello" in metadata["registered_skills"] - assert "speckit.test-ext.world" in metadata["registered_skills"] + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + + def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi should still auto-register extension skills in native-skills mode.""" + _create_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = _create_skills_dir(project_dir, ai="kimi") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir): + """Auto-registered extension skills should resolve script placeholders.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "scripted-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "scripted-ext", + "name": "Scripted Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.scripted-ext.plan", + "file": "commands/plan.md", + "description": "Scripted plan command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "plan.md").write_text( + "---\n" + "description: Scripted plan command\n" + "scripts:\n" + " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" + "agent_scripts:\n" + " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n" + "---\n\n" + "Run {SCRIPT}\n" + "Then {AGENT_SCRIPT}\n" + "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "{AGENT_SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + assert ".specify/scripts/bash/update-agent-context.sh claude" in content + assert ".specify/templates/checklist.md" in content + assert ".specify/memory/constitution.md" in content def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" @@ -375,8 +461,8 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir): ) metadata = manager.registry.get(manifest.id) - assert "speckit-missing-cmd-ext.exists" in metadata["registered_skills"] - assert "speckit-missing-cmd-ext.ghost" not in metadata["registered_skills"] + assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"] + assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"] # ===== Extension Skill Unregistration Tests ===== @@ -393,16 +479,16 @@ def test_skills_removed_on_extension_remove(self, skills_project, extension_dir) ) # Verify skills exist - assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() - assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() # Remove extension result = manager.remove(manifest.id, keep_config=False) assert result is True # Skills should be gone - assert not (skills_dir / "speckit-test-ext.hello").exists() - assert not (skills_dir / "speckit-test-ext.world").exists() + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() def test_other_skills_preserved_on_remove(self, skills_project, extension_dir): """Non-extension skills should not be affected by extension removal.""" @@ -433,8 +519,8 @@ def test_remove_handles_already_deleted_skills(self, skills_project, extension_d ) # Manually delete skill dirs before calling remove - shutil.rmtree(skills_dir / "speckit-test-ext.hello") - shutil.rmtree(skills_dir / "speckit-test-ext.world") + shutil.rmtree(skills_dir / "speckit-test-ext-hello") + shutil.rmtree(skills_dir / "speckit-test-ext-world") # Should not raise result = manager.remove(manifest.id, keep_config=False) @@ -457,6 +543,21 @@ def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir class TestExtensionSkillEdgeCases: """Test edge cases in extension skill registration.""" + def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir): + """Corrupted init-options payloads should disable skill registration, not crash install.""" + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("[]") + _create_skills_dir(project_dir, ai="claude") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + def test_command_without_frontmatter(self, skills_project, temp_dir): """Commands without YAML frontmatter should still produce valid skills.""" project_dir, skills_dir = skills_project @@ -495,10 +596,10 @@ def test_command_without_frontmatter(self, skills_project, temp_dir): ext_dir, "0.1.0", register_commands=False ) - skill_file = skills_dir / "speckit-nofm-ext.plain" / "SKILL.md" + skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() - assert "name: speckit-nofm-ext.plain" in content + assert "name: speckit-nofm-ext-plain" in content # Fallback description when no frontmatter description assert "Extension command: speckit.nofm-ext.plain" in content assert "Body without frontmatter." in content @@ -515,8 +616,8 @@ def test_gemini_agent_skills(self, project_dir, temp_dir): ) skills_dir = project_dir / ".gemini" / "skills" - assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() - assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): """Installing and removing different extensions should be independent.""" @@ -534,15 +635,15 @@ def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): ) # Both should have skills - assert (skills_dir / "speckit-ext-a.hello" / "SKILL.md").exists() - assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() # Remove ext-a manager.remove("ext-a", keep_config=False) # ext-a skills gone, ext-b skills preserved - assert not (skills_dir / "speckit-ext-a.hello").exists() - assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists() + assert not (skills_dir / "speckit-ext-a-hello").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() def test_malformed_frontmatter_handled(self, skills_project, temp_dir): """Commands with invalid YAML frontmatter should still produce valid skills.""" @@ -591,7 +692,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir): ext_dir, "0.1.0", register_commands=False ) - skill_file = skills_dir / "speckit-badfm-ext.broken" / "SKILL.md" + skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() # Fallback description since frontmatter was invalid @@ -607,7 +708,7 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens ) # Verify skills exist - assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() # Delete init-options.json to simulate user change init_opts = project_dir / ".specify" / "init-options.json" @@ -616,8 +717,8 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens # Remove should still clean up via fallback scan result = manager.remove(manifest.id, keep_config=False) assert result is True - assert not (skills_dir / "speckit-test-ext.hello").exists() - assert not (skills_dir / "speckit-test-ext.world").exists() + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir): """Skills should be cleaned up even if ai_skills is toggled to false after install.""" @@ -628,7 +729,7 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension ) # Verify skills exist - assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() # Toggle ai_skills to false _create_init_options(project_dir, ai="claude", ai_skills=False) @@ -636,5 +737,5 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension # Remove should still clean up via fallback scan result = manager.remove(manifest.id, keep_config=False) assert result is True - assert not (skills_dir / "speckit-test-ext.hello").exists() - assert not (skills_dir / "speckit-test-ext.world").exists() + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() diff --git a/tests/test_extensions.py b/tests/test_extensions.py index cd0f9ba443..92b9839ac9 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -22,6 +22,7 @@ ExtensionRegistry, ExtensionManager, CommandRegistrar, + HookExecutor, ExtensionCatalog, ExtensionError, ValidationError, @@ -759,6 +760,81 @@ def test_render_frontmatter_unicode(self): assert "Prüfe Konformität" in output assert "\\u" not in output + def test_adjust_script_paths_does_not_mutate_input(self): + """Path adjustments should not mutate caller-owned frontmatter dicts.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + original = { + "scripts": { + "sh": "../../scripts/bash/setup-plan.sh {ARGS}", + "ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}", + } + } + before = json.loads(json.dumps(original)) + + adjusted = registrar._adjust_script_paths(original) + + assert original == before + assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}" + assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" + + def test_adjust_script_paths_preserves_extension_local_paths(self): + """Extension-local script paths should not be rewritten into .specify/.specify.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + original = { + "scripts": { + "sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}", + "ps": "scripts/powershell/setup-plan.ps1 {ARGS}", + } + } + + adjusted = registrar._adjust_script_paths(original) + + assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}" + assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" + + def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self): + """Body rewrites should preserve extension-local assets while fixing top-level refs.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + body = ( + "Read `.specify/extensions/test-ext/templates/spec.md`\n" + "Run scripts/bash/setup-plan.sh\n" + ) + + rewritten = AgentCommandRegistrar._rewrite_project_relative_paths(body) + + assert ".specify/extensions/test-ext/templates/spec.md" in rewritten + assert ".specify/scripts/bash/setup-plan.sh" in rewritten + + def test_render_toml_command_handles_embedded_triple_double_quotes(self): + """TOML renderer should stay valid when body includes triple double-quotes.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + 'line1\n"""danger"""\nline2', + "extension:test-ext", + ) + + assert "prompt = '''" in output + assert '"""danger"""' in output + + def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self): + """If body has both triple quote styles, fall back to escaped basic string.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + 'a """ b\nc \'\'\' d', + "extension:test-ext", + ) + + assert 'prompt = "' in output + assert "\\n" in output + assert "\\\"\\\"\\\"" in output + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory @@ -875,11 +951,11 @@ def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir) - skill_file = skills_dir / "speckit-test.hello" / "SKILL.md" + skill_file = skills_dir / "speckit-test-hello" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() - assert "name: speckit-test.hello" in content + assert "name: speckit-test-hello" in content assert "description: Test hello command" in content assert "compatibility:" in content assert "metadata:" in content @@ -944,7 +1020,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-test.plan" / "SKILL.md" + skill_file = skills_dir / "speckit-test-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -994,12 +1070,12 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - primary = skills_dir / "speckit-alias.cmd" / "SKILL.md" + primary = skills_dir / "speckit-alias-cmd" / "SKILL.md" alias = skills_dir / "speckit-shortcut" / "SKILL.md" assert primary.exists() assert alias.exists() - assert "name: speckit-alias.cmd" in primary.read_text() + assert "name: speckit-alias-cmd" in primary.read_text() assert "name: speckit-shortcut" in alias.read_text() def test_codex_skill_registration_uses_fallback_script_variant_without_init_options( @@ -1056,7 +1132,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md" + skill_file = skills_dir / "speckit-fallback-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -1065,6 +1141,62 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content assert ".specify/scripts/bash/update-agent-context.sh codex" in content + def test_codex_skill_registration_handles_non_dict_init_options( + self, project_dir, temp_dir + ): + """Non-dict init-options payloads should not crash skill placeholder resolution.""" + import yaml + + ext_dir = temp_dir / "ext-script-list-init" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-script-list-init", + "name": "List init options", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.list.plan", + "file": "commands/plan.md", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "plan.md").write_text( + """--- +description: "List init scripted command" +scripts: + sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" +--- + +Run {SCRIPT} +""" + ) + + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("[]") + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text() + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + def test_codex_skill_registration_fallback_prefers_powershell_on_windows( self, project_dir, temp_dir, monkeypatch ): @@ -1121,7 +1253,7 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md" + skill_file = skills_dir / "speckit-windows-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -3231,3 +3363,128 @@ def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir): assert result[0][0] == "ext-with-priority" assert result[1][0] == "legacy-ext" assert result[2][0] == "ext-low-priority" + + +class TestHookInvocationRendering: + """Test hook invocation formatting for different agent modes.""" + + def test_kimi_hooks_render_skill_invocation(self, project_dir): + """Kimi projects should render /skill:speckit-* invocations.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False})) + + hook_executor = HookExecutor(project_dir) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + + assert "Executing: `/skill:speckit-plan`" in message + assert "EXECUTE_COMMAND: speckit.plan" in message + assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message + + def test_codex_hooks_render_dollar_skill_invocation(self, project_dir): + """Codex projects with --ai-skills should render $speckit-* invocations.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True})) + + hook_executor = HookExecutor(project_dir) + execution = hook_executor.execute_hook( + { + "extension": "test-ext", + "command": "speckit.tasks", + "optional": False, + } + ) + + assert execution["command"] == "speckit.tasks" + assert execution["invocation"] == "$speckit-tasks" + + def test_non_skill_command_keeps_slash_invocation(self, project_dir): + """Custom hook commands should keep slash invocation style.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False})) + + hook_executor = HookExecutor(project_dir) + message = hook_executor.format_hook_message( + "before_tasks", + [ + { + "extension": "test-ext", + "command": "pre_tasks_test", + "optional": False, + } + ], + ) + + assert "Executing: `/pre_tasks_test`" in message + assert "EXECUTE_COMMAND: pre_tasks_test" in message + assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message + + def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir): + """Multi-segment extension command ids should map to hyphenated skills.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False})) + + hook_executor = HookExecutor(project_dir) + message = hook_executor.format_hook_message( + "after_tasks", + [ + { + "extension": "test-ext", + "command": "speckit.test.hello", + "optional": False, + } + ], + ) + + assert "Executing: `/skill:speckit-test-hello`" in message + assert "EXECUTE_COMMAND: speckit.test.hello" in message + assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message + + def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch): + """Init options should be loaded once per executor instance.""" + calls = {"count": 0} + + def fake_load_init_options(_project_root): + calls["count"] += 1 + return {"ai": "kimi", "ai_skills": False} + + monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options) + + hook_executor = HookExecutor(project_dir) + assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan" + assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks" + assert calls["count"] == 1 + + def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): + """Hook messages should still render actionable command placeholders.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False})) + + hook_executor = HookExecutor(project_dir) + message = hook_executor.format_hook_message( + "after_tasks", + [ + { + "extension": "test-ext", + "command": None, + "optional": False, + } + ], + ) + + assert "Executing: `/`" in message + assert "EXECUTE_COMMAND: " in message + assert "EXECUTE_COMMAND_INVOCATION: /" in message diff --git a/tests/test_presets.py b/tests/test_presets.py index 95dca41224..1b2704c57f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1942,10 +1942,10 @@ def test_load_returns_empty_on_invalid_json(self, project_dir): class TestPresetSkills: """Tests for preset skill registration and unregistration.""" - def _write_init_options(self, project_dir, ai="claude", ai_skills=True): + def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"): from specify_cli import save_init_options - save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills}) + save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script}) def _create_skill(self, skills_dir, skill_name, body="original body"): skill_dir = skills_dir / skill_name @@ -1995,6 +1995,26 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): content = skill_file.read_text() assert "untouched" in content, "Skill should not be modified when ai_skills=False" + def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir): + """Corrupted init-options ai values should not crash preset skill resolution.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}') + + manager = PresetManager(project_dir) + + assert manager._get_skills_dir() is None + + def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir): + """Corrupted non-dict init-options payloads should fail closed.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("[]") + + manager = PresetManager(project_dir) + + assert manager._get_skills_dir() is None + def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): """When no init-options.json exists, preset install should not touch skills.""" skills_dir = project_dir / ".claude" / "skills" @@ -2040,6 +2060,52 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" + def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): + """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="old") + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\n" + "description: Core specify command\n" + "scripts:\n" + " sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" + "---\n\n" + "Run:\n" + "{SCRIPT}\n" + ) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + manager.remove("self-test") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content + + def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): + """Preset install should skip non-directory skill targets.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + (skills_dir / "speckit-specify").write_text("not-a-directory") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + assert (skills_dir / "speckit-specify").is_file() + metadata = manager.registry.get("self-test") + assert "speckit-specify" not in metadata.get("registered_skills", []) + def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): """Skills should not be created when no existing skill dir is found.""" self._write_init_options(project_dir, ai="claude") @@ -2054,6 +2120,304 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d metadata = manager.registry.get("self-test") assert metadata.get("registered_skills", []) == [] + def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir): + """Preset overrides for speckit.. should target speckit-- skills.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched") + (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "ext-skill-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-override", + "name": "Ext Skill Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:ext-skill-override" in content + assert "name: speckit-fakeext-cmd" in content + assert "# Speckit Fakeext Cmd Skill" in content + + metadata = manager.registry.get("ext-skill-override") + assert "speckit-fakeext-cmd" in metadata.get("registered_skills", []) + + def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """Preset removal should restore an extension-backed skill instead of deleting it.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "commands" / "cmd.md").write_text( + "---\n" + "description: Extension fakeext cmd\n" + "scripts:\n" + " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" + "---\n\n" + "extension:fakeext\n" + "Run {SCRIPT}\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / "ext-skill-restore" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-restore", + "name": "Ext Skill Restore", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert "preset:ext-skill-restore" in skill_file.read_text() + + manager.remove("ext-skill-restore") + + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:ext-skill-restore" not in content + assert "source: extension:fakeext" in content + assert "extension:fakeext" in content + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + assert "# Fakeext Cmd Skill" in content + + def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir): + """Preset removal should not delete arbitrary directories missing SKILL.md.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + stray_skill_dir = skills_dir / "speckit-fakeext-cmd" + stray_skill_dir.mkdir(parents=True, exist_ok=True) + note_file = stray_skill_dir / "notes.txt" + note_file.write_text("user content", encoding="utf-8") + + preset_dir = temp_dir / "ext-skill-missing-file" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-missing-file", + "name": "Ext Skill Missing File", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + installed_preset_dir = manager.presets_dir / "ext-skill-missing-file" + shutil.copytree(preset_dir, installed_preset_dir) + manager.registry.add( + "ext-skill-missing-file", + { + "version": "1.0.0", + "source": str(preset_dir), + "provides_templates": ["speckit.fakeext.cmd"], + "registered_skills": ["speckit-fakeext-cmd"], + "priority": 10, + }, + ) + + manager.remove("ext-skill-missing-file") + + assert stray_skill_dir.is_dir() + assert note_file.read_text(encoding="utf-8") == "user content" + + def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): + """Preset overrides should still target legacy dotted Kimi skill directories.""" + self._write_init_options(project_dir, ai="kimi") + skills_dir = project_dir / ".kimi" / "skills" + self._create_skill(skills_dir, "speckit.specify", body="untouched") + + (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(self_test_dir, "0.1.5") + + skill_file = skills_dir / "speckit.specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content + assert "name: speckit.specify" in content + + metadata = manager.registry.get("self-test") + assert "speckit.specify" in metadata.get("registered_skills", []) + + def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi presets should still propagate command overrides to existing skills.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = project_dir / ".kimi" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(self_test_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content + assert "name: speckit-specify" in content + + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", []) + + def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): + """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") + skills_dir = project_dir / ".kimi" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "kimi-placeholder-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + "description: Kimi placeholder override\n" + "scripts:\n" + " sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" + "---\n\n" + "Execute `{SCRIPT}` for __AGENT__\n" + "Review templates/checklist.md and memory/constitution.md\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "kimi-placeholder-override", + "name": "Kimi Placeholder Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "__AGENT__" not in content + assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content + assert ".specify/templates/checklist.md" in content + assert ".specify/memory/constitution.md" in content + assert "for kimi" in content + + def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): + """Non-dict init-options payloads should not crash preset install/remove flows.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("[]") + + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(self_test_dir, "0.1.5") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "untouched" in content + class TestPresetSetPriority: """Test preset set-priority CLI command.""" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 0cf631e963..7e4f88ed0c 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -14,6 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" +CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" @@ -147,6 +148,24 @@ def test_sequential_ignores_timestamp_dirs(self, git_repo: Path): branch = line.split(":", 1)[1].strip() assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}" + def test_sequential_supports_four_digit_prefixes(self, git_repo: Path): + """Sequential numbering should continue past 999 without truncation.""" + (git_repo / "specs" / "999-last-3digit").mkdir(parents=True) + (git_repo / "specs" / "1000-first-4digit").mkdir(parents=True) + result = run_script(git_repo, "--short-name", "next-feat", "Next feature") + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}" + + def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): + """PowerShell scanner should parse large prefixes without [int] casts.""" + content = CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "[long]::TryParse($matches[1], [ref]$num)" in content + assert "$num = [int]$matches[1]" not in content + # ── check_feature_branch Tests ─────────────────────────────────────────────── From b22f381c0dbf9697487faa51c6fe0bc865621390 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:04:09 -0500 Subject: [PATCH 142/321] chore: bump version to 0.4.3 (#1986) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf55b42372..3265b305b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## [0.4.3] - 2026-03-26 + +### Changed + +- Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971) +- fix(ps1): replace null-conditional operator for PowerShell 5.1 compatibility (#1975) +- chore: bump version to 0.4.2 (#1973) + ## [0.4.2] - 2026-03-25 ### Changed diff --git a/pyproject.toml b/pyproject.toml index a7b27109a5..3810238ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.2" +version = "0.4.3" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From d7206126e098985e8b31c062b3c4b01827072b40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:02:40 -0500 Subject: [PATCH 143/321] chore(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 (#1989) Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 19 to 23. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/v19...v23) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: '23' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3b3bcf8d3c..8b648df8c9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@v19 + uses: DavidAnson/markdownlint-cli2-action@v23 with: globs: | '**/*.md' From 362868a342b8a9fea21b68b7c6fb8c47ca94b672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:03:34 -0500 Subject: [PATCH 144/321] chore(deps): bump actions/deploy-pages from 4 to 5 (#1990) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3fe3894076..d714b16884 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -64,5 +64,5 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 8520241dfe34053ddbbd58fa072352aa4f9f7fdb Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Fri, 27 Mar 2026 13:23:01 +0000 Subject: [PATCH 145/321] Add plan-review-gate to community catalog (#1993) - Extension ID: plan-review-gate - Version: 1.0.0 - Author: luno - Catalog entries sorted alphabetically by ID - README table row inserted alphabetically by name Co-authored-by: Ed Harrod Co-authored-by: Claude Opus 4.6 --- README.md | 1 + extensions/catalog.community.json | 33 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d83483036..fd1540d06d 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ The following community-contributed extensions are available in [`catalog.commun | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | +| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 846254c484..46d727c3f0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-19T12:08:20Z", + "updated_at": "2026-03-27T08:22:30Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -437,6 +437,37 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "plan-review-gate": { + "name": "Plan Review Gate", + "id": "plan-review-gate", + "description": "Require spec.md and plan.md to be merged via MR/PR before allowing task generation", + "author": "luno", + "version": "1.0.0", + "download_url": "https://github.com/luno/spec-kit-plan-review-gate/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/luno/spec-kit-plan-review-gate", + "homepage": "https://github.com/luno/spec-kit-plan-review-gate", + "documentation": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/README.md", + "changelog": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "review", + "quality", + "workflow", + "gate" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T08:22:30Z", + "updated_at": "2026-03-27T08:22:30Z" + }, "presetify": { "name": "Presetify", "id": "presetify", From 9c2481fd676e4301d8f8166d0cb3eb5ec9002294 Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Fri, 27 Mar 2026 11:58:43 -0300 Subject: [PATCH 146/321] feat: add spec-kit-onboard extension to community catalog (#1991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the onboard extension (v2.1.0) — contextual onboarding and progressive growth for developers new to spec-kit projects. - 7 commands: start, explain, trail, quiz, badge, mentor, team - 3 hooks: after-implement, before-implement, after-explain - Repository: https://github.com/dmux/spec-kit-onboard --- README.md | 1 + extensions/catalog.community.json | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/README.md b/README.md index fd1540d06d..6f483e9fa6 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ The following community-contributed extensions are available in [`catalog.commun | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | +| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 46d727c3f0..5968211663 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -437,6 +437,39 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "onboard": { + "name": "Onboard", + "id": "onboard", + "description": "Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step.", + "author": "Rafael Sales", + "version": "2.1.0", + "download_url": "https://github.com/dmux/spec-kit-onboard/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/dmux/spec-kit-onboard", + "homepage": "https://github.com/dmux/spec-kit-onboard", + "documentation": "https://github.com/dmux/spec-kit-onboard/blob/main/README.md", + "changelog": "https://github.com/dmux/spec-kit-onboard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 7, + "hooks": 3 + }, + "tags": [ + "onboarding", + "learning", + "mentoring", + "developer-experience", + "gamification", + "knowledge-transfer" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-26T00:00:00Z" + }, "plan-review-gate": { "name": "Plan Review Gate", "id": "plan-review-gate", From 41d1f4b0acfcf2daf12ca9def1868c49840a2f6c Mon Sep 17 00:00:00 2001 From: Daniel Badde <19553316+GenieRobot@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:45:19 +0200 Subject: [PATCH 147/321] feat: add MAQA extension suite (7 extensions) to community catalog (#1981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MAQA extension suite to community catalog and README Adds 7 extensions forming the MAQA (Multi-Agent & Quality Assurance) suite to catalog.community.json in correct alphabetical order (after 'learn', before 'onboard') and to the README community extensions table: - maqa — coordinator/feature/QA workflow, board auto-detection - maqa-azure-devops — Azure DevOps Boards integration - maqa-ci — CI/CD gate (GitHub Actions/CircleCI/GitLab/Bitbucket) - maqa-github-projects — GitHub Projects v2 integration - maqa-jira — Jira integration - maqa-linear — Linear integration - maqa-trello — Trello integration All entries placed alphabetically. maqa v0.1.3 bumped to reflect multi-board auto-detection added in this release. * fix: set catalog updated_at to match latest entry timestamp Top-level updated_at was 00:00:00Z while plan-review-gate entries had 08:22:30Z, making metadata inconsistent for freshness consumers. Updated to 2026-03-27T08:22:30Z (>= all entry timestamps). --- README.md | 7 + extensions/catalog.community.json | 358 ++++++++++++++++++++++++------ 2 files changed, 299 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 6f483e9fa6..95dfce7879 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,13 @@ The following community-contributed extensions are available in [`catalog.commun | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | +| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | +| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) | +| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) | +| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) | +| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) | +| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | +| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 5968211663..8d105eb849 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -6,7 +6,7 @@ "aide": { "name": "AI-Driven Engineering (AIDE)", "id": "aide", - "description": "A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation.", + "description": "A structured 7-step workflow for building new projects from scratch with AI assistants \u2014 from vision through implementation.", "author": "mnriem", "version": "1.0.0", "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip", @@ -170,7 +170,7 @@ "cognitive-squad": { "name": "Cognitive Squad", "id": "cognitive-squad", - "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing", + "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application \u2014 with quality gates, backpropagation verification, and self-healing", "author": "Testimonial", "version": "0.1.0", "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip", @@ -379,7 +379,7 @@ "iterate": { "name": "Iterate", "id": "iterate", - "description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building", + "description": "Iterate on spec documents with a two-phase define-and-apply workflow \u2014 refine specs mid-implementation and go straight back to building", "author": "Vianca Martinez", "version": "2.0.0", "download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip", @@ -437,6 +437,263 @@ "created_at": "2026-03-05T00:00:00Z", "updated_at": "2026-03-05T00:00:00Z" }, + "learn": { + "name": "Learning Extension", + "id": "learn", + "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.", + "author": "Vianca Martinez", + "version": "1.0.0", + "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/imviancagrace/spec-kit-learn", + "homepage": "https://github.com/imviancagrace/spec-kit-learn", + "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md", + "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "learning", + "education", + "mentoring", + "knowledge-transfer" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z" + }, + "maqa": { + "name": "MAQA \u2014 Multi-Agent & Quality Assurance", + "id": "maqa", + "description": "Coordinator \u2192 feature \u2192 QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", + "author": "GenieRobot", + "version": "0.1.3", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-ext", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "multi-agent", + "orchestration", + "quality-assurance", + "workflow", + "parallel", + "tdd" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-azure-devops": { + "name": "MAQA Azure DevOps Integration", + "id": "maqa-azure-devops", + "description": "Azure DevOps Boards integration for the MAQA extension. Populates work items from specs, moves User Stories across columns as features progress, real-time Task child ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/releases/download/maqa-azure-devops-v0.1.0/maqa-azure-devops.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "azure-devops", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-ci": { + "name": "MAQA CI/CD Gate", + "id": "maqa-ci", + "description": "CI/CD pipeline gate for the MAQA extension. Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ci/releases/download/maqa-ci-v0.1.0/maqa-ci.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-ci", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ci", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "ci-cd", + "github-actions", + "circleci", + "gitlab-ci", + "quality-gate", + "maqa" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-github-projects": { + "name": "MAQA GitHub Projects Integration", + "id": "maqa-github-projects", + "description": "GitHub Projects v2 integration for the MAQA extension. Populates draft issues from specs, moves items across Status columns as features progress, real-time task list ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/releases/download/maqa-github-projects-v0.1.0/maqa-github-projects.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-github-projects", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-github-projects", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "github-projects", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-jira": { + "name": "MAQA Jira Integration", + "id": "maqa-jira", + "description": "Jira integration for the MAQA extension. Populates Stories from specs, moves issues across board columns as features progress, real-time Subtask ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-jira/releases/download/maqa-jira-v0.1.0/maqa-jira.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-jira", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-jira", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "jira", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-linear": { + "name": "MAQA Linear Integration", + "id": "maqa-linear", + "description": "Linear integration for the MAQA extension. Populates issues from specs, moves items across workflow states as features progress, real-time sub-issue ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-linear/releases/download/maqa-linear-v0.1.0/maqa-linear.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-linear", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-linear", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "linear", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-trello": { + "name": "MAQA Trello Integration", + "id": "maqa-trello", + "description": "Trello board integration for the MAQA extension. Populates board from specs, moves cards between lists as features progress, real-time checklist ticking.", + "author": "GenieRobot", + "version": "0.1.1", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-trello/releases/download/maqa-trello-v0.1.1/maqa-trello.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-trello", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-trello", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "trello", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-26T00:00:00Z" + }, "onboard": { "name": "Onboard", "id": "onboard", @@ -702,6 +959,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "status": { + "name": "Project Status", + "id": "status", + "description": "Show current SDD workflow progress \u2014 active feature, artifact status, task completion, workflow phase, and extensions summary.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-status", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-status", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "status", + "workflow", + "progress", + "feature-tracking", + "task-progress" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", @@ -775,38 +1064,6 @@ "created_at": "2026-03-07T00:00:00Z", "updated_at": "2026-03-07T00:00:00Z" }, - "status": { - "name": "Project Status", - "id": "status", - "description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.", - "author": "KhawarHabibKhan", - "version": "1.0.0", - "download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/KhawarHabibKhan/spec-kit-status", - "homepage": "https://github.com/KhawarHabibKhan/spec-kit-status", - "documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md", - "changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0" - }, - "provides": { - "commands": 1, - "hooks": 0 - }, - "tags": [ - "status", - "workflow", - "progress", - "feature-tracking", - "task-progress" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-03-16T00:00:00Z", - "updated_at": "2026-03-16T00:00:00Z" - }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", @@ -839,37 +1096,6 @@ "created_at": "2026-02-20T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, - "learn": { - "name": "Learning Extension", - "id": "learn", - "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.", - "author": "Vianca Martinez", - "version": "1.0.0", - "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/imviancagrace/spec-kit-learn", - "homepage": "https://github.com/imviancagrace/spec-kit-learn", - "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md", - "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0" - }, - "provides": { - "commands": 2, - "hooks": 1 - }, - "tags": [ - "learning", - "education", - "mentoring", - "knowledge-transfer" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-03-17T00:00:00Z", - "updated_at": "2026-03-17T00:00:00Z" - }, "verify": { "name": "Verify Extension", "id": "verify", From 8778c26dcf2672f8a3db001416496da16c2193d1 Mon Sep 17 00:00:00 2001 From: AK <94736489+chenry-me@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:48:11 +0800 Subject: [PATCH 148/321] fix(scripts): honor PowerShell agent and script filters (#1969) Rename the Normalize-List parameter in create-release-packages.ps1 to avoid conflicting with PowerShell's automatic $input variable. This fixes Windows offline scaffolding when -Agents and -Scripts are passed. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/scripts/create-release-packages.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 5bd600e5ff..912dd00ecb 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -497,13 +497,13 @@ $AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode' $AllScripts = @('sh', 'ps') function Normalize-List { - param([string]$Input) + param([string]$Value) - if ([string]::IsNullOrEmpty($Input)) { + if ([string]::IsNullOrEmpty($Value)) { return @() } - $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique + $items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique return $items } @@ -526,7 +526,7 @@ function Validate-Subset { # Determine agent list if (-not [string]::IsNullOrEmpty($Agents)) { - $AgentList = Normalize-List -Input $Agents + $AgentList = Normalize-List -Value $Agents if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { exit 1 } @@ -536,7 +536,7 @@ if (-not [string]::IsNullOrEmpty($Agents)) { # Determine script list if (-not [string]::IsNullOrEmpty($Scripts)) { - $ScriptList = Normalize-List -Input $Scripts + $ScriptList = Normalize-List -Value $Scripts if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { exit 1 } From 6b1f45c50cb068fa0d4785312a33e7e62cb57ba7 Mon Sep 17 00:00:00 2001 From: Kash <26795040+one-kash@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:50:43 -0500 Subject: [PATCH 149/321] Fix Claude Code CLI detection for npm-local installs (#1978) * Fix Claude Code CLI detection for npm-local installs `specify check` reports "Claude Code CLI (not found)" for users who installed Claude Code via npm-local (the default installer path, common with nvm). The binary lives at ~/.claude/local/node_modules/.bin/claude which was not checked. Add CLAUDE_NPM_LOCAL_PATH as a second well-known location alongside the existing migrate-installer path. Fixes https://github.com/github/spec-kit/issues/550 * Address Copilot review feedback - Remove unused pytest import from test_check_tool.py - Use tmp_path instead of hardcoded /nonexistent/claude for hermetic tests - Simplify redundant exists() + is_file() to just is_file() AI-assisted: Changes applied with Claude Code. * Update tests/test_check_tool.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/test_check_tool.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 13 +++-- tests/test_check_tool.py | 96 +++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/test_check_tool.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1f0eaf475d..f528535a61 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -345,6 +345,7 @@ def _build_ai_assistant_help() -> str: SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ @@ -605,13 +606,15 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: Returns: True if tool is found, False otherwise """ - # Special handling for Claude CLI after `claude migrate-installer` + # Special handling for Claude CLI local installs # See: https://github.com/github/spec-kit/issues/123 - # The migrate-installer command REMOVES the original executable from PATH - # and creates an alias at ~/.claude/local/claude instead - # This path should be prioritized over other claude executables in PATH + # See: https://github.com/github/spec-kit/issues/550 + # Claude Code can be installed in two local paths: + # 1. ~/.claude/local/claude (after `claude migrate-installer`) + # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) + # Neither path may be on the system PATH, so we check them explicitly. if tool == "claude": - if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file(): + if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): if tracker: tracker.complete(tool, "available") return True diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py new file mode 100644 index 0000000000..0eb267ba24 --- /dev/null +++ b/tests/test_check_tool.py @@ -0,0 +1,96 @@ +"""Tests for check_tool() — Claude Code CLI detection across install methods. + +Covers issue https://github.com/github/spec-kit/issues/550: + `specify check` reports "Claude Code CLI (not found)" even when claude is + installed via npm-local (the default `claude` installer path). +""" + +from unittest.mock import patch, MagicMock + +from specify_cli import check_tool + + +class TestCheckToolClaude: + """Claude CLI detection must work for all install methods.""" + + def test_detected_via_migrate_installer_path(self, tmp_path): + """claude migrate-installer puts binary at ~/.claude/local/claude.""" + fake_claude = tmp_path / "claude" + fake_claude.touch() + + # Ensure npm-local path is missing so we only exercise migrate-installer path + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_npm_local_path(self, tmp_path): + """npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + # Neither the migrate-installer path nor PATH has claude + fake_migrate = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_path(self, tmp_path): + """claude on PATH (global npm install) should still work.""" + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value="/usr/local/bin/claude"): + assert check_tool("claude") is True + + def test_not_found_when_nowhere(self, tmp_path): + """Should return False when claude is genuinely not installed.""" + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is False + + def test_tracker_updated_on_npm_local_detection(self, tmp_path): + """StepTracker should be marked 'available' for npm-local installs.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + fake_missing = tmp_path / "nonexistent" / "claude" + tracker = MagicMock() + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + result = check_tool("claude", tracker=tracker) + + assert result is True + tracker.complete.assert_called_once_with("claude", "available") + + +class TestCheckToolOther: + """Non-Claude tools should be unaffected by the fix.""" + + def test_git_detected_via_path(self): + with patch("shutil.which", return_value="/usr/bin/git"): + assert check_tool("git") is True + + def test_missing_tool(self): + with patch("shutil.which", return_value=None): + assert check_tool("nonexistent-tool") is False + + def test_kiro_fallback(self): + """kiro-cli detection should try both kiro-cli and kiro.""" + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + with patch("shutil.which", side_effect=fake_which): + assert check_tool("kiro-cli") is True \ No newline at end of file From 796b4f47c48935d2107386e4a88e624db64a2da1 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Fri, 27 Mar 2026 17:55:26 +0200 Subject: [PATCH 150/321] fix: prevent extension command shadowing (#1994) * fix: prevent extension command shadowing * Validate extension command namespaces * Reuse extension command name pattern --- extensions/EXTENSION-API-REFERENCE.md | 2 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 4 +- extensions/EXTENSION-USER-GUIDE.md | 4 +- extensions/RFC-EXTENSION-SYSTEM.md | 8 +- extensions/template/extension.yml | 4 +- src/specify_cli/extensions.py | 168 ++++++++++++- tests/test_extensions.py | 272 ++++++++++++++++++---- 7 files changed, 403 insertions(+), 59 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 6be3d0633d..721624ab81 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -44,7 +44,7 @@ provides: - name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ file: string # Required, relative path to command file description: string # Required - aliases: [string] # Optional, array of alternate names + aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands config: # Optional, array of config files - name: string # Config file name diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index c4af7ed15e..81179a073b 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -41,7 +41,7 @@ provides: - name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd} file: "commands/hello.md" description: "Say hello" - aliases: ["speckit.hello"] # Optional aliases + aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern config: # Optional: Config files - name: "my-ext-config.yml" @@ -186,7 +186,7 @@ What the extension provides. - `name`: Command name (must match `speckit.{ext-id}.{command}`) - `file`: Path to command file (relative to extension root) - `description`: Command description (optional) -- `aliases`: Alternative command names (optional, array) +- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`) ### Optional Fields diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 2fd28191ca..e136de6048 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -214,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code): # In Claude Code > /speckit.jira.specstoissues -# Or use short alias (if provided) -> /speckit.specstoissues +# Or use a namespaced alias (if provided) +> /speckit.jira.sync ``` ### Extension Configuration diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index 5d3d8e9cb2..dd4c97e8a2 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -223,7 +223,7 @@ provides: - name: "speckit.jira.specstoissues" file: "commands/specstoissues.md" description: "Create Jira hierarchy from spec and tasks" - aliases: ["speckit.specstoissues"] # Alternate names + aliases: ["speckit.jira.sync"] # Alternate names - name: "speckit.jira.discover-fields" file: "commands/discover-fields.md" @@ -1517,7 +1517,7 @@ specify extension add github-projects /speckit.github.taskstoissues ``` -**Compatibility shim** (if needed): +**Migration alias** (if needed): ```yaml # extension.yml @@ -1525,10 +1525,10 @@ provides: commands: - name: "speckit.github.taskstoissues" file: "commands/taskstoissues.md" - aliases: ["speckit.taskstoissues"] # Backward compatibility + aliases: ["speckit.github.sync-taskstoissues"] # Alternate namespaced entry point ``` -AI agent registers both names, so old scripts work. +AI agents register both names, so callers can migrate to the alternate alias without relying on deprecated global shortcuts like `/speckit.taskstoissues`. --- diff --git a/extensions/template/extension.yml b/extensions/template/extension.yml index 2f51ae7fd5..abf7e45afc 100644 --- a/extensions/template/extension.yml +++ b/extensions/template/extension.yml @@ -47,8 +47,8 @@ provides: - name: "speckit.my-extension.example" file: "commands/example.md" description: "Example command that demonstrates functionality" - # Optional: Add aliases for shorter command names - aliases: ["speckit.example"] + # Optional: Add aliases in the same namespaced format + aliases: ["speckit.my-extension.example-short"] # ADD MORE COMMANDS: Copy this block for each command # - name: "speckit.my-extension.another-command" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index ed2d187ba4..b898c65f2a 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -25,6 +25,49 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier +_FALLBACK_CORE_COMMAND_NAMES = frozenset({ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", +}) +EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") + + +def _load_core_command_names() -> frozenset[str]: + """Discover bundled core command names from the packaged templates. + + Prefer the wheel-time ``core_pack`` bundle when present, and fall back to + the source checkout when running from the repository. If neither is + available, use the baked-in fallback set so validation still works. + """ + candidate_dirs = [ + Path(__file__).parent / "core_pack" / "commands", + Path(__file__).resolve().parent.parent.parent / "templates" / "commands", + ] + + for commands_dir in candidate_dirs: + if not commands_dir.is_dir(): + continue + + command_names = { + command_file.stem + for command_file in commands_dir.iterdir() + if command_file.is_file() and command_file.suffix == ".md" + } + if command_names: + return frozenset(command_names) + + return _FALLBACK_CORE_COMMAND_NAMES + + +CORE_COMMAND_NAMES = _load_core_command_names() + class ExtensionError(Exception): """Base exception for extension-related errors.""" @@ -149,7 +192,7 @@ def _validate(self): raise ValidationError("Command missing 'name' or 'file'") # Validate command name format - if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]): + if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None: raise ValidationError( f"Invalid command name '{cmd['name']}': " "must follow pattern 'speckit.{extension}.{command}'" @@ -446,6 +489,126 @@ def __init__(self, project_root: Path): self.extensions_dir = project_root / ".specify" / "extensions" self.registry = ExtensionRegistry(self.extensions_dir) + @staticmethod + def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]: + """Collect command and alias names declared by a manifest. + + Performs install-time validation for extension-specific constraints: + - commands and aliases must use the canonical `speckit.{extension}.{command}` shape + - commands and aliases must use this extension's namespace + - command namespaces must not shadow core commands + - duplicate command/alias names inside one manifest are rejected + + Args: + manifest: Parsed extension manifest + + Returns: + Mapping of declared command/alias name -> kind ("command"/"alias") + + Raises: + ValidationError: If any declared name is invalid + """ + if manifest.id in CORE_COMMAND_NAMES: + raise ValidationError( + f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'" + ) + + declared_names: Dict[str, str] = {} + + for cmd in manifest.commands: + primary_name = cmd["name"] + aliases = cmd.get("aliases", []) + + if aliases is None: + aliases = [] + if not isinstance(aliases, list): + raise ValidationError( + f"Aliases for command '{primary_name}' must be a list" + ) + + for kind, name in [("command", primary_name)] + [ + ("alias", alias) for alias in aliases + ]: + if not isinstance(name, str): + raise ValidationError( + f"{kind.capitalize()} for command '{primary_name}' must be a string" + ) + + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) + if match is None: + raise ValidationError( + f"Invalid {kind} '{name}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + + namespace = match.group(1) + if namespace != manifest.id: + raise ValidationError( + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" + ) + + if namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) + + if name in declared_names: + raise ValidationError( + f"Duplicate command or alias '{name}' in extension manifest" + ) + + declared_names[name] = kind + + return declared_names + + def _get_installed_command_name_map( + self, + exclude_extension_id: Optional[str] = None, + ) -> Dict[str, str]: + """Return registered command and alias names for installed extensions.""" + installed_names: Dict[str, str] = {} + + for ext_id in self.registry.keys(): + if ext_id == exclude_extension_id: + continue + + manifest = self.get_extension(ext_id) + if manifest is None: + continue + + for cmd in manifest.commands: + cmd_name = cmd.get("name") + if isinstance(cmd_name, str): + installed_names.setdefault(cmd_name, ext_id) + + aliases = cmd.get("aliases", []) + if not isinstance(aliases, list): + continue + + for alias in aliases: + if isinstance(alias, str): + installed_names.setdefault(alias, ext_id) + + return installed_names + + def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: + """Reject installs that would shadow core or installed extension commands.""" + declared_names = self._collect_manifest_command_names(manifest) + installed_names = self._get_installed_command_name_map( + exclude_extension_id=manifest.id + ) + + collisions = [ + f"{name} (already provided by extension '{installed_names[name]}')" + for name in sorted(declared_names) + if name in installed_names + ] + if collisions: + raise ValidationError( + "Extension commands conflict with installed extensions:\n- " + + "\n- ".join(collisions) + ) + @staticmethod def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: """Load .extensionignore and return an ignore function for shutil.copytree. @@ -861,6 +1024,9 @@ def install_from_directory( f"Use 'specify extension remove {manifest.id}' first." ) + # Reject manifests that would shadow core commands or installed extensions. + self._validate_install_conflicts(manifest) + # Install extension dest_dir = self.extensions_dir / manifest.id if dest_dir.exists(): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 92b9839ac9..64b38547d7 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -18,6 +18,7 @@ from specify_cli.extensions import ( CatalogEntry, + CORE_COMMAND_NAMES, ExtensionManifest, ExtensionRegistry, ExtensionManager, @@ -63,7 +64,7 @@ def valid_manifest_data(): "provides": { "commands": [ { - "name": "speckit.test.hello", + "name": "speckit.test-ext.hello", "file": "commands/hello.md", "description": "Test command", } @@ -71,7 +72,7 @@ def valid_manifest_data(): }, "hooks": { "after_tasks": { - "command": "speckit.test.hello", + "command": "speckit.test-ext.hello", "optional": True, "prompt": "Run test?", } @@ -189,7 +190,18 @@ def test_valid_manifest(self, extension_dir): assert manifest.version == "1.0.0" assert manifest.description == "A test extension" assert len(manifest.commands) == 1 - assert manifest.commands[0]["name"] == "speckit.test.hello" + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + + def test_core_command_names_match_bundled_templates(self): + """Core command reservations should stay aligned with bundled templates.""" + commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands" + expected = { + command_file.stem + for command_file in commands_dir.iterdir() + if command_file.is_file() and command_file.suffix == ".md" + } + + assert CORE_COMMAND_NAMES == expected def test_missing_required_field(self, temp_dir): """Test manifest missing required field.""" @@ -589,6 +601,172 @@ def test_install_duplicate(self, extension_dir, project_dir): with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir): + """Install should reject extension IDs that shadow core commands.""" + import yaml + + ext_dir = temp_dir / "analyze-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "analyze", + "name": "Analyze Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.analyze.extra", + "file": "commands/cmd.md", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="conflicts with core command namespace"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): + """Install should reject legacy short aliases that can shadow core commands.""" + import yaml + + ext_dir = temp_dir / "alias-shortcut" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "alias-shortcut", + "name": "Alias Shortcut", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.alias-shortcut.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.shortcut"], + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): + """Install should reject commands and aliases outside the extension namespace.""" + import yaml + + ext_dir = temp_dir / "squat-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "squat-ext", + "name": "Squat Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.other-ext.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.squat-ext.ok"], + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir): + """Install should reject names already claimed by an installed legacy extension.""" + import yaml + + first_dir = temp_dir / "ext-one" + first_dir.mkdir() + (first_dir / "commands").mkdir() + first_manifest = { + "schema_version": "1.0", + "extension": { + "id": "ext-one", + "name": "Extension One", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-one.sync", + "file": "commands/cmd.md", + "aliases": ["speckit.shared.sync"], + } + ] + }, + } + (first_dir / "extension.yml").write_text(yaml.dump(first_manifest)) + (first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one" + installed_ext_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(first_dir, installed_ext_dir) + + second_dir = temp_dir / "ext-two" + second_dir.mkdir() + (second_dir / "commands").mkdir() + second_manifest = { + "schema_version": "1.0", + "extension": { + "id": "shared", + "name": "Shared Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.shared.sync", + "file": "commands/cmd.md", + } + ] + }, + } + (second_dir / "extension.yml").write_text(yaml.dump(second_manifest)) + (second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"}) + + with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"): + manager.install_from_directory(second_dir, "0.1.0", register_commands=False) + def test_remove_extension(self, extension_dir, project_dir): """Test removing an installed extension.""" manager = ExtensionManager(project_dir) @@ -852,10 +1030,10 @@ def test_register_commands_for_claude(self, extension_dir, project_dir): ) assert len(registered) == 1 - assert "speckit.test.hello" in registered + assert "speckit.test-ext.hello" in registered # Check command file was created - cmd_file = claude_dir / "speckit.test.hello.md" + cmd_file = claude_dir / "speckit.test-ext.hello.md" assert cmd_file.exists() content = cmd_file.read_text() @@ -885,9 +1063,9 @@ def test_command_with_aliases(self, project_dir, temp_dir): "provides": { "commands": [ { - "name": "speckit.alias.cmd", + "name": "speckit.ext-alias.cmd", "file": "commands/cmd.md", - "aliases": ["speckit.shortcut"], + "aliases": ["speckit.ext-alias.shortcut"], } ] }, @@ -907,10 +1085,10 @@ def test_command_with_aliases(self, project_dir, temp_dir): registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) assert len(registered) == 2 - assert "speckit.alias.cmd" in registered - assert "speckit.shortcut" in registered - assert (claude_dir / "speckit.alias.cmd.md").exists() - assert (claude_dir / "speckit.shortcut.md").exists() + assert "speckit.ext-alias.cmd" in registered + assert "speckit.ext-alias.shortcut" in registered + assert (claude_dir / "speckit.ext-alias.cmd.md").exists() + assert (claude_dir / "speckit.ext-alias.shortcut.md").exists() def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): """Codex skill cleanup should use the same mapped names as registration.""" @@ -951,11 +1129,11 @@ def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir) - skill_file = skills_dir / "speckit-test-hello" / "SKILL.md" + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() - assert "name: speckit-test-hello" in content + assert "name: speckit-test-ext-hello" in content assert "description: Test hello command" in content assert "compatibility:" in content assert "metadata:" in content @@ -982,7 +1160,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir "provides": { "commands": [ { - "name": "speckit.test.plan", + "name": "speckit.ext-scripted.plan", "file": "commands/plan.md", "description": "Scripted command", } @@ -1020,7 +1198,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-test-plan" / "SKILL.md" + skill_file = skills_dir / "speckit-ext-scripted-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -1051,9 +1229,9 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem "provides": { "commands": [ { - "name": "speckit.alias.cmd", + "name": "speckit.ext-alias-skill.cmd", "file": "commands/cmd.md", - "aliases": ["speckit.shortcut"], + "aliases": ["speckit.ext-alias-skill.shortcut"], } ] }, @@ -1070,13 +1248,13 @@ def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, tem registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - primary = skills_dir / "speckit-alias-cmd" / "SKILL.md" - alias = skills_dir / "speckit-shortcut" / "SKILL.md" + primary = skills_dir / "speckit-ext-alias-skill-cmd" / "SKILL.md" + alias = skills_dir / "speckit-ext-alias-skill-shortcut" / "SKILL.md" assert primary.exists() assert alias.exists() - assert "name: speckit-alias-cmd" in primary.read_text() - assert "name: speckit-shortcut" in alias.read_text() + assert "name: speckit-ext-alias-skill-cmd" in primary.read_text() + assert "name: speckit-ext-alias-skill-shortcut" in alias.read_text() def test_codex_skill_registration_uses_fallback_script_variant_without_init_options( self, project_dir, temp_dir @@ -1100,7 +1278,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti "provides": { "commands": [ { - "name": "speckit.fallback.plan", + "name": "speckit.ext-script-fallback.plan", "file": "commands/plan.md", } ] @@ -1132,7 +1310,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-fallback-plan" / "SKILL.md" + skill_file = skills_dir / "speckit-ext-script-fallback-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -1163,7 +1341,7 @@ def test_codex_skill_registration_handles_non_dict_init_options( "provides": { "commands": [ { - "name": "speckit.list.plan", + "name": "speckit.ext-script-list-init.plan", "file": "commands/plan.md", } ] @@ -1194,7 +1372,7 @@ def test_codex_skill_registration_handles_non_dict_init_options( registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text() + content = (skills_dir / "speckit-ext-script-list-init-plan" / "SKILL.md").read_text() assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content def test_codex_skill_registration_fallback_prefers_powershell_on_windows( @@ -1221,7 +1399,7 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( "provides": { "commands": [ { - "name": "speckit.windows.plan", + "name": "speckit.ext-script-windows-fallback.plan", "file": "commands/plan.md", } ] @@ -1253,7 +1431,7 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) - skill_file = skills_dir / "speckit-windows-plan" / "SKILL.md" + skill_file = skills_dir / "speckit-ext-script-windows-fallback-plan" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() @@ -1275,14 +1453,14 @@ def test_register_commands_for_copilot(self, extension_dir, project_dir): ) assert len(registered) == 1 - assert "speckit.test.hello" in registered + assert "speckit.test-ext.hello" in registered # Verify command file uses .agent.md extension - cmd_file = agents_dir / "speckit.test.hello.agent.md" + cmd_file = agents_dir / "speckit.test-ext.hello.agent.md" assert cmd_file.exists() # Verify NO plain .md file was created - plain_md_file = agents_dir / "speckit.test.hello.md" + plain_md_file = agents_dir / "speckit.test-ext.hello.md" assert not plain_md_file.exists() content = cmd_file.read_text() @@ -1302,12 +1480,12 @@ def test_copilot_companion_prompt_created(self, extension_dir, project_dir): ) # Verify companion .prompt.md file exists - prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md" + prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md" assert prompt_file.exists() # Verify content has correct agent frontmatter content = prompt_file.read_text() - assert content == "---\nagent: speckit.test.hello\n---\n" + assert content == "---\nagent: speckit.test-ext.hello\n---\n" def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir): """Test that aliases also get companion .prompt.md files for Copilot.""" @@ -1328,9 +1506,9 @@ def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir): "provides": { "commands": [ { - "name": "speckit.alias-copilot.cmd", + "name": "speckit.ext-alias-copilot.cmd", "file": "commands/cmd.md", - "aliases": ["speckit.shortcut-copilot"], + "aliases": ["speckit.ext-alias-copilot.shortcut"], } ] }, @@ -1357,8 +1535,8 @@ def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir): # Both primary and alias get companion .prompt.md prompts_dir = project_dir / ".github" / "prompts" - assert (prompts_dir / "speckit.alias-copilot.cmd.prompt.md").exists() - assert (prompts_dir / "speckit.shortcut-copilot.prompt.md").exists() + assert (prompts_dir / "speckit.ext-alias-copilot.cmd.prompt.md").exists() + assert (prompts_dir / "speckit.ext-alias-copilot.shortcut.prompt.md").exists() def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): """Test that non-copilot agents do NOT create .prompt.md files.""" @@ -1431,7 +1609,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): assert installed[0]["id"] == "test-ext" # Verify command registered - cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md" + cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md" assert cmd_file.exists() # Verify registry has registered commands (now a dict keyed by agent) @@ -1439,7 +1617,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): registered_commands = metadata["registered_commands"] # Check that the command is registered for at least one agent assert any( - "speckit.test.hello" in cmds + "speckit.test-ext.hello" in cmds for cmds in registered_commands.values() ) @@ -1465,8 +1643,8 @@ def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir): assert "copilot" in metadata["registered_commands"] # Verify files exist before cleanup - agent_file = agents_dir / "speckit.test.hello.agent.md" - prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md" + agent_file = agents_dir / "speckit.test-ext.hello.agent.md" + prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md" assert agent_file.exists() assert prompt_file.exists() @@ -2776,7 +2954,7 @@ def _create_extension_source(base_dir: Path, version: str, include_config: bool "provides": { "commands": [ { - "name": "speckit.test.hello", + "name": "speckit.test-ext.hello", "file": "commands/hello.md", "description": "Test command", } @@ -2784,7 +2962,7 @@ def _create_extension_source(base_dir: Path, version: str, include_config: bool }, "hooks": { "after_tasks": { - "command": "speckit.test.hello", + "command": "speckit.test-ext.hello", "optional": True, } }, @@ -2813,7 +2991,7 @@ def _create_catalog_zip(zip_path: Path, version: str): "description": "A test extension", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]}, + "provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]}, } with zipfile.ZipFile(zip_path, "w") as zf: @@ -3442,15 +3620,15 @@ def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir): [ { "extension": "test-ext", - "command": "speckit.test.hello", + "command": "speckit.test-ext.hello", "optional": False, } ], ) - assert "Executing: `/skill:speckit-test-hello`" in message - assert "EXECUTE_COMMAND: speckit.test.hello" in message - assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message + assert "Executing: `/skill:speckit-test-ext-hello`" in message + assert "EXECUTE_COMMAND: speckit.test-ext.hello" in message + assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-ext-hello" in message def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch): """Init options should be loaded once per executor instance.""" From 5be705e414767221dc98c0d89ae81e63e1ca3007 Mon Sep 17 00:00:00 2001 From: PChemGuy <39730837+pchemguy@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:14:11 +0300 Subject: [PATCH 151/321] Update README.md (#1995) Thank you! --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 95dfce7879..66eb57af8c 100644 --- a/README.md +++ b/README.md @@ -162,9 +162,18 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json): -**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress +**Categories:** -**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs +- `docs` — reads, validates, or generates spec artifacts +- `code` — reviews, validates, or modifies source code +- `process` — orchestrates workflow across phases +- `integration` — syncs with external platforms +- `visibility` — reports on project health or progress + +**Effect:** + +- `Read-only` — produces reports without modifying files +- `Read+Write` — modifies files, creates artifacts, or updates specs | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| From edaa5a7ff11c76b41ef180df40cee51bb977b52a Mon Sep 17 00:00:00 2001 From: Alexander Rampp <105718778+arampp-xitaso@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:43:57 +0100 Subject: [PATCH 152/321] fix(scripts): add correct path for copilot-instructions.md (#1997) --- scripts/bash/update-agent-context.sh | 2 +- scripts/powershell/update-agent-context.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index ede8d77d0f..831850f440 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -63,7 +63,7 @@ AGENT_TYPE="${1:-}" # Agent-specific file paths CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md" +COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" QWEN_FILE="$REPO_ROOT/QWEN.md" AGENTS_FILE="$REPO_ROOT/AGENTS.md" diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index c495d3c74d..61df427c7c 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -46,7 +46,7 @@ $NEW_PLAN = $IMPL_PLAN # Agent file paths $CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' $GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md' +$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md' $CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' $QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' $AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' From f8da535d715085d6bac06f0efe2fd4f07d221436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 27 Mar 2026 20:04:14 +0100 Subject: [PATCH 153/321] feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scripts): add --allow-existing-branch flag to create-new-feature Add an --allow-existing-branch / -AllowExistingBranch flag to both bash and PowerShell create-new-feature scripts. When the target branch already exists, the script switches to it instead of failing. The spec directory and template are still created if missing, but existing spec.md files are not overwritten (prevents data loss on re-runs). The flag is opt-in, so existing behavior is completely unchanged without it. This enables worktree-based workflows and CI/CD pipelines that create branches externally before running speckit.specify. Relates to #1931. Also addresses #1680, #841, #1921. Assisted-By: 🤖 Claude Code * fix: address PR review feedback for allow-existing-branch - Make checkout failure fatal instead of suppressing with || true (bash) - Check $LASTEXITCODE after git checkout in PowerShell - Use Test-Path -PathType Leaf for spec file existence check (PS) - Add PowerShell static assertion test for -AllowExistingBranch flag Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 34 +++-- scripts/powershell/create-new-feature.ps1 | 30 +++-- tests/test_timestamp_branches.py | 143 ++++++++++++++++++++++ 3 files changed, 188 insertions(+), 19 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index a393edd320..54ba1dbf58 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,6 +3,7 @@ set -e JSON_MODE=false +ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false @@ -14,6 +15,9 @@ while [ $i -le $# ]; do --json) JSON_MODE=true ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; --short-name) if [ $((i + 1)) -gt $# ]; then echo 'Error: --short-name requires a value' >&2 @@ -45,10 +49,11 @@ while [ $i -le $# ]; do USE_TIMESTAMP=true ;; --help|-h) - echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" @@ -69,7 +74,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi @@ -287,12 +292,19 @@ if [ "$HAS_GIT" = true ]; then if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then # Check if branch already exists if git branch --list "$BRANCH_NAME" | grep -q .; then - if [ "$USE_TIMESTAMP" = true ]; then + if [ "$ALLOW_EXISTING" = true ]; then + # Switch to the existing branch instead of failing + if ! git checkout "$BRANCH_NAME" 2>/dev/null; then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 else >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 fi - exit 1 else >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." exit 1 @@ -305,13 +317,15 @@ fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" -TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" -else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" +if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi fi # Inform the user how to persist the feature variable in their own shell diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index b1ca0ac82a..3708ea2db1 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -3,6 +3,7 @@ [CmdletBinding()] param( [switch]$Json, + [switch]$AllowExistingBranch, [string]$ShortName, [Parameter()] [long]$Number = 0, @@ -15,10 +16,11 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" @@ -33,7 +35,7 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } @@ -251,12 +253,20 @@ if ($hasGit) { # Check if branch already exists $existingBranch = git branch --list $branchName 2>$null if ($existingBranch) { - if ($Timestamp) { + if ($AllowExistingBranch) { + # Switch to the existing branch instead of failing + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } + } elseif ($Timestamp) { Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 } else { Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 } - exit 1 } else { Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." exit 1 @@ -269,12 +279,14 @@ if ($hasGit) { $featureDir = Join-Path $specsDir $branchName New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot $specFile = Join-Path $featureDir 'spec.md' -if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null +if (-not (Test-Path -PathType Leaf $specFile)) { + $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot + if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force + } else { + New-Item -ItemType File -Path $specFile | Out-Null + } } # Set the SPECIFY_FEATURE environment variable for the current session diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 7e4f88ed0c..0c9eb07b46 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -269,3 +269,146 @@ def test_e2e_sequential(self, git_repo: Path): assert (git_repo / "specs" / branch).is_dir() val = source_and_call(f'check_feature_branch "{branch}" "true"') assert val.returncode == 0 + + +# ── Allow Existing Branch Tests ────────────────────────────────────────────── + + +class TestAllowExistingBranch: + def test_allow_existing_switches_to_branch(self, git_repo: Path): + """T006: Pre-create branch, verify script switches to it.""" + subprocess.run( + ["git", "checkout", "-b", "004-pre-exist"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "pre-exist", + "--number", "4", "Pre-existing feature", + ) + assert result.returncode == 0, result.stderr + current = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, capture_output=True, text=True, + ).stdout.strip() + assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}" + + def test_allow_existing_already_on_branch(self, git_repo: Path): + """T007: Verify success when already on the target branch.""" + subprocess.run( + ["git", "checkout", "-b", "005-already-on"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "already-on", + "--number", "5", "Already on branch", + ) + assert result.returncode == 0, result.stderr + + def test_allow_existing_creates_spec_dir(self, git_repo: Path): + """T008: Verify spec directory created on existing branch.""" + subprocess.run( + ["git", "checkout", "-b", "006-spec-dir"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "spec-dir", + "--number", "6", "Spec dir feature", + ) + assert result.returncode == 0, result.stderr + assert (git_repo / "specs" / "006-spec-dir").is_dir() + assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists() + + def test_without_flag_still_errors(self, git_repo: Path): + """T009: Verify backwards compatibility (error without flag).""" + subprocess.run( + ["git", "checkout", "-b", "007-no-flag"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature", + ) + assert result.returncode != 0, "should fail without --allow-existing-branch" + assert "already exists" in result.stderr + + def test_allow_existing_no_overwrite_spec(self, git_repo: Path): + """T010: Pre-create spec.md with content, verify it is preserved.""" + subprocess.run( + ["git", "checkout", "-b", "008-no-overwrite"], + cwd=git_repo, check=True, capture_output=True, + ) + spec_dir = git_repo / "specs" / "008-no-overwrite" + spec_dir.mkdir(parents=True) + spec_file = spec_dir / "spec.md" + spec_file.write_text("# My custom spec content\n") + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "no-overwrite", + "--number", "8", "No overwrite feature", + ) + assert result.returncode == 0, result.stderr + assert spec_file.read_text() == "# My custom spec content\n" + + def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path): + """T011: Verify normal creation when branch doesn't exist.""" + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "new-branch", + "New branch feature", + ) + assert result.returncode == 0, result.stderr + current = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, capture_output=True, text=True, + ).stdout.strip() + assert "new-branch" in current + + def test_allow_existing_with_json(self, git_repo: Path): + """T012: Verify JSON output is correct.""" + import json + + subprocess.run( + ["git", "checkout", "-b", "009-json-test"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test", + "--number", "9", "JSON test", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "009-json-test" + + def test_allow_existing_no_git(self, no_git_dir: Path): + """T013: Verify flag is silently ignored in non-git repos.""" + result = run_script( + no_git_dir, "--allow-existing-branch", "--short-name", "no-git", + "No git feature", + ) + assert result.returncode == 0, result.stderr + + +class TestAllowExistingBranchPowerShell: + def test_powershell_supports_allow_existing_branch_flag(self): + """Static guard: PS script exposes and uses -AllowExistingBranch.""" + contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "-AllowExistingBranch" in contents + # Ensure the flag is referenced in script logic, not just declared + assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") From 9cb3f3d1ada48187ec098320a4ce0c8b86155b04 Mon Sep 17 00:00:00 2001 From: Valentin Date: Mon, 30 Mar 2026 19:42:23 +0300 Subject: [PATCH 154/321] feat: add product-forge extension to community catalog (#2012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product Forge — Full product lifecycle SpecKit extension by VaiYav. Covers 9 phases: research → product-spec → revalidation → bridge → plan → implement → verify → test-plan → test-run. 10 commands, MIT license, v1.1.0 https://github.com/VaiYav/speckit-product-forge Co-authored-by: Valentyn Yakovliev --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 66eb57af8c..8b2afdee66 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ The following community-contributed extensions are available in [`catalog.commun | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | +| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8d105eb849..e210dd14f9 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-27T08:22:30Z", + "updated_at": "2026-03-28T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -789,6 +789,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "product-forge": { + "name": "Product Forge", + "id": "product-forge", + "description": "Full product lifecycle: research \u2192 product spec \u2192 SpecKit \u2192 implement \u2192 verify \u2192 test", + "author": "VaiYav", + "version": "1.1.1", + "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip", + "repository": "https://github.com/VaiYav/speckit-product-forge", + "homepage": "https://github.com/VaiYav/speckit-product-forge", + "documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md", + "changelog": "https://github.com/VaiYav/speckit-product-forge/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 10, + "hooks": 0 + }, + "tags": [ + "process", + "research", + "product-spec", + "lifecycle", + "testing" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-28T00:00:00Z", + "updated_at": "2026-03-28T00:00:00Z" + }, "ralph": { "name": "Ralph Loop", "id": "ralph", From b19a7eedfaf7f9b36d187b7a08bba2b8f6d64ed7 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Tue, 31 Mar 2026 19:25:11 +0800 Subject: [PATCH 155/321] feat: add superpowers bridge extension to community catalog (#2023) * docs: correct specify extension add syntax to require extension name The specify extension add command requires the extension name as a positional argument. Many documentation files incorrectly demonstrated using the --from flag without specifying the extension name first. * feat: add superb extension to community catalog Orchestrates obra/superpowers skills within the spec-kit SDD workflow. * fix: link superb extension docs --- .../ISSUE_TEMPLATE/extension_submission.yml | 4 +- README.md | 1 + extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 2 +- extensions/EXTENSION-PUBLISHING-GUIDE.md | 2 +- extensions/EXTENSION-USER-GUIDE.md | 6 +- extensions/README.md | 4 +- extensions/catalog.community.json | 61 ++++++++++++++++--- 7 files changed, 62 insertions(+), 18 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml index d298925e74..9d3f15872b 100644 --- a/.github/ISSUE_TEMPLATE/extension_submission.yml +++ b/.github/ISSUE_TEMPLATE/extension_submission.yml @@ -12,7 +12,7 @@ body: - Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md) - Ensure your extension has a valid `extension.yml` manifest - Create a GitHub release with a version tag (e.g., v1.0.0) - - Test installation: `specify extension add --from ` + - Test installation: `specify extension add --from ` - type: input id: extension-id @@ -229,7 +229,7 @@ body: placeholder: | ```bash # Install extension - specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip + specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip # Use a command /speckit.your-extension.command-name arg1 arg2 diff --git a/README.md b/README.md index 8b2afdee66..e5318d8a53 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ The following community-contributed extensions are available in [`catalog.commun | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | +| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 81179a073b..4eb7626d8f 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -514,7 +514,7 @@ zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/ Users install with: ```bash -specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip +specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip ``` ### Option 3: Community Reference Catalog diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 25801ca176..1433738743 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -122,7 +122,7 @@ Test that users can install from your release: specify extension add --dev /path/to/your-extension # Test from GitHub archive -specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip +specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip ``` --- diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index e136de6048..190e263af2 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -160,7 +160,7 @@ This will: ```bash # From GitHub release -specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip ``` ### Install from Local Directory (Development) @@ -737,7 +737,7 @@ You can still install extensions not in your catalog using `--from`: specify extension add jira # Direct URL (bypasses catalog) -specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip +specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip # Local development specify extension add --dev /path/to/extension @@ -807,7 +807,7 @@ specify extension add --dev /path/to/extension 2. Install older version of extension: ```bash - specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip + specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip ``` ### MCP Tool Not Available diff --git a/extensions/README.md b/extensions/README.md index eb8c3c782f..a8eedf7ce8 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -59,7 +59,7 @@ Populate your `catalog.json` with approved extensions: Skip catalog curation - team members install directly using URLs: ```bash -specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip ``` **Benefits**: Quick for one-off testing or private extensions @@ -108,7 +108,7 @@ specify extension search # See what's in your catalog specify extension add # Install by name # Direct from URL (bypasses catalog) -specify extension add --from https://github.com///archive/refs/tags/.zip +specify extension add --from https://github.com///archive/refs/tags/.zip # List installed extensions specify extension list diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e210dd14f9..e24cb2f772 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,12 +1,12 @@ { "schema_version": "1.0", - "updated_at": "2026-03-28T00:00:00Z", + "updated_at": "2026-03-30T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { "name": "AI-Driven Engineering (AIDE)", "id": "aide", - "description": "A structured 7-step workflow for building new projects from scratch with AI assistants \u2014 from vision through implementation.", + "description": "A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation.", "author": "mnriem", "version": "1.0.0", "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip", @@ -170,7 +170,7 @@ "cognitive-squad": { "name": "Cognitive Squad", "id": "cognitive-squad", - "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application \u2014 with quality gates, backpropagation verification, and self-healing", + "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing", "author": "Testimonial", "version": "0.1.0", "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip", @@ -242,7 +242,7 @@ "updated_at": "2026-03-19T12:08:20Z" }, "docguard": { - "name": "DocGuard \u2014 CDD Enforcement", + "name": "DocGuard — CDD Enforcement", "id": "docguard", "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.", "author": "raccioly", @@ -379,7 +379,7 @@ "iterate": { "name": "Iterate", "id": "iterate", - "description": "Iterate on spec documents with a two-phase define-and-apply workflow \u2014 refine specs mid-implementation and go straight back to building", + "description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building", "author": "Vianca Martinez", "version": "2.0.0", "download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip", @@ -469,9 +469,9 @@ "updated_at": "2026-03-17T00:00:00Z" }, "maqa": { - "name": "MAQA \u2014 Multi-Agent & Quality Assurance", + "name": "MAQA — Multi-Agent & Quality Assurance", "id": "maqa", - "description": "Coordinator \u2192 feature \u2192 QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", + "description": "Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", "author": "GenieRobot", "version": "0.1.3", "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip", @@ -994,7 +994,7 @@ "status": { "name": "Project Status", "id": "status", - "description": "Show current SDD workflow progress \u2014 active feature, artifact status, task completion, workflow phase, and extensions summary.", + "description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.", "author": "KhawarHabibKhan", "version": "1.0.0", "download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip", @@ -1023,6 +1023,49 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" }, + "superb": { + "name": "Superpowers Bridge", + "id": "superb", + "description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.", + "author": "rbbtsn0w", + "version": "1.0.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip", + "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", + "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions", + "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md", + "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3", + "tools": [ + { + "name": "superpowers", + "version": ">=5.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 8, + "hooks": 4 + }, + "tags": [ + "methodology", + "tdd", + "code-review", + "workflow", + "superpowers", + "brainstorming", + "verification", + "debugging", + "branch-management" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-30T00:00:00Z", + "updated_at": "2026-03-30T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", @@ -1058,7 +1101,7 @@ "understanding": { "name": "Understanding", "id": "understanding", - "description": "Automated requirements quality analysis \u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", + "description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", "author": "Ladislav Bihari", "version": "3.4.0", "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip", From 40ecd44adaf975cbed1789dd828b232a385cb5ba Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:51:47 -0500 Subject: [PATCH 156/321] chore: use PEP 440 .dev0 versions on main after releases (#2032) * chore: use PEP 440 .dev0 versions on main after releases - Release-trigger workflow now adds a dev bump commit (X.Y.(Z+1).dev0) on the release branch after tagging, so main gets the dev version when the PR merges. The tag still points at the release commit. - Set current pyproject.toml to 0.4.4.dev0. - Replace broken release workflow badge with shields.io release badge. * Update .github/workflows/release-trigger.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release-trigger.yml | 23 ++++++++++++++++++++--- README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index 2b70d89e54..a451accfe6 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -139,6 +139,22 @@ jobs: git push origin "${{ steps.version.outputs.tag }}" echo "Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed" + - name: Bump to dev version + id: dev_version + run: | + IFS='.' read -r MAJOR MINOR PATCH <<< "${{ steps.version.outputs.version }}" + NEXT_DEV="$MAJOR.$MINOR.$((PATCH + 1)).dev0" + echo "dev_version=$NEXT_DEV" >> $GITHUB_OUTPUT + sed -i "s/version = \".*\"/version = \"$NEXT_DEV\"/" pyproject.toml + git add pyproject.toml + if git diff --cached --quiet; then + echo "No dev version changes to commit" + else + git commit -m "chore: begin $NEXT_DEV development" + git push origin "${{ env.branch }}" + echo "Bumped to dev version $NEXT_DEV" + fi + - name: Open pull request env: GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} @@ -146,16 +162,17 @@ jobs: gh pr create \ --base main \ --head "${{ env.branch }}" \ - --title "chore: bump version to ${{ steps.version.outputs.version }}" \ - --body "Automated version bump to ${{ steps.version.outputs.version }}. + --title "chore: release ${{ steps.version.outputs.version }}, begin ${{ steps.dev_version.outputs.dev_version }} development" \ + --body "Automated release of ${{ steps.version.outputs.version }}. This PR was created by the Release Trigger workflow. The git tag \`${{ steps.version.outputs.tag }}\` has already been pushed and the release artifacts are being built. - Merge this PR to record the version bump and changelog update on \`main\`." + Merging this PR will set \`main\` to \`${{ steps.dev_version.outputs.dev_version }}\` so that development installs are clearly marked as pre-release." - name: Summary run: | echo "✅ Version bumped to ${{ steps.version.outputs.version }}" echo "✅ Tag ${{ steps.version.outputs.tag }} created and pushed" + echo "✅ Dev version set to ${{ steps.dev_version.outputs.dev_version }}" echo "✅ PR opened to merge version bump into main" echo "🚀 Release workflow is building artifacts from the tag" diff --git a/README.md b/README.md index e5318d8a53..e49f025fd2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Release + Latest Release GitHub stars License Documentation diff --git a/pyproject.toml b/pyproject.toml index 3810238ad2..5589876474 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.3" +version = "0.4.4.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 4dff63a84e763c2455da61f20680969c20101509 Mon Sep 17 00:00:00 2001 From: dagecko Date: Tue, 31 Mar 2026 11:12:12 -0400 Subject: [PATCH 157/321] fix: harden GitHub Actions workflows (#2021) --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8b648df8c9..fdece63093 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@v23 + uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23 with: globs: | '**/*.md' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c62304388..f45c5f1071 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: Set up Python uses: actions/setup-python@v6 @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From 804cd10c7163befe00c00baef162b43b8eb02862 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:37:00 -0500 Subject: [PATCH 158/321] =?UTF-8?q?Stage=201:=20Integration=20foundation?= =?UTF-8?q?=20=E2=80=94=20base=20classes,=20manifest=20system,=20and=20reg?= =?UTF-8?q?istry=20(#1925)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Stage 1 — integration foundation (base classes, manifest, registry) Add the integrations package with: - IntegrationBase ABC and MarkdownIntegration base class - IntegrationOption dataclass for per-integration CLI options - IntegrationManifest with SHA-256 hash-tracked install/uninstall - INTEGRATION_REGISTRY (empty, populated in later stages) - 34 tests at 98% coverage Purely additive — no existing code modified. Part of #1924 * fix: normalize manifest keys to POSIX, type manifest parameter - Store manifest file keys using as_posix() after resolving relative to project root, ensuring cross-platform portable manifests - Type the manifest parameter as IntegrationManifest (via TYPE_CHECKING import) instead of Any in IntegrationBase methods * fix: symlink safety in uninstall/setup, handle invalid JSON in load - uninstall() now uses non-resolved path for deletion so symlinks themselves are removed, not their targets; resolve only for containment validation - setup() keeps unresolved dst_file for copy; resolves separately for project-root validation - load() catches json.JSONDecodeError and re-raises as ValueError with the manifest path for clearer diagnostics - Added test for invalid JSON manifest loading * fix: lexical symlink containment, assert project_root consistency - uninstall() now uses os.path.normpath for lexical containment check instead of resolve(), so in-project symlinks pointing outside are still properly removed - setup() asserts manifest.project_root matches the passed project_root to prevent path mismatches between file operations and manifest recording * fix: handle non-files in check_modified/uninstall, validate manifest key - check_modified() treats non-regular-files (dirs, symlinks) as modified instead of crashing with IsADirectoryError - uninstall() skips directories (adds to skipped list), only unlinks files and symlinks - load() validates stored integration key matches the requested key * fix: safe symlink handling in uninstall - Broken symlinks now removable (lexists check via is_symlink fallback) - Symlinks never hashed (avoids following to external targets) - Symlinks only removed with force=True, otherwise skipped * fix: robust unlink, fail-fast config validation, symlink tests - uninstall() wraps path.unlink() in try/except OSError to avoid partial cleanup on race conditions or permission errors - setup() raises ValueError on missing config or folder instead of silently returning empty - Added 3 tests: symlink in check_modified, symlink skip/force in uninstall (47 total) * fix: check_modified uses lexical containment, explicit is_symlink check - check_modified() no longer calls _validate_rel_path (which resolves symlinks); uses lexical checks (is_absolute, '..' in parts) instead - is_symlink() checked before is_file() so symlinks to files are still treated as modified - Fixed templates_dir() docstring to match actual behavior --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- src/specify_cli/integrations/__init__.py | 34 ++ src/specify_cli/integrations/base.py | 215 +++++++++++ src/specify_cli/integrations/manifest.py | 265 +++++++++++++ tests/test_integrations.py | 460 +++++++++++++++++++++++ 4 files changed, 974 insertions(+) create mode 100644 src/specify_cli/integrations/__init__.py create mode 100644 src/specify_cli/integrations/base.py create mode 100644 src/specify_cli/integrations/manifest.py create mode 100644 tests/test_integrations.py diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py new file mode 100644 index 0000000000..18292633f7 --- /dev/null +++ b/src/specify_cli/integrations/__init__.py @@ -0,0 +1,34 @@ +"""Integration registry for AI coding assistants. + +Each integration is a self-contained subpackage that handles setup/teardown +for a specific AI assistant (Copilot, Claude, Gemini, etc.). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import IntegrationBase + +# Maps integration key → IntegrationBase instance. +# Populated by later stages as integrations are migrated. +INTEGRATION_REGISTRY: dict[str, IntegrationBase] = {} + + +def _register(integration: IntegrationBase) -> None: + """Register an integration instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = integration.key + if not key: + raise ValueError("Cannot register integration with an empty key.") + if key in INTEGRATION_REGISTRY: + raise KeyError(f"Integration with key {key!r} is already registered.") + INTEGRATION_REGISTRY[key] = integration + + +def get_integration(key: str) -> IntegrationBase | None: + """Return the integration for *key*, or ``None`` if not registered.""" + return INTEGRATION_REGISTRY.get(key) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py new file mode 100644 index 0000000000..73e51ae7f3 --- /dev/null +++ b/src/specify_cli/integrations/base.py @@ -0,0 +1,215 @@ +"""Base classes for AI-assistant integrations. + +Provides: +- ``IntegrationOption`` — declares a CLI option an integration accepts. +- ``IntegrationBase`` — abstract base every integration must implement. +- ``MarkdownIntegration`` — concrete base for standard Markdown-format + integrations (the common case — subclass, set three class attrs, done). +""" + +from __future__ import annotations + +import shutil +from abc import ABC +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .manifest import IntegrationManifest + + +# --------------------------------------------------------------------------- +# IntegrationOption +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class IntegrationOption: + """Declares an option that an integration accepts via ``--integration-options``. + + Attributes: + name: The flag name (e.g. ``"--commands-dir"``). + is_flag: ``True`` for boolean flags (``--skills``). + required: ``True`` if the option must be supplied. + default: Default value when not supplied (``None`` → no default). + help: One-line description shown in ``specify integrate info``. + """ + + name: str + is_flag: bool = False + required: bool = False + default: Any = None + help: str = "" + + +# --------------------------------------------------------------------------- +# IntegrationBase — abstract base class +# --------------------------------------------------------------------------- + +class IntegrationBase(ABC): + """Abstract base class every integration must implement. + + Subclasses must set the following class-level attributes: + + * ``key`` — unique identifier, matches actual CLI tool name + * ``config`` — dict compatible with ``AGENT_CONFIG`` entries + * ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS`` + + And may optionally set: + + * ``context_file`` — path (relative to project root) of the agent + context/instructions file (e.g. ``"CLAUDE.md"``) + """ + + # -- Must be set by every subclass ------------------------------------ + + key: str = "" + """Unique integration key — should match the actual CLI tool name.""" + + config: dict[str, Any] | None = None + """Metadata dict matching the ``AGENT_CONFIG`` shape.""" + + registrar_config: dict[str, Any] | None = None + """Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape.""" + + # -- Optional --------------------------------------------------------- + + context_file: str | None = None + """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + + # -- Public API ------------------------------------------------------- + + @classmethod + def options(cls) -> list[IntegrationOption]: + """Return options this integration accepts. Default: none.""" + return [] + + def templates_dir(self) -> Path: + """Return the path to this integration's bundled templates. + + By convention, templates live in a ``templates/`` subdirectory + next to the file where the integration class is defined. + """ + import inspect + + module_file = inspect.getfile(type(self)) + return Path(module_file).resolve().parent / "templates" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install integration files into *project_root*. + + Returns the list of files created. The default implementation + copies every file from ``templates_dir()`` into the commands + directory derived from ``config``, recording each in *manifest*. + """ + created: list[Path] = [] + tpl_dir = self.templates_dir() + if not tpl_dir.is_dir(): + return created + + if not self.config: + raise ValueError( + f"{type(self).__name__}.config is not set; integration " + "subclasses must define a non-empty 'config' mapping." + ) + folder = self.config.get("folder") + if not folder: + raise ValueError( + f"{type(self).__name__}.config is missing required 'folder' entry." + ) + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + subdir = self.config.get("commands_subdir", "commands") + dest = (project_root / folder / subdir).resolve() + # Ensure destination stays within the project root + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + + dest.mkdir(parents=True, exist_ok=True) + + for src_file in sorted(tpl_dir.iterdir()): + if src_file.is_file(): + dst_file = dest / src_file.name + dst_resolved = dst_file.resolve() + rel = dst_resolved.relative_to(project_root_resolved) + shutil.copy2(src_file, dst_file) + manifest.record_existing(rel) + created.append(dst_file) + + return created + + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall integration files from *project_root*. + + Delegates to ``manifest.uninstall()`` which only removes files + whose hash still matches the recorded value (unless *force*). + + Returns ``(removed, skipped)`` file lists. + """ + return manifest.uninstall(project_root, force=force) + + # -- Convenience helpers for subclasses ------------------------------- + + def install( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """High-level install — calls ``setup()`` and returns created files.""" + return self.setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + + def uninstall( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """High-level uninstall — calls ``teardown()``.""" + return self.teardown(project_root, manifest, force=force) + + +# --------------------------------------------------------------------------- +# MarkdownIntegration — covers ~20 standard agents +# --------------------------------------------------------------------------- + +class MarkdownIntegration(IntegrationBase): + """Concrete base for integrations that use standard Markdown commands. + + Subclasses only need to set ``key``, ``config``, ``registrar_config`` + (and optionally ``context_file``). Everything else is inherited. + + The default ``setup()`` from ``IntegrationBase`` copies templates + into the agent's commands directory — which is correct for the + standard Markdown case. + """ + + # MarkdownIntegration inherits IntegrationBase.setup() as-is. + # Future stages may add markdown-specific path rewriting here. + pass diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py new file mode 100644 index 0000000000..50ac08ea3d --- /dev/null +++ b/src/specify_cli/integrations/manifest.py @@ -0,0 +1,265 @@ +"""Hash-tracked installation manifest for integrations. + +Each installed integration records the files it created together with +their SHA-256 hashes. On uninstall only files whose hash still matches +the recorded value are removed — modified files are left in place and +reported to the caller. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _sha256(path: Path) -> str: + """Return the hex SHA-256 digest of *path*.""" + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def _validate_rel_path(rel: Path, root: Path) -> Path: + """Resolve *rel* against *root* and verify it stays within *root*. + + Raises ``ValueError`` if *rel* is absolute, contains ``..`` segments + that escape *root*, or otherwise resolves outside the project root. + """ + if rel.is_absolute(): + raise ValueError( + f"Absolute paths are not allowed in manifests: {rel}" + ) + resolved = (root / rel).resolve() + root_resolved = root.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError: + raise ValueError( + f"Path {rel} resolves to {resolved} which is outside " + f"the project root {root_resolved}" + ) from None + return resolved + + +class IntegrationManifest: + """Tracks files installed by a single integration. + + Parameters: + key: Integration identifier (e.g. ``"copilot"``). + project_root: Absolute path to the project directory. + version: CLI version string recorded in the manifest. + """ + + def __init__(self, key: str, project_root: Path, version: str = "") -> None: + self.key = key + self.project_root = project_root.resolve() + self.version = version + self._files: dict[str, str] = {} # rel_path → sha256 hex + self._installed_at: str = "" + + # -- Manifest file location ------------------------------------------- + + @property + def manifest_path(self) -> Path: + """Path to the on-disk manifest JSON.""" + return self.project_root / ".specify" / "integrations" / f"{self.key}.manifest.json" + + # -- Recording files -------------------------------------------------- + + def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: + """Write *content* to *rel_path* (relative to project root) and record its hash. + + Creates parent directories as needed. Returns the absolute path + of the written file. + + Raises ``ValueError`` if *rel_path* resolves outside the project root. + """ + rel = Path(rel_path) + abs_path = _validate_rel_path(rel, self.project_root) + abs_path.parent.mkdir(parents=True, exist_ok=True) + + if isinstance(content, str): + content = content.encode("utf-8") + abs_path.write_bytes(content) + + normalized = abs_path.relative_to(self.project_root).as_posix() + self._files[normalized] = hashlib.sha256(content).hexdigest() + return abs_path + + def record_existing(self, rel_path: str | Path) -> None: + """Record the hash of an already-existing file at *rel_path*. + + Raises ``ValueError`` if *rel_path* resolves outside the project root. + """ + rel = Path(rel_path) + abs_path = _validate_rel_path(rel, self.project_root) + normalized = abs_path.relative_to(self.project_root).as_posix() + self._files[normalized] = _sha256(abs_path) + + # -- Querying --------------------------------------------------------- + + @property + def files(self) -> dict[str, str]: + """Return a copy of the ``{rel_path: sha256}`` mapping.""" + return dict(self._files) + + def check_modified(self) -> list[str]: + """Return relative paths of tracked files whose content changed on disk.""" + modified: list[str] = [] + for rel, expected_hash in self._files.items(): + rel_path = Path(rel) + # Skip paths that are absolute or attempt to escape the project root + if rel_path.is_absolute() or ".." in rel_path.parts: + continue + abs_path = self.project_root / rel_path + if not abs_path.exists() and not abs_path.is_symlink(): + continue + # Treat symlinks and non-regular-files as modified + if abs_path.is_symlink() or not abs_path.is_file(): + modified.append(rel) + continue + if _sha256(abs_path) != expected_hash: + modified.append(rel) + return modified + + # -- Uninstall -------------------------------------------------------- + + def uninstall( + self, + project_root: Path | None = None, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Remove tracked files whose hash still matches. + + Parameters: + project_root: Override for the project root. + force: If ``True``, remove files even if modified. + + Returns: + ``(removed, skipped)`` — absolute paths. + """ + root = (project_root or self.project_root).resolve() + removed: list[Path] = [] + skipped: list[Path] = [] + + for rel, expected_hash in self._files.items(): + # Use non-resolved path for deletion so symlinks themselves + # are removed, not their targets. + path = root / rel + # Validate containment lexically (without following symlinks) + # by collapsing .. segments via Path resolution on the string parts. + try: + normed = Path(os.path.normpath(path)) + normed.relative_to(root) + except (ValueError, OSError): + continue + if not path.exists() and not path.is_symlink(): + continue + # Skip directories — manifest only tracks files + if not path.is_file() and not path.is_symlink(): + skipped.append(path) + continue + # Never follow symlinks when comparing hashes. Only remove + # symlinks when forced, to avoid acting on tampered entries. + if path.is_symlink(): + if not force: + skipped.append(path) + continue + else: + if not force and _sha256(path) != expected_hash: + skipped.append(path) + continue + try: + path.unlink() + except OSError: + skipped.append(path) + continue + removed.append(path) + # Clean up empty parent directories up to project root + parent = path.parent + while parent != root: + try: + parent.rmdir() # only succeeds if empty + except OSError: + break + parent = parent.parent + + # Remove the manifest file itself + manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json" + if manifest.exists(): + manifest.unlink() + parent = manifest.parent + while parent != root: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + + return removed, skipped + + # -- Persistence ------------------------------------------------------ + + def save(self) -> Path: + """Write the manifest to disk. Returns the manifest path.""" + self._installed_at = self._installed_at or datetime.now(timezone.utc).isoformat() + data: dict[str, Any] = { + "integration": self.key, + "version": self.version, + "installed_at": self._installed_at, + "files": self._files, + } + path = self.manifest_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return path + + @classmethod + def load(cls, key: str, project_root: Path) -> IntegrationManifest: + """Load an existing manifest from disk. + + Raises ``FileNotFoundError`` if the manifest does not exist. + """ + inst = cls(key, project_root) + path = inst.manifest_path + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError( + f"Integration manifest at {path} contains invalid JSON" + ) from exc + + if not isinstance(data, dict): + raise ValueError( + f"Integration manifest at {path} must be a JSON object, " + f"got {type(data).__name__}" + ) + + files = data.get("files", {}) + if not isinstance(files, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in files.items() + ): + raise ValueError( + f"Integration manifest 'files' at {path} must be a " + "mapping of string paths to string hashes" + ) + + inst.version = data.get("version", "") + inst._installed_at = data.get("installed_at", "") + inst._files = files + + stored_key = data.get("integration", "") + if stored_key and stored_key != key: + raise ValueError( + f"Manifest at {path} belongs to integration {stored_key!r}, " + f"not {key!r}" + ) + + return inst diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000000..aeb17ae995 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,460 @@ +"""Tests for the integrations foundation (Stage 1). + +Covers: +- IntegrationOption dataclass +- IntegrationBase ABC and MarkdownIntegration base class +- IntegrationManifest — record, hash, save, load, uninstall, modified detection +- INTEGRATION_REGISTRY basics +""" + +import hashlib +import json + +import pytest + +from specify_cli.integrations import ( + INTEGRATION_REGISTRY, + _register, + get_integration, +) +from specify_cli.integrations.base import ( + IntegrationBase, + IntegrationOption, + MarkdownIntegration, +) +from specify_cli.integrations.manifest import IntegrationManifest, _sha256 + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +class _StubIntegration(MarkdownIntegration): + """Minimal concrete integration for testing.""" + + key = "stub" + config = { + "name": "Stub Agent", + "folder": ".stub/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".stub/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "STUB.md" + + +# ═══════════════════════════════════════════════════════════════════════════ +# IntegrationOption +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestIntegrationOption: + def test_defaults(self): + opt = IntegrationOption(name="--flag") + assert opt.name == "--flag" + assert opt.is_flag is False + assert opt.required is False + assert opt.default is None + assert opt.help == "" + + def test_flag_option(self): + opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills") + assert opt.is_flag is True + assert opt.default is True + assert opt.help == "Enable skills" + + def test_required_option(self): + opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path") + assert opt.required is True + + def test_frozen(self): + opt = IntegrationOption(name="--x") + with pytest.raises(AttributeError): + opt.name = "--y" # type: ignore[misc] + + +# ═══════════════════════════════════════════════════════════════════════════ +# IntegrationBase / MarkdownIntegration +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestIntegrationBase: + def test_key_and_config(self): + i = _StubIntegration() + assert i.key == "stub" + assert i.config["name"] == "Stub Agent" + assert i.registrar_config["format"] == "markdown" + assert i.context_file == "STUB.md" + + def test_options_default_empty(self): + assert _StubIntegration.options() == [] + + def test_templates_dir(self): + i = _StubIntegration() + td = i.templates_dir() + # Should point to a templates/ dir next to this test module. + # It won't exist, but the path should be well-formed. + assert td.name == "templates" + + def test_setup_no_templates_returns_empty(self, tmp_path): + """setup() gracefully returns empty list when templates dir is missing.""" + i = _StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + created = i.setup(tmp_path, manifest) + assert created == [] + + def test_setup_copies_templates(self, tmp_path, monkeypatch): + """setup() copies template files and records them in the manifest.""" + # Create templates under tmp_path so we don't mutate the source tree + tpl = tmp_path / "_templates" + tpl.mkdir() + (tpl / "speckit.plan.md").write_text("plan content", encoding="utf-8") + (tpl / "speckit.specify.md").write_text("spec content", encoding="utf-8") + + i = _StubIntegration() + monkeypatch.setattr(type(i), "templates_dir", lambda self: tpl) + + project = tmp_path / "project" + project.mkdir() + created = i.setup(project, IntegrationManifest("stub", project)) + assert len(created) == 2 + assert (project / ".stub" / "commands" / "speckit.plan.md").exists() + assert (project / ".stub" / "commands" / "speckit.specify.md").exists() + + def test_install_delegates_to_setup(self, tmp_path): + i = _StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + result = i.install(tmp_path, manifest) + assert result == [] # no templates dir → empty + + def test_uninstall_delegates_to_teardown(self, tmp_path): + i = _StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + removed, skipped = i.uninstall(tmp_path, manifest) + assert removed == [] + assert skipped == [] + + +class TestMarkdownIntegration: + def test_is_subclass_of_base(self): + assert issubclass(MarkdownIntegration, IntegrationBase) + + def test_stub_is_markdown(self): + assert isinstance(_StubIntegration(), MarkdownIntegration) + + +# ═══════════════════════════════════════════════════════════════════════════ +# IntegrationManifest +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestManifestRecordFile: + def test_record_file_writes_and_hashes(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + content = "hello world" + abs_path = m.record_file("a/b.txt", content) + + assert abs_path == tmp_path / "a" / "b.txt" + assert abs_path.read_text(encoding="utf-8") == content + expected_hash = hashlib.sha256(content.encode()).hexdigest() + assert m.files["a/b.txt"] == expected_hash + + def test_record_file_bytes(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + data = b"\x00\x01\x02" + abs_path = m.record_file("bin.dat", data) + assert abs_path.read_bytes() == data + assert m.files["bin.dat"] == hashlib.sha256(data).hexdigest() + + def test_record_existing(self, tmp_path): + f = tmp_path / "existing.txt" + f.write_text("content", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + m.record_existing("existing.txt") + assert m.files["existing.txt"] == _sha256(f) + + +class TestManifestPathTraversal: + def test_record_file_rejects_parent_traversal(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="outside"): + m.record_file("../escape.txt", "bad") + + def test_record_file_rejects_absolute_path(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="Absolute paths"): + m.record_file("/tmp/escape.txt", "bad") + + def test_record_existing_rejects_parent_traversal(self, tmp_path): + # Create a file outside the project root + escape = tmp_path.parent / "escape.txt" + escape.write_text("evil", encoding="utf-8") + try: + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="outside"): + m.record_existing("../escape.txt") + finally: + escape.unlink(missing_ok=True) + + def test_uninstall_skips_traversal_paths(self, tmp_path): + """If a manifest is corrupted with traversal paths, uninstall ignores them.""" + m = IntegrationManifest("test", tmp_path) + m.record_file("safe.txt", "good") + # Manually inject a traversal path into the manifest + m._files["../outside.txt"] = "fakehash" + m.save() + + removed, skipped = m.uninstall() + # Only the safe file should have been removed + assert len(removed) == 1 + assert removed[0].name == "safe.txt" + + +class TestManifestCheckModified: + def test_unmodified_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + assert m.check_modified() == [] + + def test_modified_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + (tmp_path / "f.txt").write_text("changed", encoding="utf-8") + assert m.check_modified() == ["f.txt"] + + def test_deleted_file_not_reported(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + (tmp_path / "f.txt").unlink() + assert m.check_modified() == [] + + def test_symlink_treated_as_modified(self, tmp_path): + """A tracked file replaced with a symlink is reported as modified.""" + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + assert m.check_modified() == ["f.txt"] + + +class TestManifestUninstall: + def test_removes_unmodified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("d/f.txt", "content") + m.save() + + removed, skipped = m.uninstall() + assert len(removed) == 1 + assert not (tmp_path / "d" / "f.txt").exists() + # Parent dir cleaned up because empty + assert not (tmp_path / "d").exists() + assert skipped == [] + + def test_skips_modified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + (tmp_path / "f.txt").write_text("modified", encoding="utf-8") + + removed, skipped = m.uninstall() + assert removed == [] + assert len(skipped) == 1 + assert (tmp_path / "f.txt").exists() + + def test_force_removes_modified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + (tmp_path / "f.txt").write_text("modified", encoding="utf-8") + + removed, skipped = m.uninstall(force=True) + assert len(removed) == 1 + assert skipped == [] + assert not (tmp_path / "f.txt").exists() + + def test_already_deleted_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + m.save() + (tmp_path / "f.txt").unlink() + + removed, skipped = m.uninstall() + assert removed == [] + assert skipped == [] + + def test_removes_manifest_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path, version="1.0") + m.record_file("f.txt", "content") + m.save() + assert m.manifest_path.exists() + + m.uninstall() + assert not m.manifest_path.exists() + + def test_cleans_empty_parent_dirs(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("a/b/c/f.txt", "content") + m.save() + + m.uninstall() + assert not (tmp_path / "a" / "b" / "c").exists() + assert not (tmp_path / "a" / "b").exists() + assert not (tmp_path / "a").exists() + + def test_preserves_nonempty_parent_dirs(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("a/b/tracked.txt", "content") + # Create an untracked sibling + (tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8") + m.save() + + m.uninstall() + assert not (tmp_path / "a" / "b" / "tracked.txt").exists() + assert (tmp_path / "a" / "b" / "other.txt").exists() + assert (tmp_path / "a" / "b").is_dir() + + def test_symlink_skipped_without_force(self, tmp_path): + """A tracked file replaced with a symlink is skipped unless force.""" + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + + removed, skipped = m.uninstall() + assert removed == [] + assert len(skipped) == 1 + assert (tmp_path / "f.txt").is_symlink() # still there + + def test_symlink_removed_with_force(self, tmp_path): + """A tracked file replaced with a symlink is removed with force.""" + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + + removed, skipped = m.uninstall(force=True) + assert len(removed) == 1 + assert not (tmp_path / "f.txt").exists() + assert target.exists() # target not deleted + + +class TestManifestPersistence: + def test_save_and_load_roundtrip(self, tmp_path): + m = IntegrationManifest("myagent", tmp_path, version="2.0.1") + m.record_file("dir/file.md", "# Hello") + m.save() + + loaded = IntegrationManifest.load("myagent", tmp_path) + assert loaded.key == "myagent" + assert loaded.version == "2.0.1" + assert loaded.files == m.files + assert loaded._installed_at == m._installed_at + + def test_manifest_path(self, tmp_path): + m = IntegrationManifest("copilot", tmp_path) + assert m.manifest_path == tmp_path / ".specify" / "integrations" / "copilot.manifest.json" + + def test_load_missing_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + IntegrationManifest.load("nonexistent", tmp_path) + + def test_save_creates_directories(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + path = m.save() + assert path.exists() + data = json.loads(path.read_text(encoding="utf-8")) + assert data["integration"] == "test" + assert "installed_at" in data + assert "f.txt" in data["files"] + + def test_save_preserves_installed_at(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + m.save() + first_ts = m._installed_at + + # Save again — timestamp should not change + m.save() + assert m._installed_at == first_ts + + +# ═══════════════════════════════════════════════════════════════════════════ +# Registry +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestRegistry: + def test_registry_starts_empty(self): + # Registry may have been populated by other tests; at minimum + # it should be a dict. + assert isinstance(INTEGRATION_REGISTRY, dict) + + def test_register_and_get(self): + stub = _StubIntegration() + _register(stub) + try: + assert get_integration("stub") is stub + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + def test_get_missing_returns_none(self): + assert get_integration("nonexistent-xyz") is None + + def test_register_empty_key_raises(self): + class EmptyKey(MarkdownIntegration): + key = "" + with pytest.raises(ValueError, match="empty key"): + _register(EmptyKey()) + + def test_register_duplicate_raises(self): + stub = _StubIntegration() + _register(stub) + try: + with pytest.raises(KeyError, match="already registered"): + _register(_StubIntegration()) + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + +class TestManifestLoadValidation: + def test_load_non_dict_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text('"just a string"', encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_bad_files_type_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"files": ["not", "a", "dict"]}), encoding="utf-8") + with pytest.raises(ValueError, match="mapping"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_bad_files_values_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8") + with pytest.raises(ValueError, match="mapping"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_invalid_json_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text("{not valid json", encoding="utf-8") + with pytest.raises(ValueError, match="invalid JSON"): + IntegrationManifest.load("bad", tmp_path) From cb16412f880daee3129194513b63112ca9bad7e1 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Wed, 1 Apr 2026 00:52:36 +0800 Subject: [PATCH 159/321] docs: ensure manual tests use local specify (#2020) * docs: ensure manual tests use local specify * docs: mention venv activation before editable install * docs: clarify Windows venv activation commands --- CONTRIBUTING.md | 15 ++++++++++- TESTING.md | 66 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b42e8fd61..9044ef5ff9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler > If your pull request introduces a large change that materially impacts the work of the CLI or the rest of the repository (e.g., you're introducing new templates, arguments, or otherwise major changes), make sure that it was **discussed and agreed upon** by the project maintainers. Pull requests with large changes that did not have a prior conversation and agreement will be closed. 1. Fork and clone the repository -1. Configure and install the dependencies: `uv sync` +1. Configure and install the dependencies: `uv sync --extra test` 1. Make sure the CLI works on your machine: `uv run specify --help` 1. Create a new branch: `git checkout -b my-branch-name` 1. Make your change, add tests, and make sure everything still works @@ -44,6 +44,9 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler 1. Push to your fork and submit a pull request 1. Wait for your pull request to be reviewed and merged. +For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md). +Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. + Here are a few things you can do that will increase the likelihood of your pull request being accepted: - Follow the project's coding conventions. @@ -62,6 +65,14 @@ When working on spec-kit: 3. Test script functionality in the `scripts/` directory 4. Ensure memory files (`memory/constitution.md`) are updated if major process changes are made +### Recommended validation flow + +For the smoothest review experience, validate changes in this order: + +1. **Run focused automated checks first** — use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early. +2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR. +3. **Use local release packages when debugging packaged output** — if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below. + ### Testing template and command changes locally Running `uv run specify init` pulls released packages, which won’t include your local changes. @@ -85,6 +96,8 @@ To test your templates, commands, and other changes locally, follow these steps: Navigate to your test project folder and open the agent to verify your implementation. +If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally. + ## AI contributions in Spec Kit > [!IMPORTANT] diff --git a/TESTING.md b/TESTING.md index 95b1bde847..1fa6b1c881 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,8 +1,59 @@ -# Manual Testing Guide +# Testing Guide + +This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md). + +Use it for three things: + +1. running quick automated checks before manual testing, +2. manually testing affected slash commands through an AI agent, and +3. capturing the results in a PR-friendly format. Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. -## Process +## Recommended order + +1. **Sync your environment** — install the project and test dependencies. +2. **Run focused automated checks** — especially for packaging, scaffolding, agent config, and generated-file changes. +3. **Run manual agent tests** — for any affected slash commands. +4. **Paste results into your PR** — include both command-selection reasoning and manual test results. + +## Quick automated checks + +Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring. + +### Environment setup + +```bash +cd +uv sync --extra test +source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 +``` + +### Generated package structure and content + +```bash +uv run python -m pytest tests/test_core_pack_scaffold.py -q +``` + +This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`. + +### Agent configuration and release wiring consistency + +```bash +uv run python -m pytest tests/test_agent_config_consistency.py -q +``` + +Run this when you change agent metadata, release scripts, context update scripts, or artifact naming. + +### Optional single-agent packaging spot check + +```bash +AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0 +``` + +Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination. + +## Manual testing process 1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. 2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)). @@ -13,19 +64,22 @@ Any change that affects a slash command's behavior requires manually testing tha ## Setup ```bash -# Install the CLI from your local branch +# Install the project and test dependencies from your local branch cd -uv venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv sync --extra test +source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 uv pip install -e . +# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. # Initialize a test project using your local changes -specify init /tmp/speckit-test --ai --offline +uv run specify init /tmp/speckit-test --ai --offline cd /tmp/speckit-test # Open in your agent ``` +If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md). + ## Reporting results Paste this into your PR: From b8335a532ca8218200b53b42128516aa3c5d4161 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 1 Apr 2026 02:07:09 +0500 Subject: [PATCH 160/321] docs: sync AGENTS.md with AGENT_CONFIG for missing agents (#2025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: sync AGENTS.md with AGENT_CONFIG for missing agents Add Antigravity (agy) and Mistral Vibe (vibe) to the supported agents table — both exist in AGENT_CONFIG but were missing from documentation. Fix Agent Categories section: - Move Cursor from CLI-Based to IDE-Based (requires_cli is False) - Add missing CLI agents: Codex, Auggie, iFlow - Add missing IDE agents: Kilo Code, Roo Code, Trae, Antigravity Update Command File Formats and Directory Conventions sections to include all agents that were previously undocumented. Co-Authored-By: Claude Sonnet 4.6 * docs: address Copilot review feedback on AGENTS.md - Fix Cursor table entry: CLI Tool → N/A (IDE-based), matches requires_cli=False in AGENT_CONFIG - Fix Antigravity directory: .agent/commands/ → .agent/skills/ (skills-based per AGENT_SKILLS_MIGRATIONS) - Add opencode singular command exception to Directory Conventions (.opencode/command/) Co-Authored-By: Claude Sonnet 4.6 * docs: add --ai key hint for Cursor in AGENTS.md Cursor's AGENT_CONFIG key is cursor-agent but the CLI Tool column shows N/A (IDE-based). Adding the --ai flag reference in the Description column so readers know the correct key to use with specify init --ai cursor-agent. Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: add --ai key hint for Antigravity in AGENTS.md Antigravity's AGENT_CONFIG key is 'agy' and requires --ai-skills flag, but the table only showed N/A (IDE-based). Adding the --ai flag reference so readers know to use: specify init --ai agy --ai-skills Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: clarify Antigravity directory convention for both modes AGENT_CONFIG generates .agent/commands/ by default, but --ai-skills uses .agent/skills/. Document both paths in Directory Conventions. Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: add Tabnine nested path exception to Directory Conventions Tabnine uses .tabnine/agent/commands/ which has an extra path segment compared to the usual ./commands/ convention. Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: add Copilot to Markdown list, fix Antigravity dir convention - Add GitHub Copilot to the Markdown format "Used by" list (it uses markdown with .agent.md extension and chat mode frontmatter) - Clarify Antigravity uses .agent/skills/ (requires --ai-skills); .agent/commands/ is deprecated/legacy Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: add missing IDE agents to Directory Conventions Add Roo Code and IBM Bob to the IDE agents list in Directory Conventions so all IDE-based agents are documented. Co-Authored-By: Claude Sonnet 4.6 * docs: document Codex --ai-skills requirement in all sections Codex CLI requires --ai-skills when explicitly selected via specify init --ai codex (exits with migration error otherwise). Updated table, CLI-Based Agents list, and Directory Conventions. Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: fix Antigravity dir to match AGENT_CONFIG, add Amp shared folder - Antigravity table row: .agent/skills/ → .agent/commands/ (matches AGENT_CONFIG folder + commands_subdir; skills mode via --ai-skills) - Add shared .agents/ folder exception for Amp and Codex - Move Codex from Skills-based to Shared folder section (it shares .agents/ with Amp) Addresses Copilot review feedback. Co-Authored-By: Claude Sonnet 4.6 * docs: clarify Antigravity skills path is required, not optional Reword to make clear .agent/skills/ is the effective path and .agent/commands/ is deprecated, since CLI enforces --ai-skills. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- AGENTS.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a15e0bc4b7..eb3d27065f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,10 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI | | **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | | **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | -| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | +| **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) | | **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (skills) | +| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) | | **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | | **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains | | **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE | @@ -50,6 +50,8 @@ Specify supports multiple AI agents by generating agent-specific command files a | **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | +| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) | +| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | ### Step-by-Step Integration Guide @@ -316,32 +318,40 @@ Require a command-line tool to be installed: - **Claude Code**: `claude` CLI - **Gemini CLI**: `gemini` CLI -- **Cursor**: `cursor-agent` CLI - **Qwen Code**: `qwen` CLI - **opencode**: `opencode` CLI +- **Codex CLI**: `codex` CLI (requires `--ai-skills`) - **Junie**: `junie` CLI -- **Kiro CLI**: `kiro-cli` CLI +- **Auggie CLI**: `auggie` CLI - **CodeBuddy CLI**: `codebuddy` CLI - **Qoder CLI**: `qodercli` CLI +- **Kiro CLI**: `kiro-cli` CLI - **Amp**: `amp` CLI - **SHAI**: `shai` CLI - **Tabnine CLI**: `tabnine` CLI - **Kimi Code**: `kimi` CLI +- **Mistral Vibe**: `vibe` CLI - **Pi Coding Agent**: `pi` CLI +- **iFlow CLI**: `iflow` CLI ### IDE-Based Agents Work within integrated development environments: - **GitHub Copilot**: Built into VS Code/compatible editors +- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`) - **Windsurf**: Built into Windsurf IDE +- **Kilo Code**: Built into Kilo Code IDE +- **Roo Code**: Built into Roo Code IDE - **IBM Bob**: Built into IBM Bob IDE +- **Trae**: Built into Trae IDE +- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`) ## Command File Formats ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi +Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow **Standard format:** @@ -379,15 +389,29 @@ Command content with {SCRIPT} and {{args}} placeholders. ## Directory Conventions - **CLI agents**: Usually `./commands/` +- **Singular command exception**: + - opencode: `.opencode/command/` (singular `command`, not `commands`) +- **Nested path exception**: + - Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment) +- **Shared `.agents/` folder**: + - Amp: `.agents/commands/` (shared folder, not `.amp/`) + - Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-`) - **Skills-based exceptions**: - - Codex: `.agents/skills/` (skills, invoked as `$speckit-`) + - Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-`) - **Prompt-based exceptions**: - Kiro CLI: `.kiro/prompts/` - Pi: `.pi/prompts/` + - Mistral Vibe: `.vibe/prompts/` +- **Rules-based exceptions**: + - Trae: `.trae/rules/` - **IDE agents**: Follow IDE-specific patterns: - Copilot: `.github/agents/` - Cursor: `.cursor/commands/` - Windsurf: `.windsurf/workflows/` + - Kilo Code: `.kilocode/workflows/` + - Roo Code: `.roo/commands/` + - IBM Bob: `.bob/commands/` + - Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated) ## Argument Patterns From 3899dcc0d4aac56700f9d91205af77f090c572ca Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:40:32 -0500 Subject: [PATCH 161/321] =?UTF-8?q?Stage=202:=20Copilot=20integration=20?= =?UTF-8?q?=E2=80=94=20proof=20of=20concept=20with=20shared=20template=20p?= =?UTF-8?q?rimitives=20(#2035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Stage 2a — CopilotIntegration with shared template primitives - base.py: added granular primitives (shared_commands_dir, shared_templates_dir, list_command_templates, command_filename, commands_dest, copy_command_to_directory, record_file_in_manifest, write_file_and_record, process_template) - CopilotIntegration: uses primitives to produce .agent.md commands, companion .prompt.md files, and .vscode/settings.json - Verified byte-for-byte parity with old release script output - Copilot auto-registered in INTEGRATION_REGISTRY - 70 tests (22 new: base primitives + copilot integration) Part of #1924 * feat: Stage 2b — --integration flag, routing, agent.json, shared infra - Added --integration flag to init() (mutually exclusive with --ai) - --ai copilot auto-promotes to integration path with migration nudge - Integration setup writes .specify/agent.json with integration key - _install_shared_infra() copies scripts and templates to .specify/ - init-options.json records 'integration' key when used - 4 new CLI tests: mutual exclusivity, unknown rejection, copilot end-to-end, auto-promote (74 total integration tests) Part of #1924 * feat: Stage 2 completion — integration scripts, integration.json, shared manifest - Added copilot/scripts/update-context.sh and .ps1 (thin wrappers that delegate to the shared update-agent-context script) - CopilotIntegration.setup() installs integration scripts to .specify/integrations/copilot/scripts/ - Renamed agent.json → integration.json with script paths - _install_shared_infra() now tracks files in integration-shared.manifest.json - Updated tests: scripts installed, integration.json has script paths, shared manifest recorded (74 tests) Part of #1924 * refactor: rename shared manifest to speckit.manifest.json Cleaner naming — the shared infrastructure (scripts, templates) belongs to spec-kit itself, not to any specific integration. * fix: copilot update-context scripts reflect target architecture Scripts now source shared functions (via SPECKIT_SOURCE_ONLY=1) and call update_agent_file directly with .github/copilot-instructions.md, rather than delegating back to the shared case statement. * fix: simplify copilot scripts — dispatcher sources common functions Integration scripts now contain only copilot-specific logic (target path + agent name). The dispatcher is responsible for sourcing shared functions before calling the integration script. * fix: copilot update-context scripts are self-contained implementations These scripts ARE the implementation — the dispatcher calls them. They source common.sh + update-agent-context functions, gather feature/plan data, then call update_agent_file with the copilot target path (.github/copilot-instructions.md). * docs: add Stage 7 activation note to copilot update-context scripts * test: add complete file inventory test for copilot integration Validates every single file (37 total) produced by specify init --integration copilot --script sh --no-git. * test: add PowerShell file inventory test for copilot integration Validates all 37 files produced by --script ps variant, including .specify/scripts/powershell/ instead of bash. * refactor: split test_integrations.py into tests/integrations/ directory - test_base.py: IntegrationOption, IntegrationBase, MarkdownIntegration, primitives - test_manifest.py: IntegrationManifest, path traversal, persistence, validation - test_registry.py: INTEGRATION_REGISTRY - test_copilot.py: CopilotIntegration unit tests - test_cli.py: --integration flag, auto-promote, file inventories (sh + ps) - conftest.py: shared StubIntegration helper 76 integration tests + 48 consistency tests = 124 total, all passing. * refactor: move file inventory tests from test_cli to test_copilot File inventories are copilot-specific. test_cli.py now only tests CLI flag mechanics (mutual exclusivity, unknown rejection, auto-promote). * fix: skip JSONC merge to preserve user settings, fix docstring - _merge_vscode_settings() now returns early (skips merge) when existing settings.json can't be parsed (e.g. JSONC with comments), instead of overwriting with empty settings - Updated _install_shared_infra() docstring to match implementation (scripts + templates, speckit.manifest.json) * fix: warn user when JSONC settings merge is skipped * fix: show template content when JSONC merge is skipped User now sees the exact settings they should add manually. * fix: document process_template requirement, merge scripts without rmtree - base.py setup() docstring now explicitly states raw copy behavior and directs to CopilotIntegration for process_template example - _install_shared_infra() uses merge/overwrite instead of rmtree to preserve user-added files under .specify/scripts/ * fix: don't overwrite pre-existing shared scripts or templates Only write files that don't already exist — preserves any user modifications to shared scripts (common.sh etc.) and templates. * fix: warn user about skipped pre-existing shared files Lists all shared scripts and templates that were not copied because they already existed in the project. * test: add test for shared infra skip behavior on pre-existing files Verifies that _install_shared_infra() preserves user-modified scripts and templates while still installing missing ones. * fix: address review — containment check, deterministic prompts, manifest accuracy - CopilotIntegration.setup() adds dest containment check (relative_to) - Companion prompts generated from templates list, not directory glob - _install_shared_infra() only records files actually copied (not pre-existing) - VS Code settings tests made unconditional (assert template exists) - Inventory tests use .as_posix() for cross-platform paths * fix: correct PS1 function names, document SPECKIT_SOURCE_ONLY prerequisite - Fixed Get-FeaturePaths → Get-FeaturePathsEnv, Read-PlanData → Parse-PlanData - Documented that shared scripts must guard Main with SPECKIT_SOURCE_ONLY before these integration scripts can be activated (Stage 7) * fix: add dict type check for settings merge, simplify PS1 to subprocess - _merge_vscode_settings() skips merge with warning if parsed JSON is not a dict (array, null, etc.) - PS1 update-context.ps1 uses & invocation instead of dot-sourcing since the shared script runs Main unconditionally * fix: skip-write on no-op merge, bash subprocess, dynamic integration list - _merge_vscode_settings() only writes when keys were actually added - update-context.sh uses exec subprocess like PS1 version - Unknown integration error lists available integrations dynamically * fix: align path rewriting with release script, add .specify/.specify/ fix Path rewrite regex matches the release script's rewrite_paths() exactly (verified byte-identical output). Added .specify/.specify/ double-prefix fix for additional safety. --- src/specify_cli/__init__.py | 154 +++++++++- src/specify_cli/integrations/__init__.py | 12 + src/specify_cli/integrations/base.py | 266 +++++++++++++++--- .../integrations/copilot/__init__.py | 197 +++++++++++++ .../copilot/scripts/update-context.ps1 | 22 ++ .../copilot/scripts/update-context.sh | 22 ++ tests/integrations/__init__.py | 0 tests/integrations/conftest.py | 23 ++ tests/integrations/test_base.py | 169 +++++++++++ tests/integrations/test_cli.py | 122 ++++++++ tests/integrations/test_copilot.py | 266 ++++++++++++++++++ .../test_manifest.py} | 219 +------------- tests/integrations/test_registry.py | 45 +++ 13 files changed, 1263 insertions(+), 254 deletions(-) create mode 100644 src/specify_cli/integrations/copilot/__init__.py create mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.ps1 create mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.sh create mode 100644 tests/integrations/__init__.py create mode 100644 tests/integrations/conftest.py create mode 100644 tests/integrations/test_base.py create mode 100644 tests/integrations/test_cli.py create mode 100644 tests/integrations/test_copilot.py rename tests/{test_integrations.py => integrations/test_manifest.py} (52%) create mode 100644 tests/integrations/test_registry.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f528535a61..e53d7f18e6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1197,6 +1197,84 @@ def _locate_release_script() -> tuple[Path, str]: raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") +def _install_shared_infra( + project_path: Path, + script_type: str, + tracker: StepTracker | None = None, +) -> bool: + """Install shared infrastructure files into *project_path*. + + Copies ``.specify/scripts/`` and ``.specify/templates/`` from the + bundled core_pack or source checkout. Tracks all installed files + in ``speckit.manifest.json``. + Returns ``True`` on success. + """ + from .integrations.manifest import IntegrationManifest + + core = _locate_core_pack() + manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) + + # Scripts + if core and (core / "scripts").is_dir(): + scripts_src = core / "scripts" + else: + repo_root = Path(__file__).parent.parent.parent + scripts_src = repo_root / "scripts" + + skipped_files: list[str] = [] + + if scripts_src.is_dir(): + dest_scripts = project_path / ".specify" / "scripts" + dest_scripts.mkdir(parents=True, exist_ok=True) + variant_dir = "bash" if script_type == "sh" else "powershell" + variant_src = scripts_src / variant_dir + if variant_src.is_dir(): + dest_variant = dest_scripts / variant_dir + dest_variant.mkdir(parents=True, exist_ok=True) + # Merge without overwriting — only add files that don't exist yet + for src_path in variant_src.rglob("*"): + if src_path.is_file(): + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + if dst_path.exists(): + skipped_files.append(str(dst_path.relative_to(project_path))) + else: + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_path, dst_path) + rel = dst_path.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + # Page templates (not command templates, not vscode-settings.json) + if core and (core / "templates").is_dir(): + templates_src = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_src = repo_root / "templates" + + if templates_src.is_dir(): + dest_templates = project_path / ".specify" / "templates" + dest_templates.mkdir(parents=True, exist_ok=True) + for f in templates_src.iterdir(): + if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): + dst = dest_templates / f.name + if dst.exists(): + skipped_files.append(str(dst.relative_to(project_path))) + else: + shutil.copy2(f, dst) + rel = dst.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + if skipped_files: + import logging + logging.getLogger(__name__).warning( + "The following shared files already exist and were not overwritten:\n%s", + "\n".join(f" {f}" for f in skipped_files), + ) + + manifest.save() + return True + + def scaffold_from_core_pack( project_path: Path, ai_assistant: str, @@ -1828,6 +1906,7 @@ def init( offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), + integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), ): """ Initialize a new Specify project. @@ -1889,6 +1968,35 @@ def init( if ai_assistant: ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) + # --integration and --ai are mutually exclusive + if integration and ai_assistant: + console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") + console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path") + raise typer.Exit(1) + + # Auto-promote: --ai copilot → integration path with a nudge + use_integration = False + if integration: + from .integrations import INTEGRATION_REGISTRY, get_integration + resolved_integration = get_integration(integration) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY)) + console.print(f"[yellow]Available integrations:[/yellow] {available}") + raise typer.Exit(1) + use_integration = True + # Map integration key to the ai_assistant variable for downstream compatibility + ai_assistant = integration + elif ai_assistant == "copilot": + from .integrations import get_integration + resolved_integration = get_integration("copilot") + if resolved_integration: + use_integration = True + console.print( + "[dim]Tip: Use [bold]--integration copilot[/bold] instead of " + "--ai copilot. The --ai flag will be deprecated in a future release.[/dim]" + ) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -2057,7 +2165,10 @@ def init( "This will become the default in v0.6.0." ) - if use_github: + if use_integration: + tracker.add("integration", "Install integration") + tracker.add("shared-infra", "Install shared infrastructure") + elif use_github: for key, label in [ ("fetch", "Fetch latest release"), ("download", "Download template"), @@ -2092,7 +2203,39 @@ def init( verify = not skip_tls local_ssl_context = ssl_context if verify else False - if use_github: + if use_integration: + # Integration-based scaffolding (new path) + from .integrations.manifest import IntegrationManifest + tracker.start("integration") + manifest = IntegrationManifest( + resolved_integration.key, project_path, version=get_speckit_version() + ) + resolved_integration.setup( + project_path, manifest, + script_type=selected_script, + ) + manifest.save() + + # Write .specify/integration.json + script_ext = "sh" if selected_script == "sh" else "ps1" + integration_json = project_path / ".specify" / "integration.json" + integration_json.parent.mkdir(parents=True, exist_ok=True) + integration_json.write_text(json.dumps({ + "integration": resolved_integration.key, + "version": get_speckit_version(), + "scripts": { + "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", + }, + }, indent=2) + "\n", encoding="utf-8") + + tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) + + # Install shared infrastructure (scripts, templates) + tracker.start("shared-infra") + _install_shared_infra(project_path, selected_script, tracker=tracker) + tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") + + elif use_github: with httpx.Client(verify=local_ssl_context) as local_client: download_and_extract_template( project_path, @@ -2227,7 +2370,7 @@ def init( # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. - save_init_options(project_path, { + init_opts = { "ai": selected_ai, "ai_skills": ai_skills, "ai_commands_dir": ai_commands_dir, @@ -2237,7 +2380,10 @@ def init( "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), - }) + } + if use_integration: + init_opts["integration"] = resolved_integration.key + save_init_options(project_path, init_opts) # Install preset if specified if preset: diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 18292633f7..e5ddc5c5a2 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -32,3 +32,15 @@ def _register(integration: IntegrationBase) -> None: def get_integration(key: str) -> IntegrationBase | None: """Return the integration for *key*, or ``None`` if not registered.""" return INTEGRATION_REGISTRY.get(key) + + +# -- Register built-in integrations -------------------------------------- + +def _register_builtins() -> None: + """Register all built-in integrations.""" + from .copilot import CopilotIntegration + + _register(CopilotIntegration()) + + +_register_builtins() diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 73e51ae7f3..012b45c4c2 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -9,6 +9,7 @@ from __future__ import annotations +import re import shutil from abc import ABC from dataclasses import dataclass @@ -84,35 +85,65 @@ def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] - def templates_dir(self) -> Path: - """Return the path to this integration's bundled templates. + # -- Primitives — building blocks for setup() ------------------------- - By convention, templates live in a ``templates/`` subdirectory - next to the file where the integration class is defined. + def shared_commands_dir(self) -> Path | None: + """Return path to the shared command templates directory. + + Checks ``core_pack/commands/`` (wheel install) first, then + ``templates/commands/`` (source checkout). Returns ``None`` + if neither exists. """ import inspect - module_file = inspect.getfile(type(self)) - return Path(module_file).resolve().parent / "templates" + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "commands", + pkg_dir.parent.parent / "templates" / "commands", + ]: + if candidate.is_dir(): + return candidate + return None - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Install integration files into *project_root*. + def shared_templates_dir(self) -> Path | None: + """Return path to the shared page templates directory. - Returns the list of files created. The default implementation - copies every file from ``templates_dir()`` into the commands - directory derived from ``config``, recording each in *manifest*. + Contains ``vscode-settings.json``, ``spec-template.md``, etc. + Checks ``core_pack/templates/`` then ``templates/``. """ - created: list[Path] = [] - tpl_dir = self.templates_dir() - if not tpl_dir.is_dir(): - return created + import inspect + + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "templates", + pkg_dir.parent.parent / "templates", + ]: + if candidate.is_dir(): + return candidate + return None + + def list_command_templates(self) -> list[Path]: + """Return sorted list of command template files from the shared directory.""" + cmd_dir = self.shared_commands_dir() + if not cmd_dir or not cmd_dir.is_dir(): + return [] + return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md") + + def command_filename(self, template_name: str) -> str: + """Return the destination filename for a command template. + + *template_name* is the stem of the source file (e.g. ``"plan"``). + Default: ``speckit.{template_name}.md``. Subclasses override + to change the extension or naming convention. + """ + return f"speckit.{template_name}.md" + + def commands_dest(self, project_root: Path) -> Path: + """Return the absolute path to the commands output directory. + Derived from ``config["folder"]`` and ``config["commands_subdir"]``. + Raises ``ValueError`` if ``config`` or ``folder`` is missing. + """ if not self.config: raise ValueError( f"{type(self).__name__}.config is not set; integration " @@ -123,6 +154,179 @@ def setup( raise ValueError( f"{type(self).__name__}.config is missing required 'folder' entry." ) + subdir = self.config.get("commands_subdir", "commands") + return project_root / folder / subdir + + # -- File operations — granular primitives for setup() ---------------- + + @staticmethod + def copy_command_to_directory( + src: Path, + dest_dir: Path, + filename: str, + ) -> Path: + """Copy a command template to *dest_dir* with the given *filename*. + + Creates *dest_dir* if needed. Returns the absolute path of the + written file. The caller can post-process the file before + recording it in the manifest. + """ + dest_dir.mkdir(parents=True, exist_ok=True) + dst = dest_dir / filename + shutil.copy2(src, dst) + return dst + + @staticmethod + def record_file_in_manifest( + file_path: Path, + project_root: Path, + manifest: IntegrationManifest, + ) -> None: + """Hash *file_path* and record it in *manifest*. + + *file_path* must be inside *project_root*. + """ + rel = file_path.resolve().relative_to(project_root.resolve()) + manifest.record_existing(rel) + + @staticmethod + def write_file_and_record( + content: str, + dest: Path, + project_root: Path, + manifest: IntegrationManifest, + ) -> Path: + """Write *content* to *dest*, hash it, and record in *manifest*. + + Creates parent directories as needed. Returns *dest*. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + rel = dest.resolve().relative_to(project_root.resolve()) + manifest.record_existing(rel) + return dest + + @staticmethod + def process_template( + content: str, + agent_name: str, + script_type: str, + arg_placeholder: str = "$ARGUMENTS", + ) -> str: + """Process a raw command template into agent-ready content. + + Performs the same transformations as the release script: + 1. Extract ``scripts.`` value from YAML frontmatter + 2. Replace ``{SCRIPT}`` with the extracted script command + 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` + 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter + 5. Replace ``{ARGS}`` with *arg_placeholder* + 6. Replace ``__AGENT__`` with *agent_name* + 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + """ + # 1. Extract script command from frontmatter + script_command = "" + script_pattern = re.compile( + rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE + ) + # Find the scripts: block + in_scripts = False + for line in content.splitlines(): + if line.strip() == "scripts:": + in_scripts = True + continue + if in_scripts and line and not line[0].isspace(): + in_scripts = False + if in_scripts: + m = script_pattern.match(line) + if m: + script_command = m.group(1).strip() + break + + # 2. Replace {SCRIPT} + if script_command: + content = content.replace("{SCRIPT}", script_command) + + # 3. Extract agent_script command + agent_script_command = "" + in_agent_scripts = False + for line in content.splitlines(): + if line.strip() == "agent_scripts:": + in_agent_scripts = True + continue + if in_agent_scripts and line and not line[0].isspace(): + in_agent_scripts = False + if in_agent_scripts: + m = script_pattern.match(line) + if m: + agent_script_command = m.group(1).strip() + break + + if agent_script_command: + content = content.replace("{AGENT_SCRIPT}", agent_script_command) + + # 4. Strip scripts: and agent_scripts: sections from frontmatter + lines = content.splitlines(keepends=True) + output_lines: list[str] = [] + in_frontmatter = False + skip_section = False + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 1: + in_frontmatter = True + else: + in_frontmatter = False + skip_section = False + output_lines.append(line) + continue + if in_frontmatter: + if stripped in ("scripts:", "agent_scripts:"): + skip_section = True + continue + if skip_section: + if line[0:1].isspace(): + continue # skip indented content under scripts/agent_scripts + skip_section = False + output_lines.append(line) + content = "".join(output_lines) + + # 5. Replace {ARGS} + content = content.replace("{ARGS}", arg_placeholder) + + # 6. Replace __AGENT__ + content = content.replace("__AGENT__", agent_name) + + # 7. Rewrite paths (matches release script's rewrite_paths()) + content = re.sub(r"(/?)memory/", r".specify/memory/", content) + content = re.sub(r"(/?)scripts/", r".specify/scripts/", content) + content = re.sub(r"(/?)templates/", r".specify/templates/", content) + # Fix double-prefix (same as release script's .specify.specify/ fix) + content = content.replace(".specify.specify/", ".specify/") + content = content.replace(".specify/.specify/", ".specify/") + + return content + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install integration command files into *project_root*. + + Returns the list of files created. Copies raw templates without + processing. Integrations that need placeholder replacement + (e.g. ``{SCRIPT}``, ``__AGENT__``) should override ``setup()`` + and call ``process_template()`` in their own loop — see + ``CopilotIntegration`` for an example. + """ + templates = self.list_command_templates() + if not templates: + return [] project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: @@ -130,9 +334,8 @@ def setup( f"manifest.project_root ({manifest.project_root}) does not match " f"project_root ({project_root_resolved})" ) - subdir = self.config.get("commands_subdir", "commands") - dest = (project_root / folder / subdir).resolve() - # Ensure destination stays within the project root + + dest = self.commands_dest(project_root).resolve() try: dest.relative_to(project_root_resolved) except ValueError as exc: @@ -141,16 +344,13 @@ def setup( f"project root {project_root_resolved}" ) from exc - dest.mkdir(parents=True, exist_ok=True) + created: list[Path] = [] - for src_file in sorted(tpl_dir.iterdir()): - if src_file.is_file(): - dst_file = dest / src_file.name - dst_resolved = dst_file.resolve() - rel = dst_resolved.relative_to(project_root_resolved) - shutil.copy2(src_file, dst_file) - manifest.record_existing(rel) - created.append(dst_file) + for src_file in templates: + dst_name = self.command_filename(src_file.stem) + dst_file = self.copy_command_to_directory(src_file, dest, dst_name) + self.record_file_in_manifest(dst_file, project_root, manifest) + created.append(dst_file) return created diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py new file mode 100644 index 0000000000..0c5354d533 --- /dev/null +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -0,0 +1,197 @@ +"""Copilot integration — GitHub Copilot in VS Code. + +Copilot has several unique behaviors compared to standard markdown agents: +- Commands use ``.agent.md`` extension (not ``.md``) +- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` +- Installs ``.vscode/settings.json`` with prompt file recommendations +- Context file lives at ``.github/copilot-instructions.md`` +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase +from ..manifest import IntegrationManifest + + +class CopilotIntegration(IntegrationBase): + """Integration for GitHub Copilot in VS Code.""" + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "agents", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".github/agents", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".agent.md", + } + context_file = ".github/copilot-instructions.md" + + def command_filename(self, template_name: str) -> str: + """Copilot commands use ``.agent.md`` extension.""" + return f"speckit.{template_name}.agent.md" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install copilot commands, companion prompts, and VS Code settings. + + Uses base class primitives to: read templates, process them + (replace placeholders, strip script blocks, rewrite paths), + write as ``.agent.md``, then add companion prompts and VS Code settings. + """ + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + templates = self.list_command_templates() + if not templates: + return [] + + dest = self.commands_dest(project_root) + dest_resolved = dest.resolve() + try: + dest_resolved.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest_resolved} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + created: list[Path] = [] + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + + # 1. Process and write command files as .agent.md + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # 2. Generate companion .prompt.md files from the templates we just wrote + prompts_dir = project_root / ".github" / "prompts" + for src_file in templates: + cmd_name = f"speckit.{src_file.stem}" + prompt_content = f"---\nagent: {cmd_name}\n---\n" + prompt_file = self.write_file_and_record( + prompt_content, + prompts_dir / f"{cmd_name}.prompt.md", + project_root, + manifest, + ) + created.append(prompt_file) + + # Write .vscode/settings.json + settings_src = self._vscode_settings_path() + if settings_src and settings_src.is_file(): + dst_settings = project_root / ".vscode" / "settings.json" + dst_settings.parent.mkdir(parents=True, exist_ok=True) + if dst_settings.exists(): + # Merge into existing — don't track since we can't safely + # remove the user's settings file on uninstall. + self._merge_vscode_settings(settings_src, dst_settings) + else: + shutil.copy2(settings_src, dst_settings) + self.record_file_in_manifest(dst_settings, project_root, manifest) + created.append(dst_settings) + + # 4. Install integration-specific update-context scripts + scripts_src = Path(__file__).resolve().parent / "scripts" + if scripts_src.is_dir(): + scripts_dest = project_root / ".specify" / "integrations" / "copilot" / "scripts" + scripts_dest.mkdir(parents=True, exist_ok=True) + for src_script in sorted(scripts_src.iterdir()): + if src_script.is_file(): + dst_script = scripts_dest / src_script.name + shutil.copy2(src_script, dst_script) + # Make shell scripts executable + if dst_script.suffix == ".sh": + dst_script.chmod(dst_script.stat().st_mode | 0o111) + self.record_file_in_manifest(dst_script, project_root, manifest) + created.append(dst_script) + + return created + + def _vscode_settings_path(self) -> Path | None: + """Return path to the bundled vscode-settings.json template.""" + tpl_dir = self.shared_templates_dir() + if tpl_dir: + candidate = tpl_dir / "vscode-settings.json" + if candidate.is_file(): + return candidate + return None + + @staticmethod + def _merge_vscode_settings(src: Path, dst: Path) -> None: + """Merge settings from *src* into existing *dst* JSON file. + + Top-level keys from *src* are added only if missing in *dst*. + For dict-valued keys, sub-keys are merged the same way. + + If *dst* cannot be parsed (e.g. JSONC with comments), the merge + is skipped to avoid overwriting user settings. + """ + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + # Cannot parse existing file (likely JSONC with comments). + # Skip merge to preserve the user's settings, but show + # what they should add manually. + import logging + template_content = src.read_text(encoding="utf-8") + logging.getLogger(__name__).warning( + "Could not parse %s (may contain JSONC comments). " + "Skipping settings merge to preserve existing file.\n" + "Please add the following settings manually:\n%s", + dst, template_content, + ) + return + + new_settings = json.loads(src.read_text(encoding="utf-8")) + + if not isinstance(existing, dict) or not isinstance(new_settings, dict): + import logging + logging.getLogger(__name__).warning( + "Skipping settings merge: %s or template is not a JSON object.", dst + ) + return + + changed = False + for key, value in new_settings.items(): + if key not in existing: + existing[key] = value + changed = True + elif isinstance(existing[key], dict) and isinstance(value, dict): + for sub_key, sub_value in value.items(): + if sub_key not in existing[key]: + existing[key][sub_key] = sub_value + changed = True + + if not changed: + return + + dst.write_text( + json.dumps(existing, indent=4) + "\n", encoding="utf-8" + ) diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 new file mode 100644 index 0000000000..c6f9845a35 --- /dev/null +++ b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 @@ -0,0 +1,22 @@ +# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md +# +# This is the copilot-specific implementation that produces the GitHub +# Copilot instructions file. The shared dispatcher reads +# .specify/integration.json and calls this script. +# +# NOTE: This script is not yet active. It will be activated in Stage 7 +# when the shared update-agent-context.ps1 replaces its switch statement +# with integration.json-based dispatch. The shared script must also be +# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before +# dot-sourcing will work. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { $repoRoot = $PWD.Path } + +# Invoke shared update-agent-context script as a separate process. +# Dot-sourcing is unsafe until that script guards its Main call. +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh new file mode 100644 index 0000000000..84c86422ea --- /dev/null +++ b/src/specify_cli/integrations/copilot/scripts/update-context.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md +# +# This is the copilot-specific implementation that produces the GitHub +# Copilot instructions file. The shared dispatcher reads +# .specify/integration.json and calls this script. +# +# NOTE: This script is not yet active. It will be activated in Stage 7 +# when the shared update-agent-context.sh replaces its case statement +# with integration.json-based dispatch. The shared script must also be +# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) +# before sourcing will work. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +# Invoke shared update-agent-context script as a separate process. +# Sourcing is unsafe until that script guards its main logic. +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py new file mode 100644 index 0000000000..54f59e23a7 --- /dev/null +++ b/tests/integrations/conftest.py @@ -0,0 +1,23 @@ +"""Shared test helpers for integration tests.""" + +from specify_cli.integrations.base import MarkdownIntegration + + +class StubIntegration(MarkdownIntegration): + """Minimal concrete integration for testing.""" + + key = "stub" + config = { + "name": "Stub Agent", + "folder": ".stub/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".stub/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py new file mode 100644 index 0000000000..03b5eb3068 --- /dev/null +++ b/tests/integrations/test_base.py @@ -0,0 +1,169 @@ +"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" + +import pytest + +from specify_cli.integrations.base import ( + IntegrationBase, + IntegrationOption, + MarkdownIntegration, +) +from specify_cli.integrations.manifest import IntegrationManifest +from .conftest import StubIntegration + + +class TestIntegrationOption: + def test_defaults(self): + opt = IntegrationOption(name="--flag") + assert opt.name == "--flag" + assert opt.is_flag is False + assert opt.required is False + assert opt.default is None + assert opt.help == "" + + def test_flag_option(self): + opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills") + assert opt.is_flag is True + assert opt.default is True + assert opt.help == "Enable skills" + + def test_required_option(self): + opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path") + assert opt.required is True + + def test_frozen(self): + opt = IntegrationOption(name="--x") + with pytest.raises(AttributeError): + opt.name = "--y" # type: ignore[misc] + + +class TestIntegrationBase: + def test_key_and_config(self): + i = StubIntegration() + assert i.key == "stub" + assert i.config["name"] == "Stub Agent" + assert i.registrar_config["format"] == "markdown" + assert i.context_file == "STUB.md" + + def test_options_default_empty(self): + assert StubIntegration.options() == [] + + def test_shared_commands_dir(self): + i = StubIntegration() + cmd_dir = i.shared_commands_dir() + assert cmd_dir is not None + assert cmd_dir.is_dir() + + def test_setup_uses_shared_templates(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + created = i.setup(tmp_path, manifest) + assert len(created) > 0 + for f in created: + assert f.parent == tmp_path / ".stub" / "commands" + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_setup_copies_templates(self, tmp_path, monkeypatch): + tpl = tmp_path / "_templates" + tpl.mkdir() + (tpl / "plan.md").write_text("plan content", encoding="utf-8") + (tpl / "specify.md").write_text("spec content", encoding="utf-8") + + i = StubIntegration() + monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md"))) + + project = tmp_path / "project" + project.mkdir() + created = i.setup(project, IntegrationManifest("stub", project)) + assert len(created) == 2 + assert (project / ".stub" / "commands" / "speckit.plan.md").exists() + assert (project / ".stub" / "commands" / "speckit.specify.md").exists() + + def test_install_delegates_to_setup(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + result = i.install(tmp_path, manifest) + assert len(result) > 0 + + def test_uninstall_delegates_to_teardown(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + removed, skipped = i.uninstall(tmp_path, manifest) + assert removed == [] + assert skipped == [] + + +class TestMarkdownIntegration: + def test_is_subclass_of_base(self): + assert issubclass(MarkdownIntegration, IntegrationBase) + + def test_stub_is_markdown(self): + assert isinstance(StubIntegration(), MarkdownIntegration) + + +class TestBasePrimitives: + def test_shared_commands_dir_returns_path(self): + i = StubIntegration() + cmd_dir = i.shared_commands_dir() + assert cmd_dir is not None + assert cmd_dir.is_dir() + + def test_shared_templates_dir_returns_path(self): + i = StubIntegration() + tpl_dir = i.shared_templates_dir() + assert tpl_dir is not None + assert tpl_dir.is_dir() + + def test_list_command_templates_returns_md_files(self): + i = StubIntegration() + templates = i.list_command_templates() + assert len(templates) > 0 + assert all(t.suffix == ".md" for t in templates) + + def test_command_filename_default(self): + i = StubIntegration() + assert i.command_filename("plan") == "speckit.plan.md" + + def test_commands_dest(self, tmp_path): + i = StubIntegration() + dest = i.commands_dest(tmp_path) + assert dest == tmp_path / ".stub" / "commands" + + def test_commands_dest_no_config_raises(self, tmp_path): + class NoConfig(MarkdownIntegration): + key = "noconfig" + with pytest.raises(ValueError, match="config is not set"): + NoConfig().commands_dest(tmp_path) + + def test_copy_command_to_directory(self, tmp_path): + src = tmp_path / "source.md" + src.write_text("content", encoding="utf-8") + dest_dir = tmp_path / "output" + result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md") + assert result == dest_dir / "speckit.plan.md" + assert result.read_text(encoding="utf-8") == "content" + + def test_record_file_in_manifest(self, tmp_path): + f = tmp_path / "f.txt" + f.write_text("hello", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + IntegrationBase.record_file_in_manifest(f, tmp_path, m) + assert "f.txt" in m.files + + def test_write_file_and_record(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + dest = tmp_path / "sub" / "f.txt" + result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m) + assert result == dest + assert dest.read_text(encoding="utf-8") == "content" + assert "sub/f.txt" in m.files + + def test_setup_copies_shared_templates(self, tmp_path): + i = StubIntegration() + m = IntegrationManifest("stub", tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + for f in created: + assert f.parent.name == "commands" + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py new file mode 100644 index 0000000000..03b0e11866 --- /dev/null +++ b/tests/integrations/test_cli.py @@ -0,0 +1,122 @@ +"""Tests for --integration flag on specify init (CLI-level).""" + +import json +import os + +import pytest + + +class TestInitIntegrationFlag: + def test_integration_and_ai_mutually_exclusive(self): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", "test-project", "--ai", "claude", "--integration", "copilot", + ]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + def test_unknown_integration_rejected(self): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", "test-project", "--integration", "nonexistent", + ]) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_integration_copilot_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "int-test" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists() + assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + assert "scripts" in data + assert "update-context" in data["scripts"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "copilot" + + assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() + assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").exists() + + shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" + assert shared_manifest.exists() + + def test_ai_copilot_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "promote-test" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "--integration copilot" in result.output + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + def test_shared_infra_skips_existing_files(self, tmp_path): + """Pre-existing shared files are not overwritten by _install_shared_infra.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "skip-test" + project.mkdir() + + # Pre-create a shared script with custom content + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + # Pre-create a shared template with custom content + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + custom_template = "# user-modified spec-template\n" + (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--force", + "--integration", "copilot", + "--script", "sh", + "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + + # User's files should be preserved + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + + # Other shared files should still be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() diff --git a/tests/integrations/test_copilot.py b/tests/integrations/test_copilot.py new file mode 100644 index 0000000000..5db0155bdb --- /dev/null +++ b/tests/integrations/test_copilot.py @@ -0,0 +1,266 @@ +"""Tests for CopilotIntegration.""" + +import json +import os + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestCopilotIntegration: + def test_copilot_key_and_config(self): + copilot = get_integration("copilot") + assert copilot is not None + assert copilot.key == "copilot" + assert copilot.config["folder"] == ".github/" + assert copilot.config["commands_subdir"] == "agents" + assert copilot.registrar_config["extension"] == ".agent.md" + assert copilot.context_file == ".github/copilot-instructions.md" + + def test_command_filename_agent_md(self): + copilot = get_integration("copilot") + assert copilot.command_filename("plan") == "speckit.plan.agent.md" + + def test_setup_creates_agent_md_files(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + assert len(created) > 0 + agent_files = [f for f in created if ".agent." in f.name] + assert len(agent_files) > 0 + for f in agent_files: + assert f.parent == tmp_path / ".github" / "agents" + assert f.name.endswith(".agent.md") + + def test_setup_creates_companion_prompts(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + prompt_files = [f for f in created if f.parent.name == "prompts"] + assert len(prompt_files) > 0 + for f in prompt_files: + assert f.name.endswith(".prompt.md") + content = f.read_text(encoding="utf-8") + assert content.startswith("---\nagent: speckit.") + + def test_agent_and_prompt_counts_match(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + agents = [f for f in created if ".agent.md" in f.name] + prompts = [f for f in created if ".prompt.md" in f.name] + assert len(agents) == len(prompts) + + def test_setup_creates_vscode_settings_new(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + assert copilot._vscode_settings_path() is not None + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + settings = tmp_path / ".vscode" / "settings.json" + assert settings.exists() + assert settings in created + assert any("settings.json" in k for k in m.files) + + def test_setup_merges_existing_vscode_settings(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + existing = {"editor.fontSize": 14, "custom.setting": True} + (vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8") + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + settings = tmp_path / ".vscode" / "settings.json" + data = json.loads(settings.read_text(encoding="utf-8")) + assert data["editor.fontSize"] == 14 + assert data["custom.setting"] is True + assert settings not in created + assert not any("settings.json" in k for k in m.files) + + def test_all_created_files_tracked_in_manifest(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"Created file {rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = copilot.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = copilot.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_directory_structure(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + copilot.setup(tmp_path, m) + agents_dir = tmp_path / ".github" / "agents" + assert agents_dir.is_dir() + agent_files = sorted(agents_dir.glob("speckit.*.agent.md")) + assert len(agent_files) == 9 + expected_commands = { + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + } + actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files} + assert actual_commands == expected_commands + + def test_templates_are_processed(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + copilot.setup(tmp_path, m) + agents_dir = tmp_path / ".github" / "agents" + for agent_file in agents_dir.glob("speckit.*.agent.md"): + content = agent_file.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}" + assert "\nscripts:\n" not in content + assert "\nagent_scripts:\n" not in content + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration copilot --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.checklist.agent.md", + ".github/agents/speckit.clarify.agent.md", + ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.implement.agent.md", + ".github/agents/speckit.plan.agent.md", + ".github/agents/speckit.specify.agent.md", + ".github/agents/speckit.tasks.agent.md", + ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.checklist.prompt.md", + ".github/prompts/speckit.clarify.prompt.md", + ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.implement.prompt.md", + ".github/prompts/speckit.plan.prompt.md", + ".github/prompts/speckit.specify.prompt.md", + ".github/prompts/speckit.tasks.prompt.md", + ".github/prompts/speckit.taskstoissues.prompt.md", + ".vscode/settings.json", + ".specify/integration.json", + ".specify/init-options.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + ".specify/integrations/copilot/scripts/update-context.ps1", + ".specify/integrations/copilot/scripts/update-context.sh", + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/update-agent-context.sh", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration copilot --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-ps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "ps", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.checklist.agent.md", + ".github/agents/speckit.clarify.agent.md", + ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.implement.agent.md", + ".github/agents/speckit.plan.agent.md", + ".github/agents/speckit.specify.agent.md", + ".github/agents/speckit.tasks.agent.md", + ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.checklist.prompt.md", + ".github/prompts/speckit.clarify.prompt.md", + ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.implement.prompt.md", + ".github/prompts/speckit.plan.prompt.md", + ".github/prompts/speckit.specify.prompt.md", + ".github/prompts/speckit.tasks.prompt.md", + ".github/prompts/speckit.taskstoissues.prompt.md", + ".vscode/settings.json", + ".specify/integration.json", + ".specify/init-options.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + ".specify/integrations/copilot/scripts/update-context.ps1", + ".specify/integrations/copilot/scripts/update-context.sh", + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/update-agent-context.ps1", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/test_integrations.py b/tests/integrations/test_manifest.py similarity index 52% rename from tests/test_integrations.py rename to tests/integrations/test_manifest.py index aeb17ae995..b5d5bc39f5 100644 --- a/tests/test_integrations.py +++ b/tests/integrations/test_manifest.py @@ -1,164 +1,18 @@ -"""Tests for the integrations foundation (Stage 1). - -Covers: -- IntegrationOption dataclass -- IntegrationBase ABC and MarkdownIntegration base class -- IntegrationManifest — record, hash, save, load, uninstall, modified detection -- INTEGRATION_REGISTRY basics -""" +"""Tests for IntegrationManifest — record, hash, save, load, uninstall, modified detection.""" import hashlib import json import pytest -from specify_cli.integrations import ( - INTEGRATION_REGISTRY, - _register, - get_integration, -) -from specify_cli.integrations.base import ( - IntegrationBase, - IntegrationOption, - MarkdownIntegration, -) from specify_cli.integrations.manifest import IntegrationManifest, _sha256 -# ── helpers ────────────────────────────────────────────────────────────────── - - -class _StubIntegration(MarkdownIntegration): - """Minimal concrete integration for testing.""" - - key = "stub" - config = { - "name": "Stub Agent", - "folder": ".stub/", - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - } - registrar_config = { - "dir": ".stub/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - } - context_file = "STUB.md" - - -# ═══════════════════════════════════════════════════════════════════════════ -# IntegrationOption -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestIntegrationOption: - def test_defaults(self): - opt = IntegrationOption(name="--flag") - assert opt.name == "--flag" - assert opt.is_flag is False - assert opt.required is False - assert opt.default is None - assert opt.help == "" - - def test_flag_option(self): - opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills") - assert opt.is_flag is True - assert opt.default is True - assert opt.help == "Enable skills" - - def test_required_option(self): - opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path") - assert opt.required is True - - def test_frozen(self): - opt = IntegrationOption(name="--x") - with pytest.raises(AttributeError): - opt.name = "--y" # type: ignore[misc] - - -# ═══════════════════════════════════════════════════════════════════════════ -# IntegrationBase / MarkdownIntegration -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestIntegrationBase: - def test_key_and_config(self): - i = _StubIntegration() - assert i.key == "stub" - assert i.config["name"] == "Stub Agent" - assert i.registrar_config["format"] == "markdown" - assert i.context_file == "STUB.md" - - def test_options_default_empty(self): - assert _StubIntegration.options() == [] - - def test_templates_dir(self): - i = _StubIntegration() - td = i.templates_dir() - # Should point to a templates/ dir next to this test module. - # It won't exist, but the path should be well-formed. - assert td.name == "templates" - - def test_setup_no_templates_returns_empty(self, tmp_path): - """setup() gracefully returns empty list when templates dir is missing.""" - i = _StubIntegration() - manifest = IntegrationManifest("stub", tmp_path) - created = i.setup(tmp_path, manifest) - assert created == [] - - def test_setup_copies_templates(self, tmp_path, monkeypatch): - """setup() copies template files and records them in the manifest.""" - # Create templates under tmp_path so we don't mutate the source tree - tpl = tmp_path / "_templates" - tpl.mkdir() - (tpl / "speckit.plan.md").write_text("plan content", encoding="utf-8") - (tpl / "speckit.specify.md").write_text("spec content", encoding="utf-8") - - i = _StubIntegration() - monkeypatch.setattr(type(i), "templates_dir", lambda self: tpl) - - project = tmp_path / "project" - project.mkdir() - created = i.setup(project, IntegrationManifest("stub", project)) - assert len(created) == 2 - assert (project / ".stub" / "commands" / "speckit.plan.md").exists() - assert (project / ".stub" / "commands" / "speckit.specify.md").exists() - - def test_install_delegates_to_setup(self, tmp_path): - i = _StubIntegration() - manifest = IntegrationManifest("stub", tmp_path) - result = i.install(tmp_path, manifest) - assert result == [] # no templates dir → empty - - def test_uninstall_delegates_to_teardown(self, tmp_path): - i = _StubIntegration() - manifest = IntegrationManifest("stub", tmp_path) - removed, skipped = i.uninstall(tmp_path, manifest) - assert removed == [] - assert skipped == [] - - -class TestMarkdownIntegration: - def test_is_subclass_of_base(self): - assert issubclass(MarkdownIntegration, IntegrationBase) - - def test_stub_is_markdown(self): - assert isinstance(_StubIntegration(), MarkdownIntegration) - - -# ═══════════════════════════════════════════════════════════════════════════ -# IntegrationManifest -# ═══════════════════════════════════════════════════════════════════════════ - - class TestManifestRecordFile: def test_record_file_writes_and_hashes(self, tmp_path): m = IntegrationManifest("test", tmp_path) content = "hello world" abs_path = m.record_file("a/b.txt", content) - assert abs_path == tmp_path / "a" / "b.txt" assert abs_path.read_text(encoding="utf-8") == content expected_hash = hashlib.sha256(content.encode()).hexdigest() @@ -191,7 +45,6 @@ def test_record_file_rejects_absolute_path(self, tmp_path): m.record_file("/tmp/escape.txt", "bad") def test_record_existing_rejects_parent_traversal(self, tmp_path): - # Create a file outside the project root escape = tmp_path.parent / "escape.txt" escape.write_text("evil", encoding="utf-8") try: @@ -202,15 +55,11 @@ def test_record_existing_rejects_parent_traversal(self, tmp_path): escape.unlink(missing_ok=True) def test_uninstall_skips_traversal_paths(self, tmp_path): - """If a manifest is corrupted with traversal paths, uninstall ignores them.""" m = IntegrationManifest("test", tmp_path) m.record_file("safe.txt", "good") - # Manually inject a traversal path into the manifest m._files["../outside.txt"] = "fakehash" m.save() - removed, skipped = m.uninstall() - # Only the safe file should have been removed assert len(removed) == 1 assert removed[0].name == "safe.txt" @@ -234,7 +83,6 @@ def test_deleted_file_not_reported(self, tmp_path): assert m.check_modified() == [] def test_symlink_treated_as_modified(self, tmp_path): - """A tracked file replaced with a symlink is reported as modified.""" m = IntegrationManifest("test", tmp_path) m.record_file("f.txt", "original") target = tmp_path / "target.txt" @@ -249,11 +97,9 @@ def test_removes_unmodified(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("d/f.txt", "content") m.save() - removed, skipped = m.uninstall() assert len(removed) == 1 assert not (tmp_path / "d" / "f.txt").exists() - # Parent dir cleaned up because empty assert not (tmp_path / "d").exists() assert skipped == [] @@ -262,7 +108,6 @@ def test_skips_modified(self, tmp_path): m.record_file("f.txt", "original") m.save() (tmp_path / "f.txt").write_text("modified", encoding="utf-8") - removed, skipped = m.uninstall() assert removed == [] assert len(skipped) == 1 @@ -273,18 +118,15 @@ def test_force_removes_modified(self, tmp_path): m.record_file("f.txt", "original") m.save() (tmp_path / "f.txt").write_text("modified", encoding="utf-8") - removed, skipped = m.uninstall(force=True) assert len(removed) == 1 assert skipped == [] - assert not (tmp_path / "f.txt").exists() def test_already_deleted_file(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("f.txt", "content") m.save() (tmp_path / "f.txt").unlink() - removed, skipped = m.uninstall() assert removed == [] assert skipped == [] @@ -294,7 +136,6 @@ def test_removes_manifest_file(self, tmp_path): m.record_file("f.txt", "content") m.save() assert m.manifest_path.exists() - m.uninstall() assert not m.manifest_path.exists() @@ -302,26 +143,19 @@ def test_cleans_empty_parent_dirs(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("a/b/c/f.txt", "content") m.save() - m.uninstall() - assert not (tmp_path / "a" / "b" / "c").exists() - assert not (tmp_path / "a" / "b").exists() assert not (tmp_path / "a").exists() def test_preserves_nonempty_parent_dirs(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("a/b/tracked.txt", "content") - # Create an untracked sibling (tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8") m.save() - m.uninstall() assert not (tmp_path / "a" / "b" / "tracked.txt").exists() assert (tmp_path / "a" / "b" / "other.txt").exists() - assert (tmp_path / "a" / "b").is_dir() def test_symlink_skipped_without_force(self, tmp_path): - """A tracked file replaced with a symlink is skipped unless force.""" m = IntegrationManifest("test", tmp_path) m.record_file("f.txt", "original") m.save() @@ -329,14 +163,11 @@ def test_symlink_skipped_without_force(self, tmp_path): target.write_text("target", encoding="utf-8") (tmp_path / "f.txt").unlink() (tmp_path / "f.txt").symlink_to(target) - removed, skipped = m.uninstall() assert removed == [] assert len(skipped) == 1 - assert (tmp_path / "f.txt").is_symlink() # still there def test_symlink_removed_with_force(self, tmp_path): - """A tracked file replaced with a symlink is removed with force.""" m = IntegrationManifest("test", tmp_path) m.record_file("f.txt", "original") m.save() @@ -344,11 +175,9 @@ def test_symlink_removed_with_force(self, tmp_path): target.write_text("target", encoding="utf-8") (tmp_path / "f.txt").unlink() (tmp_path / "f.txt").symlink_to(target) - removed, skipped = m.uninstall(force=True) assert len(removed) == 1 - assert not (tmp_path / "f.txt").exists() - assert target.exists() # target not deleted + assert target.exists() class TestManifestPersistence: @@ -356,12 +185,10 @@ def test_save_and_load_roundtrip(self, tmp_path): m = IntegrationManifest("myagent", tmp_path, version="2.0.1") m.record_file("dir/file.md", "# Hello") m.save() - loaded = IntegrationManifest.load("myagent", tmp_path) assert loaded.key == "myagent" assert loaded.version == "2.0.1" assert loaded.files == m.files - assert loaded._installed_at == m._installed_at def test_manifest_path(self, tmp_path): m = IntegrationManifest("copilot", tmp_path) @@ -378,58 +205,16 @@ def test_save_creates_directories(self, tmp_path): assert path.exists() data = json.loads(path.read_text(encoding="utf-8")) assert data["integration"] == "test" - assert "installed_at" in data - assert "f.txt" in data["files"] def test_save_preserves_installed_at(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("f.txt", "content") m.save() first_ts = m._installed_at - - # Save again — timestamp should not change m.save() assert m._installed_at == first_ts -# ═══════════════════════════════════════════════════════════════════════════ -# Registry -# ═══════════════════════════════════════════════════════════════════════════ - - -class TestRegistry: - def test_registry_starts_empty(self): - # Registry may have been populated by other tests; at minimum - # it should be a dict. - assert isinstance(INTEGRATION_REGISTRY, dict) - - def test_register_and_get(self): - stub = _StubIntegration() - _register(stub) - try: - assert get_integration("stub") is stub - finally: - INTEGRATION_REGISTRY.pop("stub", None) - - def test_get_missing_returns_none(self): - assert get_integration("nonexistent-xyz") is None - - def test_register_empty_key_raises(self): - class EmptyKey(MarkdownIntegration): - key = "" - with pytest.raises(ValueError, match="empty key"): - _register(EmptyKey()) - - def test_register_duplicate_raises(self): - stub = _StubIntegration() - _register(stub) - try: - with pytest.raises(KeyError, match="already registered"): - _register(_StubIntegration()) - finally: - INTEGRATION_REGISTRY.pop("stub", None) - - class TestManifestLoadValidation: def test_load_non_dict_raises(self, tmp_path): path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py new file mode 100644 index 0000000000..8fb5ef0668 --- /dev/null +++ b/tests/integrations/test_registry.py @@ -0,0 +1,45 @@ +"""Tests for INTEGRATION_REGISTRY.""" + +import pytest + +from specify_cli.integrations import ( + INTEGRATION_REGISTRY, + _register, + get_integration, +) +from specify_cli.integrations.base import MarkdownIntegration +from .conftest import StubIntegration + + +class TestRegistry: + def test_registry_is_dict(self): + assert isinstance(INTEGRATION_REGISTRY, dict) + + def test_register_and_get(self): + stub = StubIntegration() + _register(stub) + try: + assert get_integration("stub") is stub + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + def test_get_missing_returns_none(self): + assert get_integration("nonexistent-xyz") is None + + def test_register_empty_key_raises(self): + class EmptyKey(MarkdownIntegration): + key = "" + with pytest.raises(ValueError, match="empty key"): + _register(EmptyKey()) + + def test_register_duplicate_raises(self): + stub = StubIntegration() + _register(stub) + try: + with pytest.raises(KeyError, match="already registered"): + _register(StubIntegration()) + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + def test_copilot_registered(self): + assert "copilot" in INTEGRATION_REGISTRY From 3113b72d6fa5d8613ed7572ce9dd1e6692ff07b7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:58:36 -0500 Subject: [PATCH 162/321] chore: release 0.4.4, begin 0.4.5.dev0 development (#2048) * chore: bump version to 0.4.4 * chore: begin 0.4.5.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3265b305b1..8394968a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ +## [0.4.4] - 2026-04-01 + +### Changed + +- Stage 2: Copilot integration — proof of concept with shared template primitives (#2035) +- docs: sync AGENTS.md with AGENT_CONFIG for missing agents (#2025) +- docs: ensure manual tests use local specify (#2020) +- Stage 1: Integration foundation — base classes, manifest system, and registry (#1925) +- fix: harden GitHub Actions workflows (#2021) +- chore: use PEP 440 .dev0 versions on main after releases (#2032) +- feat: add superpowers bridge extension to community catalog (#2023) +- feat: add product-forge extension to community catalog (#2012) +- feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999) +- fix(scripts): add correct path for copilot-instructions.md (#1997) +- Update README.md (#1995) +- fix: prevent extension command shadowing (#1994) +- Fix Claude Code CLI detection for npm-local installs (#1978) +- fix(scripts): honor PowerShell agent and script filters (#1969) +- feat: add MAQA extension suite (7 extensions) to community catalog (#1981) +- feat: add spec-kit-onboard extension to community catalog (#1991) +- Add plan-review-gate to community catalog (#1993) +- chore(deps): bump actions/deploy-pages from 4 to 5 (#1990) +- chore(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 (#1989) +- chore: bump version to 0.4.3 (#1986) + ## [0.4.3] - 2026-03-26 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 5589876474..dbb24e59fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.4.dev0" +version = "0.4.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 255371d36741241df92090602765851259e82914 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:17:21 -0500 Subject: [PATCH 163/321] =?UTF-8?q?Stage=203:=20Standard=20markdown=20inte?= =?UTF-8?q?grations=20=E2=80=94=2019=20agents=20migrated=20to=20plugin=20a?= =?UTF-8?q?rchitecture=20(#2038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture Migrate all standard markdown integrations to self-contained subpackages under integrations/. Each subclasses MarkdownIntegration with config-only overrides (~10 lines per __init__.py). Integrations migrated (19): claude, qwen, opencode, junie, kilocode, auggie, roo, codebuddy, qodercli, amp, shai, bob, trae, pi, iflow, kiro-cli, windsurf, vibe, cursor-agent Changes: - Create integrations// subpackage with __init__.py and scripts/ (update-context.sh, update-context.ps1) for each integration - Register all 19 in INTEGRATION_REGISTRY (20 total with copilot) - MarkdownIntegration.setup() processes templates (replaces {SCRIPT}, {ARGS}, __AGENT__; strips frontmatter blocks; rewrites paths) - Extract install_scripts() to IntegrationBase; refactor copilot to use it - Generalize --ai auto-promote from copilot-only to registry-driven: any integration registered in INTEGRATION_REGISTRY auto-promotes. Unregistered agents (gemini, tabnine, codex, kimi, agy, generic) continue through the legacy --ai path unchanged. - Fix cursor/cursor-agent key mismatch in CommandRegistrar.AGENT_CONFIGS - Add missing vibe entry to CommandRegistrar.AGENT_CONFIGS - Update kiro alias test to reflect auto-promote behavior Testing: - Per-agent test files (test_integration_.py) with shared mixin - 1316 tests passing, 0 failures - Complete file inventory tests for both sh and ps variants - Byte-for-byte validated against v0.4.3 release packages (684 files) * Address PR review: fix repo root detection and no-op test - Fix repo root fallback in all 20 update-context.sh scripts: walk up from script location to find .specify/ instead of falling back to pwd - Fix repo root fallback in all 20 update-context.ps1 scripts: walk up from script location to find .specify/ instead of falling back to $PWD - Add assertions to test_setup_writes_to_correct_directory: verify expected_dir exists and all command files reside under it * Fix REPO_ROOT priority: prefer .specify walk-up over git root In monorepos the git toplevel may differ from the project root that contains .specify/. The previous fix still preferred git rev-parse over the walk-up result. Bash scripts (20): prefer the discovered _root when it contains .specify/; only accept git root if it also contains .specify/. PowerShell scripts (20): validate git root contains .specify/ before using it; fall back to walking up from script directory otherwise. * Guard git call with try/catch in PowerShell scripts With $ErrorActionPreference = 'Stop', an unguarded git rev-parse throws a terminating CommandNotFoundException when git is not installed, preventing the .specify walk-up fallback from running. Wrap the git call in try/catch across all 20 update-context.ps1 scripts so the fallback works reliably without git. * Rename hyphenated package dirs to valid Python identifiers Rename kiro-cli → kiro_cli and cursor-agent → cursor_agent so the packages can be imported with normal Python syntax instead of importlib. The user-facing integration key (IntegrationBase.key) stays hyphenated to match the actual CLI tool / binary name. Also reorganize _register_builtins(): imports and registrations are now grouped alphabetically with clear section comments. * Reuse CommandRegistrar path rewriting in process_template() Replace the duplicated regex-based path rewriting in MarkdownIntegration.process_template() with a call to the shared CommandRegistrar._rewrite_project_relative_paths() implementation. This ensures extension-local paths are preserved and boundary rules stay consistent across the codebase. * Promote _rewrite_project_relative_paths to public API Rename CommandRegistrar._rewrite_project_relative_paths() to rewrite_project_relative_paths() (drop leading underscore) so integrations can call it without reaching into a private method across subsystem boundaries. Addresses PR review feedback: https://github.com/github/spec-kit/pull/2038#discussion_r3022105627 * Broaden TestRegistrarKeyAlignment to cover all integration keys Parametrize across ALL_INTEGRATION_KEYS instead of only checking cursor-agent and vibe. Keeps a separate negative test for the stale 'cursor' shorthand. Addresses PR review feedback: https://github.com/github/spec-kit/pull/2038#discussion_r3022269032 --- src/specify_cli/__init__.py | 10 +- src/specify_cli/agents.py | 14 +- src/specify_cli/integrations/__init__.py | 51 ++- src/specify_cli/integrations/amp/__init__.py | 21 ++ .../amp/scripts/update-context.ps1 | 23 ++ .../amp/scripts/update-context.sh | 28 ++ .../integrations/auggie/__init__.py | 21 ++ .../auggie/scripts/update-context.ps1 | 23 ++ .../auggie/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/base.py | 111 ++++++- src/specify_cli/integrations/bob/__init__.py | 21 ++ .../bob/scripts/update-context.ps1 | 23 ++ .../bob/scripts/update-context.sh | 28 ++ .../integrations/claude/__init__.py | 21 ++ .../claude/scripts/update-context.ps1 | 23 ++ .../claude/scripts/update-context.sh | 28 ++ .../integrations/codebuddy/__init__.py | 21 ++ .../codebuddy/scripts/update-context.ps1 | 23 ++ .../codebuddy/scripts/update-context.sh | 28 ++ .../integrations/copilot/__init__.py | 14 +- .../copilot/scripts/update-context.ps1 | 14 +- .../copilot/scripts/update-context.sh | 17 +- .../integrations/cursor_agent/__init__.py | 21 ++ .../cursor_agent/scripts/update-context.ps1 | 23 ++ .../cursor_agent/scripts/update-context.sh | 28 ++ .../integrations/iflow/__init__.py | 21 ++ .../iflow/scripts/update-context.ps1 | 23 ++ .../iflow/scripts/update-context.sh | 28 ++ .../integrations/junie/__init__.py | 21 ++ .../junie/scripts/update-context.ps1 | 23 ++ .../junie/scripts/update-context.sh | 28 ++ .../integrations/kilocode/__init__.py | 21 ++ .../kilocode/scripts/update-context.ps1 | 23 ++ .../kilocode/scripts/update-context.sh | 28 ++ .../integrations/kiro_cli/__init__.py | 21 ++ .../kiro_cli/scripts/update-context.ps1 | 23 ++ .../kiro_cli/scripts/update-context.sh | 28 ++ .../integrations/opencode/__init__.py | 21 ++ .../opencode/scripts/update-context.ps1 | 23 ++ .../opencode/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/pi/__init__.py | 21 ++ .../pi/scripts/update-context.ps1 | 23 ++ .../integrations/pi/scripts/update-context.sh | 28 ++ .../integrations/qodercli/__init__.py | 21 ++ .../qodercli/scripts/update-context.ps1 | 23 ++ .../qodercli/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/qwen/__init__.py | 21 ++ .../qwen/scripts/update-context.ps1 | 23 ++ .../qwen/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/roo/__init__.py | 21 ++ .../roo/scripts/update-context.ps1 | 23 ++ .../roo/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/shai/__init__.py | 21 ++ .../shai/scripts/update-context.ps1 | 23 ++ .../shai/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/trae/__init__.py | 21 ++ .../trae/scripts/update-context.ps1 | 23 ++ .../trae/scripts/update-context.sh | 28 ++ src/specify_cli/integrations/vibe/__init__.py | 21 ++ .../vibe/scripts/update-context.ps1 | 23 ++ .../vibe/scripts/update-context.sh | 28 ++ .../integrations/windsurf/__init__.py | 21 ++ .../windsurf/scripts/update-context.ps1 | 23 ++ .../windsurf/scripts/update-context.sh | 28 ++ tests/integrations/test_integration_amp.py | 11 + tests/integrations/test_integration_auggie.py | 11 + .../test_integration_base_markdown.py | 296 ++++++++++++++++++ tests/integrations/test_integration_bob.py | 11 + tests/integrations/test_integration_claude.py | 11 + .../test_integration_codebuddy.py | 11 + ...copilot.py => test_integration_copilot.py} | 0 .../test_integration_cursor_agent.py | 11 + tests/integrations/test_integration_iflow.py | 11 + tests/integrations/test_integration_junie.py | 11 + .../integrations/test_integration_kilocode.py | 11 + .../integrations/test_integration_kiro_cli.py | 11 + .../integrations/test_integration_opencode.py | 11 + tests/integrations/test_integration_pi.py | 11 + .../integrations/test_integration_qodercli.py | 11 + tests/integrations/test_integration_qwen.py | 11 + tests/integrations/test_integration_roo.py | 11 + tests/integrations/test_integration_shai.py | 11 + tests/integrations/test_integration_trae.py | 11 + tests/integrations/test_integration_vibe.py | 11 + .../integrations/test_integration_windsurf.py | 11 + tests/integrations/test_registry.py | 37 ++- tests/test_ai_skills.py | 31 +- tests/test_extensions.py | 2 +- 88 files changed, 2113 insertions(+), 61 deletions(-) create mode 100644 src/specify_cli/integrations/amp/__init__.py create mode 100644 src/specify_cli/integrations/amp/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/amp/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/auggie/__init__.py create mode 100644 src/specify_cli/integrations/auggie/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/auggie/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/bob/__init__.py create mode 100644 src/specify_cli/integrations/bob/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/bob/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/claude/__init__.py create mode 100644 src/specify_cli/integrations/claude/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/claude/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/codebuddy/__init__.py create mode 100644 src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/codebuddy/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/cursor_agent/__init__.py create mode 100644 src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/cursor_agent/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/iflow/__init__.py create mode 100644 src/specify_cli/integrations/iflow/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/iflow/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/junie/__init__.py create mode 100644 src/specify_cli/integrations/junie/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/junie/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/kilocode/__init__.py create mode 100644 src/specify_cli/integrations/kilocode/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/kilocode/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/kiro_cli/__init__.py create mode 100644 src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/kiro_cli/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/opencode/__init__.py create mode 100644 src/specify_cli/integrations/opencode/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/opencode/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/pi/__init__.py create mode 100644 src/specify_cli/integrations/pi/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/pi/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/qodercli/__init__.py create mode 100644 src/specify_cli/integrations/qodercli/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/qodercli/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/qwen/__init__.py create mode 100644 src/specify_cli/integrations/qwen/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/qwen/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/roo/__init__.py create mode 100644 src/specify_cli/integrations/roo/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/roo/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/shai/__init__.py create mode 100644 src/specify_cli/integrations/shai/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/shai/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/trae/__init__.py create mode 100644 src/specify_cli/integrations/trae/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/trae/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/vibe/__init__.py create mode 100644 src/specify_cli/integrations/vibe/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/vibe/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/windsurf/__init__.py create mode 100644 src/specify_cli/integrations/windsurf/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/windsurf/scripts/update-context.sh create mode 100644 tests/integrations/test_integration_amp.py create mode 100644 tests/integrations/test_integration_auggie.py create mode 100644 tests/integrations/test_integration_base_markdown.py create mode 100644 tests/integrations/test_integration_bob.py create mode 100644 tests/integrations/test_integration_claude.py create mode 100644 tests/integrations/test_integration_codebuddy.py rename tests/integrations/{test_copilot.py => test_integration_copilot.py} (100%) create mode 100644 tests/integrations/test_integration_cursor_agent.py create mode 100644 tests/integrations/test_integration_iflow.py create mode 100644 tests/integrations/test_integration_junie.py create mode 100644 tests/integrations/test_integration_kilocode.py create mode 100644 tests/integrations/test_integration_kiro_cli.py create mode 100644 tests/integrations/test_integration_opencode.py create mode 100644 tests/integrations/test_integration_pi.py create mode 100644 tests/integrations/test_integration_qodercli.py create mode 100644 tests/integrations/test_integration_qwen.py create mode 100644 tests/integrations/test_integration_roo.py create mode 100644 tests/integrations/test_integration_shai.py create mode 100644 tests/integrations/test_integration_trae.py create mode 100644 tests/integrations/test_integration_vibe.py create mode 100644 tests/integrations/test_integration_windsurf.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e53d7f18e6..698b672dab 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1974,7 +1974,7 @@ def init( console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path") raise typer.Exit(1) - # Auto-promote: --ai copilot → integration path with a nudge + # Auto-promote: --ai → integration path with a nudge (if registered) use_integration = False if integration: from .integrations import INTEGRATION_REGISTRY, get_integration @@ -1987,14 +1987,14 @@ def init( use_integration = True # Map integration key to the ai_assistant variable for downstream compatibility ai_assistant = integration - elif ai_assistant == "copilot": + elif ai_assistant: from .integrations import get_integration - resolved_integration = get_integration("copilot") + resolved_integration = get_integration(ai_assistant) if resolved_integration: use_integration = True console.print( - "[dim]Tip: Use [bold]--integration copilot[/bold] instead of " - "--ai copilot. The --ai flag will be deprecated in a future release.[/dim]" + f"[dim]Tip: Use [bold]--integration {ai_assistant}[/bold] instead of " + f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]" ) if project_name == ".": diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 64617e8431..4a8c2d1b29 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -43,7 +43,7 @@ class CommandRegistrar: "args": "$ARGUMENTS", "extension": ".agent.md" }, - "cursor": { + "cursor-agent": { "dir": ".cursor/commands", "format": "markdown", "args": "$ARGUMENTS", @@ -162,6 +162,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" + }, + "vibe": { + "dir": ".vibe/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" } } @@ -235,11 +241,11 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: for key, script_path in scripts.items(): if isinstance(script_path, str): - scripts[key] = self._rewrite_project_relative_paths(script_path) + scripts[key] = self.rewrite_project_relative_paths(script_path) return frontmatter @staticmethod - def _rewrite_project_relative_paths(text: str) -> str: + 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 @@ -422,7 +428,7 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr body = body.replace("{AGENT_SCRIPT}", agent_script_command) body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - return CommandRegistrar._rewrite_project_relative_paths(body) + 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. diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index e5ddc5c5a2..0d7a712427 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -37,10 +37,57 @@ def get_integration(key: str) -> IntegrationBase | None: # -- Register built-in integrations -------------------------------------- def _register_builtins() -> None: - """Register all built-in integrations.""" - from .copilot import CopilotIntegration + """Register all built-in integrations. + Package directories use Python-safe identifiers (e.g. ``kiro_cli``, + ``cursor_agent``). The user-facing integration key stored in + ``IntegrationBase.key`` stays hyphenated (``"kiro-cli"``, + ``"cursor-agent"``) to match the actual CLI tool / binary name that + users install and invoke. + """ + # -- Imports (alphabetical) ------------------------------------------- + from .amp import AmpIntegration + from .auggie import AuggieIntegration + from .bob import BobIntegration + from .claude import ClaudeIntegration + from .codebuddy import CodebuddyIntegration + from .copilot import CopilotIntegration + from .cursor_agent import CursorAgentIntegration + from .iflow import IflowIntegration + from .junie import JunieIntegration + from .kilocode import KilocodeIntegration + from .kiro_cli import KiroCliIntegration + from .opencode import OpencodeIntegration + from .pi import PiIntegration + from .qodercli import QodercliIntegration + from .qwen import QwenIntegration + from .roo import RooIntegration + from .shai import ShaiIntegration + from .trae import TraeIntegration + from .vibe import VibeIntegration + from .windsurf import WindsurfIntegration + + # -- Registration (alphabetical) -------------------------------------- + _register(AmpIntegration()) + _register(AuggieIntegration()) + _register(BobIntegration()) + _register(ClaudeIntegration()) + _register(CodebuddyIntegration()) _register(CopilotIntegration()) + _register(CursorAgentIntegration()) + _register(IflowIntegration()) + _register(JunieIntegration()) + _register(KilocodeIntegration()) + _register(KiroCliIntegration()) + _register(OpencodeIntegration()) + _register(PiIntegration()) + _register(QodercliIntegration()) + _register(QwenIntegration()) + _register(RooIntegration()) + _register(ShaiIntegration()) + _register(TraeIntegration()) + _register(VibeIntegration()) + _register(WindsurfIntegration()) _register_builtins() diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py new file mode 100644 index 0000000000..39df0a9bbf --- /dev/null +++ b/src/specify_cli/integrations/amp/__init__.py @@ -0,0 +1,21 @@ +"""Amp CLI integration.""" + +from ..base import MarkdownIntegration + + +class AmpIntegration(MarkdownIntegration): + key = "amp" + config = { + "name": "Amp", + "folder": ".agents/", + "commands_subdir": "commands", + "install_url": "https://ampcode.com/manual#install", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1 new file mode 100644 index 0000000000..c217b99f9a --- /dev/null +++ b/src/specify_cli/integrations/amp/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Amp integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh new file mode 100755 index 0000000000..56cbf6e787 --- /dev/null +++ b/src/specify_cli/integrations/amp/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Amp integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" amp diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py new file mode 100644 index 0000000000..9715e936ef --- /dev/null +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -0,0 +1,21 @@ +"""Auggie CLI integration.""" + +from ..base import MarkdownIntegration + + +class AuggieIntegration(MarkdownIntegration): + key = "auggie" + config = { + "name": "Auggie CLI", + "folder": ".augment/", + "commands_subdir": "commands", + "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".augment/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".augment/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 new file mode 100644 index 0000000000..49e7e6b5f3 --- /dev/null +++ b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Auggie CLI integration: create/update .augment/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh new file mode 100755 index 0000000000..4cf80bba2b --- /dev/null +++ b/src/specify_cli/integrations/auggie/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Auggie CLI integration: create/update .augment/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" auggie diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 012b45c4c2..0320d7f7ab 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -206,6 +206,53 @@ def write_file_and_record( manifest.record_existing(rel) return dest + def integration_scripts_dir(self) -> Path | None: + """Return path to this integration's bundled ``scripts/`` directory. + + Looks for a ``scripts/`` sibling of the module that defines the + concrete subclass (not ``IntegrationBase`` itself). + Returns ``None`` if the directory doesn't exist. + """ + import inspect + + cls_file = inspect.getfile(type(self)) + scripts = Path(cls_file).resolve().parent / "scripts" + return scripts if scripts.is_dir() else None + + def install_scripts( + self, + project_root: Path, + manifest: IntegrationManifest, + ) -> list[Path]: + """Copy integration-specific scripts into the project. + + Copies files from this integration's ``scripts/`` directory to + ``.specify/integrations//scripts/`` in the project. Shell + scripts are made executable. All copied files are recorded in + *manifest*. + + Returns the list of files created. + """ + scripts_src = self.integration_scripts_dir() + if not scripts_src: + return [] + + created: list[Path] = [] + scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts" + scripts_dest.mkdir(parents=True, exist_ok=True) + + for src_script in sorted(scripts_src.iterdir()): + if not src_script.is_file(): + continue + dst_script = scripts_dest / src_script.name + shutil.copy2(src_script, dst_script) + if dst_script.suffix == ".sh": + dst_script.chmod(dst_script.stat().st_mode | 0o111) + self.record_file_in_manifest(dst_script, project_root, manifest) + created.append(dst_script) + + return created + @staticmethod def process_template( content: str, @@ -299,13 +346,11 @@ def process_template( # 6. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) - # 7. Rewrite paths (matches release script's rewrite_paths()) - content = re.sub(r"(/?)memory/", r".specify/memory/", content) - content = re.sub(r"(/?)scripts/", r".specify/scripts/", content) - content = re.sub(r"(/?)templates/", r".specify/templates/", content) - # Fix double-prefix (same as release script's .specify.specify/ fix) - content = content.replace(".specify.specify/", ".specify/") - content = content.replace(".specify/.specify/", ".specify/") + # 7. Rewrite paths — delegate to the shared implementation in + # CommandRegistrar so extension-local paths are preserved and + # boundary rules stay consistent across the codebase. + from specify_cli.agents import CommandRegistrar + content = CommandRegistrar.rewrite_project_relative_paths(content) return content @@ -405,11 +450,51 @@ class MarkdownIntegration(IntegrationBase): Subclasses only need to set ``key``, ``config``, ``registrar_config`` (and optionally ``context_file``). Everything else is inherited. - The default ``setup()`` from ``IntegrationBase`` copies templates - into the agent's commands directory — which is correct for the - standard Markdown case. + ``setup()`` processes command templates (replacing ``{SCRIPT}``, + ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs + integration-specific scripts (``update-context.sh`` / ``.ps1``). """ - # MarkdownIntegration inherits IntegrationBase.setup() as-is. - # Future stages may add markdown-specific path rewriting here. - pass + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py new file mode 100644 index 0000000000..78f2df0379 --- /dev/null +++ b/src/specify_cli/integrations/bob/__init__.py @@ -0,0 +1,21 @@ +"""IBM Bob integration.""" + +from ..base import MarkdownIntegration + + +class BobIntegration(MarkdownIntegration): + key = "bob" + config = { + "name": "IBM Bob", + "folder": ".bob/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".bob/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/bob/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1 new file mode 100644 index 0000000000..188860899f --- /dev/null +++ b/src/specify_cli/integrations/bob/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — IBM Bob integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh new file mode 100755 index 0000000000..0228603fea --- /dev/null +++ b/src/specify_cli/integrations/bob/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — IBM Bob integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" bob diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py new file mode 100644 index 0000000000..00375ead51 --- /dev/null +++ b/src/specify_cli/integrations/claude/__init__.py @@ -0,0 +1,21 @@ +"""Claude Code integration.""" + +from ..base import MarkdownIntegration + + +class ClaudeIntegration(MarkdownIntegration): + key = "claude" + config = { + "name": "Claude Code", + "folder": ".claude/", + "commands_subdir": "commands", + "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", + "requires_cli": True, + } + registrar_config = { + "dir": ".claude/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "CLAUDE.md" diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1 new file mode 100644 index 0000000000..837974d47a --- /dev/null +++ b/src/specify_cli/integrations/claude/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Claude Code integration: create/update CLAUDE.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh new file mode 100755 index 0000000000..4b83855a27 --- /dev/null +++ b/src/specify_cli/integrations/claude/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Claude Code integration: create/update CLAUDE.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" claude diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py new file mode 100644 index 0000000000..061ac7641f --- /dev/null +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -0,0 +1,21 @@ +"""CodeBuddy CLI integration.""" + +from ..base import MarkdownIntegration + + +class CodebuddyIntegration(MarkdownIntegration): + key = "codebuddy" + config = { + "name": "CodeBuddy", + "folder": ".codebuddy/", + "commands_subdir": "commands", + "install_url": "https://www.codebuddy.ai/cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".codebuddy/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "CODEBUDDY.md" diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 new file mode 100644 index 0000000000..0269392c09 --- /dev/null +++ b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — CodeBuddy integration: create/update CODEBUDDY.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh new file mode 100755 index 0000000000..d57ddc3560 --- /dev/null +++ b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — CodeBuddy integration: create/update CODEBUDDY.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codebuddy diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0c5354d533..036f2e1db7 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -118,19 +118,7 @@ def setup( created.append(dst_settings) # 4. Install integration-specific update-context scripts - scripts_src = Path(__file__).resolve().parent / "scripts" - if scripts_src.is_dir(): - scripts_dest = project_root / ".specify" / "integrations" / "copilot" / "scripts" - scripts_dest.mkdir(parents=True, exist_ok=True) - for src_script in sorted(scripts_src.iterdir()): - if src_script.is_file(): - dst_script = scripts_dest / src_script.name - shutil.copy2(src_script, dst_script) - # Make shell scripts executable - if dst_script.suffix == ".sh": - dst_script.chmod(dst_script.stat().st_mode | 0o111) - self.record_file_in_manifest(dst_script, project_root, manifest) - created.append(dst_script) + created.extend(self.install_scripts(project_root, manifest)) return created diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 index c6f9845a35..26e746a789 100644 --- a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 +++ b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 @@ -14,8 +14,18 @@ $ErrorActionPreference = 'Stop' -$repoRoot = git rev-parse --show-toplevel 2>$null -if (-not $repoRoot) { $repoRoot = $PWD.Path } +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} # Invoke shared update-agent-context script as a separate process. # Dot-sourcing is unsafe until that script guards its Main call. diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh index 84c86422ea..c7f3bc60b5 100644 --- a/src/specify_cli/integrations/copilot/scripts/update-context.sh +++ b/src/specify_cli/integrations/copilot/scripts/update-context.sh @@ -15,7 +15,22 @@ set -euo pipefail -REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi # Invoke shared update-agent-context script as a separate process. # Sourcing is unsafe until that script guards its main logic. diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py new file mode 100644 index 0000000000..c244a7c01a --- /dev/null +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -0,0 +1,21 @@ +"""Cursor IDE integration.""" + +from ..base import MarkdownIntegration + + +class CursorAgentIntegration(MarkdownIntegration): + key = "cursor-agent" + config = { + "name": "Cursor", + "folder": ".cursor/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".cursor/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".cursor/rules/specify-rules.mdc" diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 new file mode 100644 index 0000000000..4ce50a4873 --- /dev/null +++ b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Cursor integration: create/update .cursor/rules/specify-rules.mdc +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh new file mode 100755 index 0000000000..597ca2289c --- /dev/null +++ b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Cursor integration: create/update .cursor/rules/specify-rules.mdc +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" cursor-agent diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py new file mode 100644 index 0000000000..4acc2cf372 --- /dev/null +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -0,0 +1,21 @@ +"""iFlow CLI integration.""" + +from ..base import MarkdownIntegration + + +class IflowIntegration(MarkdownIntegration): + key = "iflow" + config = { + "name": "iFlow CLI", + "folder": ".iflow/", + "commands_subdir": "commands", + "install_url": "https://docs.iflow.cn/en/cli/quickstart", + "requires_cli": True, + } + registrar_config = { + "dir": ".iflow/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "IFLOW.md" diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 new file mode 100644 index 0000000000..b502d4182a --- /dev/null +++ b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — iFlow CLI integration: create/update IFLOW.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh new file mode 100755 index 0000000000..5080402071 --- /dev/null +++ b/src/specify_cli/integrations/iflow/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — iFlow CLI integration: create/update IFLOW.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" iflow diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py new file mode 100644 index 0000000000..0cc3b3f0ff --- /dev/null +++ b/src/specify_cli/integrations/junie/__init__.py @@ -0,0 +1,21 @@ +"""Junie integration (JetBrains).""" + +from ..base import MarkdownIntegration + + +class JunieIntegration(MarkdownIntegration): + key = "junie" + config = { + "name": "Junie", + "folder": ".junie/", + "commands_subdir": "commands", + "install_url": "https://junie.jetbrains.com/", + "requires_cli": True, + } + registrar_config = { + "dir": ".junie/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".junie/AGENTS.md" diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1 new file mode 100644 index 0000000000..5a32432132 --- /dev/null +++ b/src/specify_cli/integrations/junie/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Junie integration: create/update .junie/AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh new file mode 100755 index 0000000000..f4c8ba6c0e --- /dev/null +++ b/src/specify_cli/integrations/junie/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Junie integration: create/update .junie/AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" junie diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py new file mode 100644 index 0000000000..ffd38f741a --- /dev/null +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -0,0 +1,21 @@ +"""Kilo Code integration.""" + +from ..base import MarkdownIntegration + + +class KilocodeIntegration(MarkdownIntegration): + key = "kilocode" + config = { + "name": "Kilo Code", + "folder": ".kilocode/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".kilocode/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".kilocode/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 new file mode 100644 index 0000000000..d87e7ef59f --- /dev/null +++ b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Kilo Code integration: create/update .kilocode/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh new file mode 100755 index 0000000000..132c0403f3 --- /dev/null +++ b/src/specify_cli/integrations/kilocode/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Kilo Code integration: create/update .kilocode/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kilocode diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py new file mode 100644 index 0000000000..b316cb4bd2 --- /dev/null +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -0,0 +1,21 @@ +"""Kiro CLI integration.""" + +from ..base import MarkdownIntegration + + +class KiroCliIntegration(MarkdownIntegration): + key = "kiro-cli" + config = { + "name": "Kiro CLI", + "folder": ".kiro/", + "commands_subdir": "prompts", + "install_url": "https://kiro.dev/docs/cli/", + "requires_cli": True, + } + registrar_config = { + "dir": ".kiro/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 new file mode 100644 index 0000000000..7dd2b35fb7 --- /dev/null +++ b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Kiro CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh new file mode 100755 index 0000000000..fa258edc75 --- /dev/null +++ b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Kiro CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kiro-cli diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py new file mode 100644 index 0000000000..be4dcc3094 --- /dev/null +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -0,0 +1,21 @@ +"""opencode integration.""" + +from ..base import MarkdownIntegration + + +class OpencodeIntegration(MarkdownIntegration): + key = "opencode" + config = { + "name": "opencode", + "folder": ".opencode/", + "commands_subdir": "command", + "install_url": "https://opencode.ai", + "requires_cli": True, + } + registrar_config = { + "dir": ".opencode/command", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 new file mode 100644 index 0000000000..4bba02b455 --- /dev/null +++ b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — opencode integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh new file mode 100755 index 0000000000..24c7e60251 --- /dev/null +++ b/src/specify_cli/integrations/opencode/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — opencode integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" opencode diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py new file mode 100644 index 0000000000..8a25f326ba --- /dev/null +++ b/src/specify_cli/integrations/pi/__init__.py @@ -0,0 +1,21 @@ +"""Pi Coding Agent integration.""" + +from ..base import MarkdownIntegration + + +class PiIntegration(MarkdownIntegration): + key = "pi" + config = { + "name": "Pi Coding Agent", + "folder": ".pi/", + "commands_subdir": "prompts", + "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", + "requires_cli": True, + } + registrar_config = { + "dir": ".pi/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1 new file mode 100644 index 0000000000..6362118a5b --- /dev/null +++ b/src/specify_cli/integrations/pi/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Pi Coding Agent integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh new file mode 100755 index 0000000000..1ad84c95a2 --- /dev/null +++ b/src/specify_cli/integrations/pi/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Pi Coding Agent integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" pi diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py new file mode 100644 index 0000000000..541001be17 --- /dev/null +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -0,0 +1,21 @@ +"""Qoder CLI integration.""" + +from ..base import MarkdownIntegration + + +class QodercliIntegration(MarkdownIntegration): + key = "qodercli" + config = { + "name": "Qoder CLI", + "folder": ".qoder/", + "commands_subdir": "commands", + "install_url": "https://qoder.com/cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".qoder/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "QODER.md" diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 new file mode 100644 index 0000000000..1fa007a168 --- /dev/null +++ b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Qoder CLI integration: create/update QODER.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh new file mode 100755 index 0000000000..d371ad7952 --- /dev/null +++ b/src/specify_cli/integrations/qodercli/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Qoder CLI integration: create/update QODER.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qodercli diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py new file mode 100644 index 0000000000..d9d930152c --- /dev/null +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -0,0 +1,21 @@ +"""Qwen Code integration.""" + +from ..base import MarkdownIntegration + + +class QwenIntegration(MarkdownIntegration): + key = "qwen" + config = { + "name": "Qwen Code", + "folder": ".qwen/", + "commands_subdir": "commands", + "install_url": "https://github.com/QwenLM/qwen-code", + "requires_cli": True, + } + registrar_config = { + "dir": ".qwen/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "QWEN.md" diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 new file mode 100644 index 0000000000..24e4c90fab --- /dev/null +++ b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Qwen Code integration: create/update QWEN.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh new file mode 100755 index 0000000000..d1c62eb161 --- /dev/null +++ b/src/specify_cli/integrations/qwen/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Qwen Code integration: create/update QWEN.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qwen diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py new file mode 100644 index 0000000000..3c680e7e35 --- /dev/null +++ b/src/specify_cli/integrations/roo/__init__.py @@ -0,0 +1,21 @@ +"""Roo Code integration.""" + +from ..base import MarkdownIntegration + + +class RooIntegration(MarkdownIntegration): + key = "roo" + config = { + "name": "Roo Code", + "folder": ".roo/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".roo/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".roo/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1 new file mode 100644 index 0000000000..d1dec923ed --- /dev/null +++ b/src/specify_cli/integrations/roo/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Roo Code integration: create/update .roo/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh new file mode 100755 index 0000000000..8fe255cb1b --- /dev/null +++ b/src/specify_cli/integrations/roo/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Roo Code integration: create/update .roo/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" roo diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py new file mode 100644 index 0000000000..7a9d1deb02 --- /dev/null +++ b/src/specify_cli/integrations/shai/__init__.py @@ -0,0 +1,21 @@ +"""SHAI CLI integration.""" + +from ..base import MarkdownIntegration + + +class ShaiIntegration(MarkdownIntegration): + key = "shai" + config = { + "name": "SHAI", + "folder": ".shai/", + "commands_subdir": "commands", + "install_url": "https://github.com/ovh/shai", + "requires_cli": True, + } + registrar_config = { + "dir": ".shai/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "SHAI.md" diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1 new file mode 100644 index 0000000000..2c621c76ac --- /dev/null +++ b/src/specify_cli/integrations/shai/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — SHAI integration: create/update SHAI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh new file mode 100755 index 0000000000..093b9d1f76 --- /dev/null +++ b/src/specify_cli/integrations/shai/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — SHAI integration: create/update SHAI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" shai diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py new file mode 100644 index 0000000000..7037eecb8c --- /dev/null +++ b/src/specify_cli/integrations/trae/__init__.py @@ -0,0 +1,21 @@ +"""Trae IDE integration.""" + +from ..base import MarkdownIntegration + + +class TraeIntegration(MarkdownIntegration): + key = "trae" + config = { + "name": "Trae", + "folder": ".trae/", + "commands_subdir": "rules", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".trae/rules", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".trae/rules/AGENTS.md" diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 new file mode 100644 index 0000000000..f72d96318e --- /dev/null +++ b/src/specify_cli/integrations/trae/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Trae integration: create/update .trae/rules/AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh new file mode 100755 index 0000000000..b868a7c983 --- /dev/null +++ b/src/specify_cli/integrations/trae/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Trae integration: create/update .trae/rules/AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" trae diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py new file mode 100644 index 0000000000..dcc4a60dda --- /dev/null +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -0,0 +1,21 @@ +"""Mistral Vibe CLI integration.""" + +from ..base import MarkdownIntegration + + +class VibeIntegration(MarkdownIntegration): + key = "vibe" + config = { + "name": "Mistral Vibe", + "folder": ".vibe/", + "commands_subdir": "prompts", + "install_url": "https://github.com/mistralai/mistral-vibe", + "requires_cli": True, + } + registrar_config = { + "dir": ".vibe/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".vibe/agents/specify-agents.md" diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 new file mode 100644 index 0000000000..d82ce3389c --- /dev/null +++ b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh new file mode 100755 index 0000000000..f924cdb896 --- /dev/null +++ b/src/specify_cli/integrations/vibe/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" vibe diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py new file mode 100644 index 0000000000..f0f77d318e --- /dev/null +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -0,0 +1,21 @@ +"""Windsurf IDE integration.""" + +from ..base import MarkdownIntegration + + +class WindsurfIntegration(MarkdownIntegration): + key = "windsurf" + config = { + "name": "Windsurf", + "folder": ".windsurf/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".windsurf/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 new file mode 100644 index 0000000000..b5fe1d0c0a --- /dev/null +++ b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Windsurf integration: create/update .windsurf/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh new file mode 100755 index 0000000000..b9a78d320e --- /dev/null +++ b/src/specify_cli/integrations/windsurf/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Windsurf integration: create/update .windsurf/rules/specify-rules.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" windsurf diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py new file mode 100644 index 0000000000..a36dd47136 --- /dev/null +++ b/tests/integrations/test_integration_amp.py @@ -0,0 +1,11 @@ +"""Tests for AmpIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestAmpIntegration(MarkdownIntegrationTests): + KEY = "amp" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".agents/commands" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py new file mode 100644 index 0000000000..e4033a23e8 --- /dev/null +++ b/tests/integrations/test_integration_auggie.py @@ -0,0 +1,11 @@ +"""Tests for AuggieIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestAuggieIntegration(MarkdownIntegrationTests): + KEY = "auggie" + FOLDER = ".augment/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".augment/commands" + CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py new file mode 100644 index 0000000000..75319eb944 --- /dev/null +++ b/tests/integrations/test_integration_base_markdown.py @@ -0,0 +1,296 @@ +"""Reusable test mixin for standard MarkdownIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``MarkdownIntegrationTests``. +""" + +import os + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import MarkdownIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class MarkdownIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".claude/" + COMMANDS_SUBDIR: str — e.g. "commands" + REGISTRAR_DIR: str — e.g. ".claude/commands" + CONTEXT_FILE: str — e.g. "CLAUDE.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_markdown_integration(self): + assert isinstance(get_integration(self.KEY), MarkdownIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "markdown" + assert i.registrar_config["args"] == "$ARGUMENTS" + assert i.registrar_config["extension"] == ".md" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced, not raw templates.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" + assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_scripts: block" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + assert f"--integration {self.KEY}" in result.output + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.md") + + # Integration scripts + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") + + # Framework files + files.append(f".specify/integration.json") + files.append(f".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(f".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "setup-plan.sh", "update-agent-context.sh"]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "setup-plan.ps1", "update-agent-context.ps1"]: + files.append(f".specify/scripts/powershell/{name}") + + for name in ["agent-file-template.md", "checklist-template.md", + "constitution-template.md", "plan-template.md", + "spec-template.md", "tasks-template.md"]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "ps", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py new file mode 100644 index 0000000000..1562f0100c --- /dev/null +++ b/tests/integrations/test_integration_bob.py @@ -0,0 +1,11 @@ +"""Tests for BobIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestBobIntegration(MarkdownIntegrationTests): + KEY = "bob" + FOLDER = ".bob/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".bob/commands" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py new file mode 100644 index 0000000000..6867a295ea --- /dev/null +++ b/tests/integrations/test_integration_claude.py @@ -0,0 +1,11 @@ +"""Tests for ClaudeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestClaudeIntegration(MarkdownIntegrationTests): + KEY = "claude" + FOLDER = ".claude/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".claude/commands" + CONTEXT_FILE = "CLAUDE.md" diff --git a/tests/integrations/test_integration_codebuddy.py b/tests/integrations/test_integration_codebuddy.py new file mode 100644 index 0000000000..dcc2153a7b --- /dev/null +++ b/tests/integrations/test_integration_codebuddy.py @@ -0,0 +1,11 @@ +"""Tests for CodebuddyIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestCodebuddyIntegration(MarkdownIntegrationTests): + KEY = "codebuddy" + FOLDER = ".codebuddy/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".codebuddy/commands" + CONTEXT_FILE = "CODEBUDDY.md" diff --git a/tests/integrations/test_copilot.py b/tests/integrations/test_integration_copilot.py similarity index 100% rename from tests/integrations/test_copilot.py rename to tests/integrations/test_integration_copilot.py diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py new file mode 100644 index 0000000000..71b7db1c98 --- /dev/null +++ b/tests/integrations/test_integration_cursor_agent.py @@ -0,0 +1,11 @@ +"""Tests for CursorAgentIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestCursorAgentIntegration(MarkdownIntegrationTests): + KEY = "cursor-agent" + FOLDER = ".cursor/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".cursor/commands" + CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py new file mode 100644 index 0000000000..ea2f5ef97a --- /dev/null +++ b/tests/integrations/test_integration_iflow.py @@ -0,0 +1,11 @@ +"""Tests for IflowIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestIflowIntegration(MarkdownIntegrationTests): + KEY = "iflow" + FOLDER = ".iflow/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".iflow/commands" + CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py new file mode 100644 index 0000000000..2b924ce434 --- /dev/null +++ b/tests/integrations/test_integration_junie.py @@ -0,0 +1,11 @@ +"""Tests for JunieIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestJunieIntegration(MarkdownIntegrationTests): + KEY = "junie" + FOLDER = ".junie/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".junie/commands" + CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py new file mode 100644 index 0000000000..8e441c0833 --- /dev/null +++ b/tests/integrations/test_integration_kilocode.py @@ -0,0 +1,11 @@ +"""Tests for KilocodeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestKilocodeIntegration(MarkdownIntegrationTests): + KEY = "kilocode" + FOLDER = ".kilocode/" + COMMANDS_SUBDIR = "workflows" + REGISTRAR_DIR = ".kilocode/workflows" + CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py new file mode 100644 index 0000000000..d6ae7afce1 --- /dev/null +++ b/tests/integrations/test_integration_kiro_cli.py @@ -0,0 +1,11 @@ +"""Tests for KiroCliIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestKiroCliIntegration(MarkdownIntegrationTests): + KEY = "kiro-cli" + FOLDER = ".kiro/" + COMMANDS_SUBDIR = "prompts" + REGISTRAR_DIR = ".kiro/prompts" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py new file mode 100644 index 0000000000..4f3aee5d9b --- /dev/null +++ b/tests/integrations/test_integration_opencode.py @@ -0,0 +1,11 @@ +"""Tests for OpencodeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestOpencodeIntegration(MarkdownIntegrationTests): + KEY = "opencode" + FOLDER = ".opencode/" + COMMANDS_SUBDIR = "command" + REGISTRAR_DIR = ".opencode/command" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py new file mode 100644 index 0000000000..5ac5676501 --- /dev/null +++ b/tests/integrations/test_integration_pi.py @@ -0,0 +1,11 @@ +"""Tests for PiIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestPiIntegration(MarkdownIntegrationTests): + KEY = "pi" + FOLDER = ".pi/" + COMMANDS_SUBDIR = "prompts" + REGISTRAR_DIR = ".pi/prompts" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py new file mode 100644 index 0000000000..1dbee480a0 --- /dev/null +++ b/tests/integrations/test_integration_qodercli.py @@ -0,0 +1,11 @@ +"""Tests for QodercliIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestQodercliIntegration(MarkdownIntegrationTests): + KEY = "qodercli" + FOLDER = ".qoder/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".qoder/commands" + CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py new file mode 100644 index 0000000000..10a3c083f4 --- /dev/null +++ b/tests/integrations/test_integration_qwen.py @@ -0,0 +1,11 @@ +"""Tests for QwenIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestQwenIntegration(MarkdownIntegrationTests): + KEY = "qwen" + FOLDER = ".qwen/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".qwen/commands" + CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py new file mode 100644 index 0000000000..69d859c42f --- /dev/null +++ b/tests/integrations/test_integration_roo.py @@ -0,0 +1,11 @@ +"""Tests for RooIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestRooIntegration(MarkdownIntegrationTests): + KEY = "roo" + FOLDER = ".roo/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".roo/commands" + CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py new file mode 100644 index 0000000000..74f93396b1 --- /dev/null +++ b/tests/integrations/test_integration_shai.py @@ -0,0 +1,11 @@ +"""Tests for ShaiIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestShaiIntegration(MarkdownIntegrationTests): + KEY = "shai" + FOLDER = ".shai/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".shai/commands" + CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py new file mode 100644 index 0000000000..307c3481db --- /dev/null +++ b/tests/integrations/test_integration_trae.py @@ -0,0 +1,11 @@ +"""Tests for TraeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestTraeIntegration(MarkdownIntegrationTests): + KEY = "trae" + FOLDER = ".trae/" + COMMANDS_SUBDIR = "rules" + REGISTRAR_DIR = ".trae/rules" + CONTEXT_FILE = ".trae/rules/AGENTS.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py new file mode 100644 index 0000000000..ea6dc85a88 --- /dev/null +++ b/tests/integrations/test_integration_vibe.py @@ -0,0 +1,11 @@ +"""Tests for VibeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestVibeIntegration(MarkdownIntegrationTests): + KEY = "vibe" + FOLDER = ".vibe/" + COMMANDS_SUBDIR = "prompts" + REGISTRAR_DIR = ".vibe/prompts" + CONTEXT_FILE = ".vibe/agents/specify-agents.md" diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py new file mode 100644 index 0000000000..fa8d1e622a --- /dev/null +++ b/tests/integrations/test_integration_windsurf.py @@ -0,0 +1,11 @@ +"""Tests for WindsurfIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestWindsurfIntegration(MarkdownIntegrationTests): + KEY = "windsurf" + FOLDER = ".windsurf/" + COMMANDS_SUBDIR = "workflows" + REGISTRAR_DIR = ".windsurf/workflows" + CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 8fb5ef0668..e70f3006ac 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -1,4 +1,4 @@ -"""Tests for INTEGRATION_REGISTRY.""" +"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment.""" import pytest @@ -11,6 +11,16 @@ from .conftest import StubIntegration +# Every integration key that must be registered (Stage 2 + Stage 3). +ALL_INTEGRATION_KEYS = [ + "copilot", + # Stage 3 — standard markdown integrations + "claude", "qwen", "opencode", "junie", "kilocode", "auggie", + "roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae", + "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", +] + + class TestRegistry: def test_registry_is_dict(self): assert isinstance(INTEGRATION_REGISTRY, dict) @@ -41,5 +51,26 @@ def test_register_duplicate_raises(self): finally: INTEGRATION_REGISTRY.pop("stub", None) - def test_copilot_registered(self): - assert "copilot" in INTEGRATION_REGISTRY + +class TestRegistryCompleteness: + """Every expected integration must be registered.""" + + @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS) + def test_key_registered(self, key): + assert key in INTEGRATION_REGISTRY, f"{key} missing from registry" + + +class TestRegistrarKeyAlignment: + """Every integration key must have a matching AGENT_CONFIGS entry.""" + + @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS) + def test_integration_key_in_registrar(self, key): + from specify_cli.agents import CommandRegistrar + assert key in CommandRegistrar.AGENT_CONFIGS, ( + f"Integration '{key}' is registered but has no AGENT_CONFIGS entry" + ) + + def test_no_stale_cursor_shorthand(self): + """The old 'cursor' shorthand must not appear in AGENT_CONFIGS.""" + from specify_cli.agents import CommandRegistrar + assert "cursor" not in CommandRegistrar.AGENT_CONFIGS diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index f0e220e26a..7f9ecf66ab 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -1237,24 +1237,22 @@ def test_ai_skills_flag_appears_in_help(self): assert "agent skills" in plain.lower() def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): - """--ai kiro should normalize to canonical kiro-cli agent key.""" + """--ai kiro should normalize to canonical kiro-cli and auto-promote to integration path.""" + import os from typer.testing import CliRunner runner = CliRunner() target = tmp_path / "kiro-alias-proj" + target.mkdir() - with patch("specify_cli.download_and_extract_template") as mock_download, \ - patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - mock_scaffold.return_value = True + old_cwd = os.getcwd() + try: + os.chdir(target) result = runner.invoke( app, [ "init", - str(target), + "--here", "--ai", "kiro", "--ignore-agent-tools", @@ -1262,17 +1260,16 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): "sh", "--no-git", ], + catch_exceptions=False, ) + finally: + os.chdir(old_cwd) assert result.exit_code == 0 - # Without --offline, the download path should be taken. - assert mock_download.called, ( - "Expected download_and_extract_template to be called (default non-offline path)" - ) - assert mock_download.call_args.args[1] == "kiro-cli" - assert not mock_scaffold.called, ( - "scaffold_from_core_pack should not be called without --offline" - ) + # kiro alias should auto-promote to integration path with nudge + assert "--integration kiro-cli" in result.output + # Command files should be created via integration path + assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 64b38547d7..a5ee4e03a6 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -981,7 +981,7 @@ def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(sel "Run scripts/bash/setup-plan.sh\n" ) - rewritten = AgentCommandRegistrar._rewrite_project_relative_paths(body) + rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(body) assert ".specify/extensions/test-ext/templates/spec.md" in rewritten assert ".specify/scripts/bash/setup-plan.sh" in rewritten From b606b385124eb385b4ab69ad152ce2c6af8a8221 Mon Sep 17 00:00:00 2001 From: Arun_18 <102735445+arunt14@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:27:31 +0530 Subject: [PATCH 164/321] feat: add 5 lifecycle extensions to community catalog (#2049) * feat: add 5 gstack-inspired lifecycle commands (critique, review, qa, ship, retro) Add 5 new core command templates inspired by Garry Tan's GStack to complete the spec-driven development lifecycle: - /speckit.critique: Dual-lens product + engineering review before implementation - /speckit.review: Staff-level code review (correctness, security, performance) - /speckit.qa: Systematic QA testing (browser-driven and CLI modes) - /speckit.ship: Release automation (pre-flight, changelog, CI, PR creation) - /speckit.retro: Sprint retrospective with metrics and improvement suggestions Each command includes: - Command template in templates/commands/ - Output report template in templates/ - Extension hook support (before_*/after_*) - YAML frontmatter with prerequisite scripts Updated README.md workflow from 6 to 11 steps and added CHANGELOG entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "feat: add 5 gstack-inspired lifecycle commands (critique, review, qa, ship, retro)" This reverts commit 6eb15a7a3ee201d1e3743e5f0d92173b1503f9e6. * feat: add 5 lifecycle extensions to community catalog Add the following community extensions: - staff-review: Staff-engineer-level code review - qa: Systematic QA testing with browser/CLI validation - ship: Release engineering automation - retro: Sprint retrospective with metrics - critique: Dual-lens spec and plan critique Each extension is hosted in its own repository under arunt14/ with v1.0.0 releases available. * fix: resolve mojibake encoding, sort keys, rename retro extension - Fix double-encoded em dashes and arrows in catalog.community.json - Sort extension entries alphabetically by key - Rename 'Retrospective Extension' to 'Retro Extension' to avoid name collision with existing 'retrospective' extension --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 + extensions/catalog.community.json | 154 +++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e49f025fd2..a1ab6bb279 100644 --- a/README.md +++ b/README.md @@ -203,12 +203,17 @@ The following community-contributed extensions are available in [`catalog.commun | Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | +| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | +| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | +| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e24cb2f772..3b64f0bfb5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-30T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -241,6 +241,36 @@ "created_at": "2026-03-19T12:08:20Z", "updated_at": "2026-03-19T12:08:20Z" }, + "critique": { + "name": "Spec Critique Extension", + "id": "critique", + "description": "Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-critique/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-critique", + "homepage": "https://github.com/arunt14/spec-kit-critique", + "documentation": "https://github.com/arunt14/spec-kit-critique/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-critique/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "docs", + "review", + "planning" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "docguard": { "name": "DocGuard — CDD Enforcement", "id": "docguard", @@ -792,7 +822,7 @@ "product-forge": { "name": "Product Forge", "id": "product-forge", - "description": "Full product lifecycle: research \u2192 product spec \u2192 SpecKit \u2192 implement \u2192 verify \u2192 test", + "description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test", "author": "VaiYav", "version": "1.1.1", "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip", @@ -821,6 +851,36 @@ "created_at": "2026-03-28T00:00:00Z", "updated_at": "2026-03-28T00:00:00Z" }, + "qa": { + "name": "QA Testing Extension", + "id": "qa", + "description": "Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-qa/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-qa", + "homepage": "https://github.com/arunt14/spec-kit-qa", + "documentation": "https://github.com/arunt14/spec-kit-qa/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-qa/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "testing", + "qa" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "ralph": { "name": "Ralph Loop", "id": "ralph", @@ -893,6 +953,36 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "retro": { + "name": "Retro Extension", + "id": "retro", + "description": "Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-retro/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-retro", + "homepage": "https://github.com/arunt14/spec-kit-retro", + "documentation": "https://github.com/arunt14/spec-kit-retro/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-retro/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "process", + "retrospective", + "metrics" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "retrospective": { "name": "Retrospective Extension", "id": "retrospective", @@ -959,6 +1049,36 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-06T00:00:00Z" }, + "ship": { + "name": "Ship Release Extension", + "id": "ship", + "description": "Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-ship/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-ship", + "homepage": "https://github.com/arunt14/spec-kit-ship", + "documentation": "https://github.com/arunt14/spec-kit-ship/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-ship/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "process", + "release", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "speckit-utils": { "name": "SDD Utilities", "id": "speckit-utils", @@ -991,6 +1111,36 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "staff-review": { + "name": "Staff Review Extension", + "id": "staff-review", + "description": "Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-staff-review/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-staff-review", + "homepage": "https://github.com/arunt14/spec-kit-staff-review", + "documentation": "https://github.com/arunt14/spec-kit-staff-review/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-staff-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "review", + "quality" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "status": { "name": "Project Status", "id": "status", From 682ffbfc0d0a34aabf62aba2f7bba151391dd599 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:26:48 -0500 Subject: [PATCH 165/321] =?UTF-8?q?Stage=204:=20TOML=20integrations=20?= =?UTF-8?q?=E2=80=94=20gemini=20and=20tabnine=20migrated=20to=20plugin=20a?= =?UTF-8?q?rchitecture=20(#2050)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TomlIntegration base class in base.py that mirrors MarkdownIntegration: - Overrides command_filename() for .toml extension - Extracts description from YAML frontmatter for top-level TOML key - Renders prompt body in TOML multiline basic strings with escaped backslashes - Keeps full processed template (including frontmatter) as prompt body - Byte-for-byte parity with v0.4.4 release ZIP output Create integrations/gemini/ and integrations/tabnine/ subpackages: - Config-only __init__.py subclassing TomlIntegration - Integration-specific update-context scripts (sh + ps1) Add TomlIntegrationTests mixin with TOML-specific validations: - Valid TOML parsing, description/prompt keys, {{args}} placeholder - Setup/teardown, manifest tracking, install/uninstall round-trips - CLI auto-promote (--ai) and --integration flag tests - Complete file inventory tests (sh + ps) Register both in INTEGRATION_REGISTRY; --ai auto-promote works automatically. --- src/specify_cli/integrations/__init__.py | 4 + src/specify_cli/integrations/base.py | 135 +++++++ .../integrations/gemini/__init__.py | 21 ++ .../gemini/scripts/update-context.ps1 | 23 ++ .../gemini/scripts/update-context.sh | 28 ++ .../integrations/tabnine/__init__.py | 21 ++ .../tabnine/scripts/update-context.ps1 | 23 ++ .../tabnine/scripts/update-context.sh | 28 ++ .../test_integration_base_toml.py | 346 ++++++++++++++++++ tests/integrations/test_integration_gemini.py | 11 + .../integrations/test_integration_tabnine.py | 11 + 11 files changed, 651 insertions(+) create mode 100644 src/specify_cli/integrations/gemini/__init__.py create mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.ps1 create mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/tabnine/__init__.py create mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.ps1 create mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.sh create mode 100644 tests/integrations/test_integration_base_toml.py create mode 100644 tests/integrations/test_integration_gemini.py create mode 100644 tests/integrations/test_integration_tabnine.py diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 0d7a712427..ed131103c1 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -53,6 +53,7 @@ def _register_builtins() -> None: from .codebuddy import CodebuddyIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration + from .gemini import GeminiIntegration from .iflow import IflowIntegration from .junie import JunieIntegration from .kilocode import KilocodeIntegration @@ -63,6 +64,7 @@ def _register_builtins() -> None: from .qwen import QwenIntegration from .roo import RooIntegration from .shai import ShaiIntegration + from .tabnine import TabnineIntegration from .trae import TraeIntegration from .vibe import VibeIntegration from .windsurf import WindsurfIntegration @@ -75,6 +77,7 @@ def _register_builtins() -> None: _register(CodebuddyIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) + _register(GeminiIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) @@ -85,6 +88,7 @@ def _register_builtins() -> None: _register(QwenIntegration()) _register(RooIntegration()) _register(ShaiIntegration()) + _register(TabnineIntegration()) _register(TraeIntegration()) _register(VibeIntegration()) _register(WindsurfIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 0320d7f7ab..a88039b9a0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -5,6 +5,8 @@ - ``IntegrationBase`` — abstract base every integration must implement. - ``MarkdownIntegration`` — concrete base for standard Markdown-format integrations (the common case — subclass, set three class attrs, done). +- ``TomlIntegration`` — concrete base for TOML-format integrations + (Gemini, Tabnine — subclass, set three class attrs, done). """ from __future__ import annotations @@ -498,3 +500,136 @@ def setup( created.extend(self.install_scripts(project_root, manifest)) return created + + +# --------------------------------------------------------------------------- +# TomlIntegration — TOML-format agents (Gemini, Tabnine) +# --------------------------------------------------------------------------- + +class TomlIntegration(IntegrationBase): + """Concrete base for integrations that use TOML command format. + + Mirrors ``MarkdownIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config`` (and optionally + ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + TOML format (``description`` key + ``prompt`` multiline string). + """ + + def command_filename(self, template_name: str) -> str: + """TOML commands use ``.toml`` extension.""" + return f"speckit.{template_name}.toml" + + @staticmethod + def _extract_description(content: str) -> str: + """Extract the ``description`` value from YAML frontmatter. + + Scans lines between the first pair of ``---`` delimiters for a + top-level ``description:`` key. Returns the value (with + surrounding quotes stripped) or an empty string if not found. + """ + in_frontmatter = False + for line in content.splitlines(): + stripped = line.rstrip("\n\r") + if stripped == "---": + if not in_frontmatter: + in_frontmatter = True + continue + break # second --- + if in_frontmatter and stripped.startswith("description:"): + _, _, value = stripped.partition(":") + return value.strip().strip('"').strip("'") + return "" + + @staticmethod + def _render_toml(description: str, body: str) -> str: + """Render a TOML command file from description and body. + + Uses multiline basic strings (``\"\"\"``) with backslashes + escaped, matching the output of the release script. Falls back + to multiline literal strings (``'''``) if the body contains + ``\"\"\"``, then to an escaped basic string as a last resort. + + The body is rstrip'd so the closing delimiter appears on the line + immediately after the last content line — matching the release + script's ``echo "$body"; echo '\"\"\"'`` pattern. + """ + toml_lines: list[str] = [] + + if description: + desc = description.replace('"', '\\"') + toml_lines.append(f'description = "{desc}"') + toml_lines.append("") + + body = body.rstrip("\n") + + # Escape backslashes for basic multiline strings. + escaped = body.replace("\\", "\\\\") + + if '"""' not in escaped: + toml_lines.append('prompt = """') + toml_lines.append(escaped) + toml_lines.append('"""') + elif "'''" not in body: + toml_lines.append("prompt = '''") + toml_lines.append(body) + toml_lines.append("'''") + else: + escaped_body = ( + body.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + toml_lines.append(f'prompt = "{escaped_body}"') + + return "\n".join(toml_lines) + "\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + description = self._extract_description(raw) + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + toml_content = self._render_toml(description, processed) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + toml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py new file mode 100644 index 0000000000..d66f0b80bc --- /dev/null +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -0,0 +1,21 @@ +"""Gemini CLI integration.""" + +from ..base import TomlIntegration + + +class GeminiIntegration(TomlIntegration): + key = "gemini" + config = { + "name": "Gemini CLI", + "folder": ".gemini/", + "commands_subdir": "commands", + "install_url": "https://github.com/google-gemini/gemini-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "GEMINI.md" diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 new file mode 100644 index 0000000000..51c9e0bc83 --- /dev/null +++ b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh new file mode 100644 index 0000000000..c4e5003a55 --- /dev/null +++ b/src/specify_cli/integrations/gemini/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Gemini CLI integration: create/update GEMINI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py new file mode 100644 index 0000000000..2928a214a7 --- /dev/null +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -0,0 +1,21 @@ +"""Tabnine CLI integration.""" + +from ..base import TomlIntegration + + +class TabnineIntegration(TomlIntegration): + key = "tabnine" + config = { + "name": "Tabnine CLI", + "folder": ".tabnine/agent/", + "commands_subdir": "commands", + "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".tabnine/agent/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "TABNINE.md" diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 new file mode 100644 index 0000000000..0ffb3a1649 --- /dev/null +++ b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 @@ -0,0 +1,23 @@ +# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh new file mode 100644 index 0000000000..fe5050b6e9 --- /dev/null +++ b/src/specify_cli/integrations/tabnine/scripts/update-context.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# update-context.sh — Tabnine CLI integration: create/update TABNINE.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py new file mode 100644 index 0000000000..e7b506782f --- /dev/null +++ b/tests/integrations/test_integration_base_toml.py @@ -0,0 +1,346 @@ +"""Reusable test mixin for standard TomlIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``TomlIntegrationTests``. + +Mirrors ``MarkdownIntegrationTests`` closely — same test structure, +adapted for TOML output format. +""" + +import os + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import TomlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TomlIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".gemini/" + COMMANDS_SUBDIR: str — e.g. "commands" + REGISTRAR_DIR: str — e.g. ".gemini/commands" + CONTEXT_FILE: str — e.g. "GEMINI.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_toml_integration(self): + assert isinstance(get_integration(self.KEY), TomlIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "toml" + assert i.registrar_config["args"] == "{{args}}" + assert i.registrar_config["extension"] == ".toml" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".toml") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced and be valid TOML.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_toml_has_description(self, tmp_path): + """Every TOML command file should have a description key.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert 'description = "' in content, f"{f.name} missing description key" + + def test_toml_has_prompt(self, tmp_path): + """Every TOML command file should have a prompt key.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "prompt = " in content, f"{f.name} missing prompt key" + + def test_toml_uses_correct_arg_placeholder(self, tmp_path): + """TOML commands must use {{args}} (from {ARGS} replacement).""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + # At least one file should contain {{args}} from the {ARGS} placeholder + has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) + assert has_args, "No TOML command file contains {{args}} placeholder" + + def test_toml_is_valid(self, tmp_path): + """Every generated TOML file must parse without errors.""" + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + raw = f.read_bytes() + try: + parsed = tomllib.loads(raw.decode("utf-8")) + except Exception as exc: + raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc + assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + assert f"--integration {self.KEY}" in result.output + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*.toml")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files (.toml) + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.toml") + + # Integration scripts + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") + + # Framework files + files.append(f".specify/integration.json") + files.append(f".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(f".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "setup-plan.sh", "update-agent-context.sh"]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "setup-plan.ps1", "update-agent-context.ps1"]: + files.append(f".specify/scripts/powershell/{name}") + + for name in ["agent-file-template.md", "checklist-template.md", + "constitution-template.md", "plan-template.md", + "spec-template.md", "tasks-template.md"]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "ps", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py new file mode 100644 index 0000000000..9be5985e29 --- /dev/null +++ b/tests/integrations/test_integration_gemini.py @@ -0,0 +1,11 @@ +"""Tests for GeminiIntegration.""" + +from .test_integration_base_toml import TomlIntegrationTests + + +class TestGeminiIntegration(TomlIntegrationTests): + KEY = "gemini" + FOLDER = ".gemini/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".gemini/commands" + CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py new file mode 100644 index 0000000000..95eb47cc16 --- /dev/null +++ b/tests/integrations/test_integration_tabnine.py @@ -0,0 +1,11 @@ +"""Tests for TabnineIntegration.""" + +from .test_integration_base_toml import TomlIntegrationTests + + +class TestTabnineIntegration(TomlIntegrationTests): + KEY = "tabnine" + FOLDER = ".tabnine/agent/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".tabnine/agent/commands" + CONTEXT_FILE = "TABNINE.md" From 4df6d963dca281343bd4b780cca6ddc9d88be7f5 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 1 Apr 2026 22:51:06 +0500 Subject: [PATCH 166/321] Add fix-findings extension to community catalog (#2039) - Extension ID: fix-findings - Version: 1.0.0 - Author: Quratulain-bilal - Description: Automated analyze-fix-reanalyze loop that resolves spec findings until clean - Addresses #2011 Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index a1ab6bb279..462c24c05d 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The following community-contributed extensions are available in [`catalog.commun | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | +| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3b64f0bfb5..7a37430fb7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -375,6 +375,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "fix-findings": { + "name": "Fix Findings", + "id": "fix-findings", + "description": "Automated analyze-fix-reanalyze loop that resolves spec findings until clean.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-fix-findings", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-fix-findings", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "analysis", + "quality", + "automation", + "findings" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, "fleet": { "name": "Fleet Orchestrator", "id": "fleet", From 97b9f0f00d269832d879b3053e2bf4465956f7b1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:22:48 -0500 Subject: [PATCH 167/321] docs: remove dead Cognitive Squad and Understanding extension links and from extensions/catalog.community.json (#2057) * docs: remove dead Cognitive Squad and Understanding extension links Both repos (Testimonial/cognitive-squad and Testimonial/understanding) have been deleted by their author. No forks or relocations exist. * chore: remove dead extensions from community catalog Remove cognitive-squad and understanding entries whose repos have been deleted by their author. --- README.md | 2 - extensions/catalog.community.json | 85 ------------------------------- 2 files changed, 87 deletions(-) diff --git a/README.md b/README.md index 462c24c05d..aefcefb9ca 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,6 @@ The following community-contributed extensions are available in [`catalog.commun | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | -| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | @@ -216,7 +215,6 @@ The following community-contributed extensions are available in [`catalog.commun | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | -| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 7a37430fb7..f04b370678 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -167,50 +167,6 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, - "cognitive-squad": { - "name": "Cognitive Squad", - "id": "cognitive-squad", - "description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing", - "author": "Testimonial", - "version": "0.1.0", - "download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip", - "repository": "https://github.com/Testimonial/cognitive-squad", - "homepage": "https://github.com/Testimonial/cognitive-squad", - "documentation": "https://github.com/Testimonial/cognitive-squad/blob/main/README.md", - "changelog": "https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.3.0", - "tools": [ - { - "name": "understanding", - "version": ">=3.4.0", - "required": false - }, - { - "name": "spec-kit-reverse-eng", - "version": ">=1.0.0", - "required": false - } - ] - }, - "provides": { - "commands": 10, - "hooks": 1 - }, - "tags": [ - "ai-agents", - "cognitive", - "full-lifecycle", - "verification", - "multi-agent" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-03-16T00:00:00Z", - "updated_at": "2026-03-18T00:00:00Z" - }, "conduct": { "name": "Conduct Extension", "id": "conduct", @@ -1280,47 +1236,6 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, - "understanding": { - "name": "Understanding", - "id": "understanding", - "description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.", - "author": "Ladislav Bihari", - "version": "3.4.0", - "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip", - "repository": "https://github.com/Testimonial/understanding", - "homepage": "https://github.com/Testimonial/understanding", - "documentation": "https://github.com/Testimonial/understanding/blob/main/extension/README.md", - "changelog": "https://github.com/Testimonial/understanding/blob/main/extension/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "understanding", - "version": ">=3.4.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": [ - "quality", - "metrics", - "requirements", - "validation", - "readability", - "IEEE-830", - "ISO-29148" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-03-07T00:00:00Z", - "updated_at": "2026-03-07T00:00:00Z" - }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From ea60efe2faee43a4235c37ebf643b40ccaa367f8 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:26:25 +0200 Subject: [PATCH 168/321] docs: add community extensions website link to README and extensions docs (#2014) --- README.md | 2 ++ extensions/README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index aefcefb9ca..ac7d9f4072 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c ## 🧩 Community Extensions +🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** + The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json): **Categories:** diff --git a/extensions/README.md b/extensions/README.md index a8eedf7ce8..379e2d92f7 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -68,6 +68,8 @@ specify extension add --from https://github.com/org/spec-kit-ex ## Available Community Extensions +🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** + See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions. For the raw catalog data, see [`catalog.community.json`](catalog.community.json). From 0945df9ec8103b2e61c1ddb114afaa045d261853 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:41:18 -0500 Subject: [PATCH 169/321] Add community content disclaimers (#2058) * Add community content disclaimers Add notes clarifying that community extensions, presets, walkthroughs, and community friends are independently created and maintained by their respective authors and are not reviewed, nor endorsed, nor supported by GitHub. Disclaimers added to: - README.md: Community Extensions, Community Presets, Community Walkthroughs, and Community Friends sections - extensions/README.md: Community Reference Catalog and Available Community Extensions sections - presets/README.md: Catalog Management section * Refine community disclaimers per PR review feedback - Clarify that GitHub/maintainers may review catalog PRs for formatting and policy compliance, but do not review, audit, endorse, or support the extension/preset code itself (avoids contradiction with submission process that mentions PR reviews) - Add missing 'use at your own discretion' guidance to Community Walkthroughs and Community Friends sections for consistency --- README.md | 12 ++++++++++++ extensions/README.md | 6 ++++++ presets/README.md | 3 +++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index ac7d9f4072..129d694130 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,9 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c ## 🧩 Community Extensions +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. + 🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json): @@ -225,6 +228,9 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX ## 🎨 Community Presets +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): | Preset | Purpose | Provides | Requires | URL | @@ -236,6 +242,9 @@ To build and publish your own preset, see the [Presets Publishing Guide](presets ## 🚶 Community Walkthroughs +> [!NOTE] +> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. + See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: - **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. @@ -254,6 +263,9 @@ See Spec-Driven Development in action across different scenarios with these comm ## 🛠️ Community Friends +> [!NOTE] +> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. + Community projects that extend, visualize, or build on Spec Kit: - **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. diff --git a/extensions/README.md b/extensions/README.md index 379e2d92f7..f535ba539a 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -24,6 +24,9 @@ specify extension search # Now uses your organization's catalog instead of the ### Community Reference Catalog (`catalog.community.json`) +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion. + - **Purpose**: Browse available community-contributed extensions - **Status**: Active - contains extensions submitted by the community - **Location**: `extensions/catalog.community.json` @@ -68,6 +71,9 @@ specify extension add --from https://github.com/org/spec-kit-ex ## Available Community Extensions +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. + 🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions. diff --git a/presets/README.md b/presets/README.md index f039b83d43..dd3997b239 100644 --- a/presets/README.md +++ b/presets/README.md @@ -67,6 +67,9 @@ Presets **override**, they don't merge. If two presets both provide `spec-templa Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + ```bash # List active catalogs specify preset catalog list From 8e14ab19354c60572244acd9afc5365998596077 Mon Sep 17 00:00:00 2001 From: "Sakoda, Taro (cub)" <35255268+t-sakoda@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:44:26 +0900 Subject: [PATCH 170/321] fix: support feature branch numbers with 4+ digits (#2040) * fix: support feature branch numbers with 4+ digits in common.sh and common.ps1 The sequential feature number pattern was hardcoded to exactly 3 digits (`{3}`), causing branches like `1234-feature-name` to be rejected. Changed to `{3,}` (3 or more digits) to support growing projects. Also added a guard to exclude malformed timestamp patterns from being accepted as sequential prefixes. Closes #344 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: narrow timestamp guard and use [long] to prevent overflow - Change [int] to [long] in PowerShell Get-CurrentBranch to avoid overflow for large feature numbers (>2,147,483,647) - Narrow malformed-timestamp exclusion from ^[0-9]+-[0-9]{6}- to ^[0-9]{7}-[0-9]{6}- so valid sequential branches like 004-123456-fix-bug are not rejected Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression tests for 4+ digit feature branch support Cover check_feature_branch and find_feature_dir_by_prefix with 4-digit sequential prefixes, as requested in PR review #2040. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reject timestamp-like branches without trailing slug Branches like "20260319-143022" (no "-" suffix) were incorrectly accepted as sequential prefixes. Add explicit rejection for 7-or-8 digit date + 6-digit time patterns with no trailing slug, in both common.sh and common.ps1. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/bash/common.sh | 14 ++++++++++---- scripts/powershell/common.ps1 | 12 ++++++++---- tests/test_timestamp_branches.py | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 416fcadfc2..5e45e8708c 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -78,7 +78,7 @@ get_current_branch() { latest_timestamp="$ts" latest_feature=$dirname fi - elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then + elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then @@ -124,9 +124,15 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi @@ -146,7 +152,7 @@ find_feature_dir_by_prefix() { local prefix="" if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then prefix="${BASH_REMATCH[1]}" - elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then + elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then prefix="${BASH_REMATCH[1]}" else # If branch doesn't have a recognized prefix, fall back to exact match diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 7a96d3fac8..8c8c801ee3 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -83,8 +83,8 @@ function Get-CurrentBranch { $latestTimestamp = $ts $latestFeature = $_.Name } - } elseif ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] + } elseif ($_.Name -match '^(\d{3,})-') { + $num = [long]$matches[1] if ($num -gt $highest) { $highest = $num # Only update if no timestamp branch found yet @@ -139,9 +139,13 @@ function Test-FeatureBranch { return $true } - if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') { + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" return $false } return $true diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 0c9eb07b46..9e36f17567 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -186,11 +186,26 @@ def test_rejects_main(self): result = source_and_call('check_feature_branch "main" "true"') assert result.returncode != 0 + def test_accepts_four_digit_sequential_branch(self): + """check_feature_branch accepts 4+ digit sequential branch.""" + result = source_and_call('check_feature_branch "1234-feat" "true"') + assert result.returncode == 0 + def test_rejects_partial_timestamp(self): """Test 9: check_feature_branch rejects 7-digit date.""" result = source_and_call('check_feature_branch "2026031-143022-feat" "true"') assert result.returncode != 0 + def test_rejects_timestamp_without_slug(self): + """check_feature_branch rejects timestamp-like branch missing trailing slug.""" + result = source_and_call('check_feature_branch "20260319-143022" "true"') + assert result.returncode != 0 + + def test_rejects_7digit_timestamp_without_slug(self): + """check_feature_branch rejects 7-digit date + 6-digit time without slug.""" + result = source_and_call('check_feature_branch "2026031-143022" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── @@ -214,6 +229,15 @@ def test_cross_branch_prefix(self, tmp_path: Path): assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat" + def test_four_digit_sequential_prefix(self, tmp_path: Path): + """find_feature_dir_by_prefix resolves 4+ digit sequential prefix.""" + (tmp_path / "specs" / "1000-original-feat").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + # ── get_current_branch Tests ───────────────────────────────────────────────── From b44ffc010183bf77895343bf19bce999fbd88d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 2 Apr 2026 14:52:21 +0200 Subject: [PATCH 171/321] feat(scripts): add --dry-run flag to create-new-feature (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scripts): add --dry-run flag to create-new-feature scripts Add a --dry-run / -DryRun flag to both bash and PowerShell create-new-feature scripts that computes the next branch name, spec file path, and feature number without creating any branches, directories, or files. This enables external tools to query the next available name before running the full specify workflow. When combined with --json, the output includes a DRY_RUN field. Without --dry-run, behavior is completely unchanged. Closes #1931 Assisted-By: 🤖 Claude Code * fix(scripts): gate specs/ dir creation behind dry-run check Dry-run was unconditionally creating the root specs/ directory via mkdir -p / New-Item before the dry-run guard. This violated the documented contract of zero side effects. Also adds returncode assertion on git branch --list in tests and adds PowerShell dry-run test coverage (skipped when pwsh unavailable). Addresses review comments on #1998. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Gate `mkdir -p $SPECS_DIR` behind DRY_RUN check (bash + PowerShell) so dry-run creates zero directories - Add returncode assertion on `git branch --list` in test - Strengthen spec dir test to verify root `specs/` is not created - Add PowerShell dry-run test class (5 tests, skipped without pwsh) - Fix run_ps_script to use temp repo copy instead of project root Assisted-By: 🤖 Claude Code * fix: use git ls-remote for remote-aware dry-run numbering Dry-run now queries remote branches via `git ls-remote --heads` (read-only, no fetch) to account for remote-only branches when computing the next sequential number. This prevents dry-run from returning a number that already exists on a remote. Added test verifying dry-run sees remote-only higher-numbered branches and adjusts numbering accordingly. Assisted-By: 🤖 Claude Code * fix(scripts): deduplicate number extraction and branch scanning logic Extract shared _extract_highest_number helper (bash) and Get-HighestNumberFromNames (PowerShell) to eliminate duplicated number extraction patterns between local branch and remote ref scanning. Add SkipFetch/skip_fetch parameter to check_existing_branches / Get-NextBranchNumber so dry-run reuses the same function instead of inlining duplicate max-of-branches-and-specs logic. Assisted-By: 🤖 Claude Code * fix(tests): use isolated paths for remote branch test Move remote.git and second_clone directories under git_repo instead of git_repo.parent to prevent path collisions with parallel test workers. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Set GIT_TERMINAL_PROMPT=0 for git ls-remote calls to prevent credential prompts from blocking dry-run in automation scenarios - Add returncode assertion to test_dry_run_with_timestamp git branch --list check Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 185 +++++++----- scripts/powershell/create-new-feature.ps1 | 217 +++++++++----- tests/test_timestamp_branches.py | 338 ++++++++++++++++++++++ 3 files changed, 593 insertions(+), 147 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54ba1dbf58..36ea537991 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,6 +3,7 @@ set -e JSON_MODE=false +DRY_RUN=false ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" @@ -15,6 +16,9 @@ while [ $i -le $# ]; do --json) JSON_MODE=true ;; + --dry-run) + DRY_RUN=true + ;; --allow-existing-branch) ALLOW_EXISTING=true ;; @@ -49,10 +53,11 @@ while [ $i -le $# ]; do USE_TIMESTAMP=true ;; --help|-h) - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" @@ -74,7 +79,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi @@ -110,39 +115,59 @@ get_highest_from_specs() { # Function to get highest number from git branches get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { local highest=0 - - # Get all branches (local and remote) - branches=$(git branch -a 2>/dev/null || echo "") - - if [ -n "$branches" ]; then - while IFS= read -r branch; do - # Clean branch name: remove leading markers and remote prefixes - clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') - - # Extract sequential feature number (>=3 digits), skip timestamp branches. - if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number - fi + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number fi - done <<< "$branches" - fi - + fi + done echo "$highest" } -# Function to check existing branches (local and remote) and return next available number +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. check_existing_branches() { local specs_dir="$1" + local skip_fetch="${2:-false}" - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - git fetch --all --prune >/dev/null 2>&1 || true - - # Get highest number from ALL branches (not just matching short name) - local highest_branch=$(get_highest_from_branches) + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi # Get highest number from ALL specs (not just matching short name) local highest_spec=$(get_highest_from_specs "$specs_dir") @@ -179,7 +204,9 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -mkdir -p "$SPECS_DIR" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi # Function to generate branch name with stop word filtering and length filtering generate_branch_name() { @@ -251,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then # Check existing branches on remotes BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") else @@ -288,62 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -if [ "$HAS_GIT" = true ]; then - if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then - # Check if branch already exists - if git branch --list "$BRANCH_NAME" | grep -q .; then - if [ "$ALLOW_EXISTING" = true ]; then - # Switch to the existing branch instead of failing - if ! git checkout "$BRANCH_NAME" 2>/dev/null; then - >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + # Switch to the existing branch instead of failing + if ! git checkout "$BRANCH_NAME" 2>/dev/null; then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." exit 1 fi - elif [ "$USE_TIMESTAMP" = true ]; then - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." - exit 1 else - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." exit 1 fi - else - >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." - exit 1 fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi -else - >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" -fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" + mkdir -p "$FEATURE_DIR" -SPEC_FILE="$FEATURE_DIR/spec.md" -if [ ! -f "$SPEC_FILE" ]; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi fi -fi -# Inform the user how to persist the feature variable in their own shell -printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + # Inform the user how to persist the feature variable in their own shell + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi if $JSON_MODE; then if command -v jq >/dev/null 2>&1; then - jq -cn \ - --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ - --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" - printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 3708ea2db1..2cfa351399 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,6 +4,7 @@ param( [switch]$Json, [switch]$AllowExistingBranch, + [switch]$DryRun, [string]$ShortName, [Parameter()] [long]$Number = 0, @@ -16,10 +17,11 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" @@ -35,7 +37,7 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } @@ -49,7 +51,7 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) { function Get-HighestNumberFromSpecs { param([string]$SpecsDir) - + [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { @@ -65,48 +67,87 @@ function Get-HighestNumberFromSpecs { return $highest } +# Extract the highest sequential feature number from a list of branch/ref names. +# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs. +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + function Get-HighestNumberFromBranches { param() - - [long]$highest = 0 + try { $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract sequential feature number (>=3 digits), skip timestamp branches. - if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') { - [long]$num = 0 - if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { - $highest = $num - } - } + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' } + return Get-HighestNumberFromNames -Names $cleanNames } } catch { - # If git command fails, return 0 Write-Verbose "Could not check Git branches: $_" } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } return $highest } +# Return next available branch number. When SkipFetch is true, queries remotes +# via ls-remote (read-only) instead of fetching. function Get-NextBranchNumber { param( - [string]$SpecsDir + [string]$SpecsDir, + [switch]$SkipFetch ) - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors + if ($SkipFetch) { + # Side-effect-free: query remotes via ls-remote + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + $highestBranch = Get-HighestNumberFromBranches } - # Get highest number from ALL branches (not just matching short name) - $highestBranch = Get-HighestNumberFromBranches - # Get highest number from ALL specs (not just matching short name) $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -119,7 +160,7 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) @@ -134,12 +175,14 @@ $hasGit = Test-HasGit Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +if (-not $DryRun) { + New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +} # Function to generate branch name with stop word filtering and length filtering function Get-BranchName { param([string]$Description) - + # Common stop words to filter out $stopWords = @( 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', @@ -148,17 +191,17 @@ function Get-BranchName { 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', 'want', 'need', 'add', 'get', 'set' ) - + # Convert to lowercase and extract words (alphanumeric only) $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' $words = $cleanName -split '\s+' | Where-Object { $_ } - + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) $meaningfulWords = @() foreach ($word in $words) { # Skip stop words if ($stopWords -contains $word) { continue } - + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word @@ -167,7 +210,7 @@ function Get-BranchName { $meaningfulWords += $word } } - + # If we have meaningful words, use first 3-4 of them if ($meaningfulWords.Count -gt 0) { $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } @@ -203,7 +246,13 @@ if ($Timestamp) { } else { # Determine branch number if ($Number -eq 0) { - if ($hasGit) { + if ($DryRun -and $hasGit) { + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + # Dry-run without git: local spec dirs only + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { # Check existing branches on remotes $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { @@ -224,86 +273,94 @@ if ($branchName.Length -gt $maxBranchLength) { # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 $prefixLength = $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength - + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName $branchName = "$featureNum-$truncatedSuffix" - + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -if ($hasGit) { - $branchCreated = $false - try { - git checkout -q -b $branchName 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - $branchCreated = $true +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + try { + git checkout -q -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + # Exception during git command } - } catch { - # Exception during git command - } - if (-not $branchCreated) { - # Check if branch already exists - $existingBranch = git branch --list $branchName 2>$null - if ($existingBranch) { - if ($AllowExistingBranch) { - # Switch to the existing branch instead of failing - git checkout -q $branchName 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if (-not $branchCreated) { + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + # Switch to the existing branch instead of failing + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." exit 1 } - } elseif ($Timestamp) { - Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." - exit 1 } else { - Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." exit 1 } - } else { - Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." - exit 1 } + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" -} -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$specFile = Join-Path $featureDir 'spec.md' -if (-not (Test-Path -PathType Leaf $specFile)) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force - } else { - New-Item -ItemType File -Path $specFile | Out-Null + if (-not (Test-Path -PathType Leaf $specFile)) { + $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot + if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force + } else { + New-Item -ItemType File -Path $specFile -Force | Out-Null + } } -} -# Set the SPECIFY_FEATURE environment variable for the current session -$env:SPECIFY_FEATURE = $branchName + # Set the SPECIFY_FEATURE environment variable for the current session + $env:SPECIFY_FEATURE = $branchName +} if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 9e36f17567..edc93fb39e 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -436,3 +436,341 @@ def test_powershell_supports_allow_existing_branch_flag(self): assert "-AllowExistingBranch" in contents # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + + +# ── Dry-Run Tests ──────────────────────────────────────────────────────────── + + +class TestDryRun: + def test_dry_run_sequential_outputs_name(self, git_repo: Path): + """T009: Dry-run computes correct branch name with existing specs.""" + (git_repo / "specs" / "001-first-feat").mkdir(parents=True) + (git_repo / "specs" / "002-second-feat").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "new-feat", "New feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}" + + def test_dry_run_no_branch_created(self, git_repo: Path): + """T010: Dry-run does not create a git branch.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature" + ) + assert result.returncode == 0, result.stderr + branches = subprocess.run( + ["git", "branch", "--list", "*no-branch*"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "", "branch should not exist after dry-run" + + def test_dry_run_no_spec_dir_created(self, git_repo: Path): + """T011: Dry-run does not create any directories (including root specs/).""" + specs_root = git_repo / "specs" + if specs_root.exists(): + shutil.rmtree(specs_root) + assert not specs_root.exists(), "specs/ should not exist before dry-run" + + result = run_script( + git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature" + ) + assert result.returncode == 0, result.stderr + assert not specs_root.exists(), "specs/ should not be created during dry-run" + + def test_dry_run_empty_repo(self, git_repo: Path): + """T012: Dry-run returns 001 prefix when no existing specs or branches.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "first", "First feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "001-first", f"expected 001-first, got: {branch}" + + def test_dry_run_with_short_name(self, git_repo: Path): + """T013: Dry-run with --short-name produces expected name.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + (git_repo / "specs" / "002-existing").mkdir(parents=True) + (git_repo / "specs" / "003-existing").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "004-user-auth", f"expected 004-user-auth, got: {branch}" + + def test_dry_run_then_real_run_match(self, git_repo: Path): + """T014: Dry-run name matches subsequent real creation.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + # Dry-run first + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "match-test", "Match test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + # Real run + real_result = run_script( + git_repo, "--short-name", "match-test", "Match test" + ) + assert real_result.returncode == 0, real_result.stderr + real_branch = None + for line in real_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + real_branch = line.split(":", 1)[1].strip() + assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}" + + def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): + """Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + + # Set up a bare remote and push (use subdirs of git_repo for isolation) + remote_dir = git_repo / "test-remote.git" + subprocess.run( + ["git", "init", "--bare", str(remote_dir)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", str(remote_dir)], + check=True, cwd=git_repo, capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "HEAD"], + check=True, cwd=git_repo, capture_output=True, + ) + + # Clone into a second copy, create a higher-numbered branch, push it + second_clone = git_repo / "test-second-clone" + subprocess.run( + ["git", "clone", str(remote_dir), str(second_clone)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=second_clone, check=True, capture_output=True, + ) + # Create branch 005 on the remote (higher than local 001) + subprocess.run( + ["git", "checkout", "-b", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "origin", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + + # Primary repo: dry-run should see 005 via ls-remote and return 006 + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "remote-test", "Remote test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}" + + def test_dry_run_json_includes_field(self, git_repo: Path): + """T015: JSON output includes DRY_RUN field when --dry-run is active.""" + import json + + result = run_script( + git_repo, "--dry-run", "--json", "--short-name", "json-test", "JSON test" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}" + assert data["DRY_RUN"] is True + + def test_dry_run_json_absent_without_flag(self, git_repo: Path): + """T016: Normal JSON output does NOT include DRY_RUN field.""" + import json + + result = run_script( + git_repo, "--json", "--short-name", "no-dry", "No dry run" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + def test_dry_run_with_timestamp(self, git_repo: Path): + """T017: Dry-run works with --timestamp flag.""" + result = run_script( + git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch is not None, "no BRANCH_NAME in output" + assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" + # Verify no side effects + branches = subprocess.run( + ["git", "branch", "--list", f"*ts-feat*"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "" + + def test_dry_run_with_number(self, git_repo: Path): + """T018: Dry-run works with --number flag.""" + result = run_script( + git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "042-num-feat", f"expected 042-num-feat, got: {branch}" + + def test_dry_run_no_git(self, no_git_dir: Path): + """T019: Dry-run works in non-git directory.""" + (no_git_dir / "specs" / "001-existing").mkdir(parents=True) + result = run_script( + no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "002-no-git-dry", f"expected 002-no-git-dry, got: {branch}" + # Verify no spec dir created + spec_dirs = [ + d.name + for d in (no_git_dir / "specs").iterdir() + if d.is_dir() and "no-git-dry" in d.name + ] + assert len(spec_dirs) == 0 + + +# ── PowerShell Dry-Run Tests ───────────────────────────────────────────────── + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + try: + subprocess.run(["pwsh", "--version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run create-new-feature.ps1 from the temp repo's scripts directory.""" + script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" + cmd = ["pwsh", "-NoProfile", "-File", str(script), *args] + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +@pytest.fixture +def ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell scripts and .specify dir.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], + cwd=tmp_path, + check=True, + ) + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1") + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + return tmp_path + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available") +class TestPowerShellDryRun: + def test_ps_dry_run_outputs_name(self, ps_git_repo: Path): + """PowerShell -DryRun computes correct branch name.""" + (ps_git_repo / "specs" / "001-first").mkdir(parents=True) + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}" + + def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path): + """PowerShell -DryRun does not create a git branch.""" + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch" + ) + assert result.returncode == 0, result.stderr + branches = subprocess.run( + ["git", "branch", "--list", "*no-ps-branch*"], + cwd=ps_git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "", "branch should not exist after dry-run" + + def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path): + """PowerShell -DryRun does not create specs/ directory.""" + specs_root = ps_git_repo / "specs" + if specs_root.exists(): + shutil.rmtree(specs_root) + assert not specs_root.exists() + + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir" + ) + assert result.returncode == 0, result.stderr + assert not specs_root.exists(), "specs/ should not be created during dry-run" + + def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path): + """PowerShell -DryRun JSON output includes DRY_RUN field.""" + import json + + result = run_ps_script( + ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}" + assert data["DRY_RUN"] is True + + def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): + """PowerShell normal JSON output does NOT include DRY_RUN field.""" + import json + + result = run_ps_script( + ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" From 4f9d966bebc77fde9b5f2ae06eda5eddf3d54272 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:00:12 -0500 Subject: [PATCH 172/321] Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage 5: Skills, Generic & Option-Driven Integrations (#1924) Add SkillsIntegration base class and migrate codex, kimi, agy, and generic to the integration system. Integrations: - SkillsIntegration(IntegrationBase) in base.py — creates speckit-/SKILL.md layout matching release ZIP output byte-for-byte - CodexIntegration — .agents/skills/, --skills default=True - KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options, dotted→hyphenated skill directory migration - AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5) - GenericIntegration — user-specified --commands-dir, MarkdownIntegration - All four have update-context.sh/.ps1 scripts - All four registered in INTEGRATION_REGISTRY CLI changes: - --ai auto-promotes to integration path for all registered agents - Interactive agent selection also auto-promotes (bug fix) - --ai-skills and --ai-commands-dir show deprecation notices on integration path - Next-steps display shows correct skill invocation syntax for skills integrations - agy added to CommandRegistrar.AGENT_CONFIGS Tests: - test_integration_base_skills.py — reusable mixin with setup, frontmatter, directory structure, scripts, CLI auto-promote, and complete file inventory (sh+ps) tests - Per-agent test files: test_integration_{codex,kimi,agy,generic}.py - Kimi legacy migration tests, generic --commands-dir validation - Registry updated with Stage 5 keys - Removed 9 dead-mock tests, moved 4 integration tests to proper locations - Fixed all bare project-name tests to use tmp_path - Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py and test_presets.py 1524 tests pass, 0 failures. * fix: remove unused variable flagged by ruff (F841) * fix: address PR review — integration-type-aware deprecation messages and early generic validation - --ai-skills deprecation message now distinguishes SkillsIntegration ("skills are the default") from command-based integrations ("has no effect") - --ai-commands-dir validation for generic runs even when auto-promoted, giving clear CLI error instead of late ValueError from setup() - Resolves review comments from #2052 * fix: address PR review round 2 - Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after switching to template descriptions for ZIP parity) - Narrow YAML parse catch from Exception to yaml.YAMLError - Remove unused shutil import from test_integration_kimi.py - Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py - Reword --ai-commands-dir deprecation to be actionable - Update generic validation error to mention both --ai and --integration * fix: address PR review round 3 - Clarify parsed_options forwarding is intentional (all options passed, integrations decide what to use) - Extract _strip_ansi() helper in test_extensions.py and test_presets.py - Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py) - Reword --ai-commands-dir deprecation to be actionable without referencing the not-yet-implemented --integration-options * fix: address PR review round 4 - Reorder kimi migration: run super().setup() first so hyphenated targets exist, then migrate dotted dirs (prevents user content loss) - Move _strip_ansi() to shared tests/conftest.py, import from there in test_extensions.py, test_presets.py, test_ai_skills.py - Remove now-unused re imports from all three test files * fix: address PR review round 5 - Use write_bytes() for LF-only newlines (no CRLF on Windows) - Add --integration-options CLI parameter — raw string passed through to the integration via opts['raw_options']; the integration owns parsing of its own options - GenericIntegration.setup() reads --commands-dir from raw_options when not in parsed_options (supports --integration-options="...") - Skip early --ai-commands-dir validation when --integration-options is provided (integration validates in its own setup()) - Remove parse_integration_options from core — integrations parse their own options * fix: address PR review round 6 - GenericIntegration is now stateless: removed self._commands_dir instance state, overrides setup() directly to compute destination from parsed_options/raw_options on the stack - commands_dest() raises by design (stateless singleton) - _quote() in SkillsIntegration now escapes backslashes and double quotes to produce valid YAML even with special characters * fix: address PR review round 7 - Support --commands-dir=value form in raw_options parsing (not just --commands-dir value with space separator) - Normalize CRLF to LF in write_file_and_record() before encoding - Persist ai_skills=True in init-options.json when using a SkillsIntegration, so extensions/presets emit SKILL.md overrides correctly even without explicit --ai-skills flag --- src/specify_cli/__init__.py | 97 +++- src/specify_cli/agents.py | 6 + src/specify_cli/integrations/__init__.py | 8 + src/specify_cli/integrations/agy/__init__.py | 41 ++ .../agy/scripts/update-context.ps1 | 17 + .../agy/scripts/update-context.sh | 24 + src/specify_cli/integrations/base.py | 162 ++++++- .../integrations/codex/__init__.py | 40 ++ .../codex/scripts/update-context.ps1 | 17 + .../codex/scripts/update-context.sh | 24 + .../integrations/generic/__init__.py | 133 ++++++ .../generic/scripts/update-context.ps1 | 17 + .../generic/scripts/update-context.sh | 24 + src/specify_cli/integrations/kimi/__init__.py | 124 ++++++ .../kimi/scripts/update-context.ps1 | 17 + .../kimi/scripts/update-context.sh | 24 + tests/conftest.py | 10 + tests/integrations/test_cli.py | 10 +- tests/integrations/test_integration_agy.py | 25 ++ .../test_integration_base_skills.py | 402 +++++++++++++++++ tests/integrations/test_integration_codex.py | 25 ++ .../integrations/test_integration_generic.py | 311 +++++++++++++ tests/integrations/test_integration_kimi.py | 149 +++++++ .../integrations/test_integration_kiro_cli.py | 29 ++ tests/integrations/test_registry.py | 17 +- tests/test_ai_skills.py | 414 ++---------------- tests/test_extensions.py | 17 +- tests/test_presets.py | 7 +- 28 files changed, 1777 insertions(+), 414 deletions(-) create mode 100644 src/specify_cli/integrations/agy/__init__.py create mode 100644 src/specify_cli/integrations/agy/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/agy/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/codex/__init__.py create mode 100644 src/specify_cli/integrations/codex/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/codex/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/generic/__init__.py create mode 100644 src/specify_cli/integrations/generic/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/generic/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/kimi/__init__.py create mode 100644 src/specify_cli/integrations/kimi/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/kimi/scripts/update-context.sh create mode 100644 tests/conftest.py create mode 100644 tests/integrations/test_integration_agy.py create mode 100644 tests/integrations/test_integration_base_skills.py create mode 100644 tests/integrations/test_integration_codex.py create mode 100644 tests/integrations/test_integration_generic.py create mode 100644 tests/integrations/test_integration_kimi.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 698b672dab..7d1ecbc007 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1907,6 +1907,7 @@ def init( preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), + integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): """ Initialize a new Specify project. @@ -1997,6 +1998,26 @@ def init( f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]" ) + # Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path + if use_integration: + if ai_skills: + from .integrations.base import SkillsIntegration as _SkillsCheck + if isinstance(resolved_integration, _SkillsCheck): + console.print( + "[dim]Note: --ai-skills is not needed with --integration; " + "skills are the default for this integration.[/dim]" + ) + else: + console.print( + "[dim]Note: --ai-skills has no effect with --integration " + f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" + ) + if ai_commands_dir and resolved_integration.key != "generic": + console.print( + "[dim]Note: --ai-commands-dir is deprecated; " + 'use [bold]--integration generic --integration-options="--commands-dir

"[/bold] instead.[/dim]' + ) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -2062,8 +2083,18 @@ def init( "copilot" ) + # Auto-promote interactively selected agents to the integration path + # when a matching integration is registered (same behavior as --ai). + if not use_integration: + from .integrations import get_integration as _get_int + _resolved = _get_int(selected_ai) + if _resolved: + use_integration = True + resolved_integration = _resolved + # Agents that have moved from explicit commands/prompts to agent skills. - if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: + # Skip this check when using the integration path — skills are the default. + if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: # If selected interactively (no --ai provided), automatically enable # ai_skills so the agent remains usable without requiring an extra flag. # Preserve fail-fast behavior only for explicit '--ai ' without skills. @@ -2073,14 +2104,20 @@ def init( ai_skills = True console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}") - # Validate --ai-commands-dir usage - if selected_ai == "generic": + # Validate --ai-commands-dir usage. + # Skip validation when --integration-options is provided — the integration + # will validate its own options in setup(). + if selected_ai == "generic" and not integration_options: if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") - console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") + console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]") raise typer.Exit(1) - elif ai_commands_dir: - console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") + elif ai_commands_dir and not use_integration: + console.print( + f"[red]Error:[/red] --ai-commands-dir can only be used with the " + f"'generic' integration via --ai generic or --integration generic " + f"(not '{selected_ai}')" + ) raise typer.Exit(1) current_dir = Path.cwd() @@ -2210,9 +2247,21 @@ def init( manifest = IntegrationManifest( resolved_integration.key, project_path, version=get_speckit_version() ) + + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True + resolved_integration.setup( project_path, manifest, + parsed_options=integration_parsed_options or None, script_type=selected_script, + raw_options=integration_options, ) manifest.save() @@ -2268,7 +2317,7 @@ def init( shutil.rmtree(project_path) raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path - if selected_ai == "generic" and ai_commands_dir: + if not use_integration and selected_ai == "generic" and ai_commands_dir: placeholder_dir = project_path / ".speckit" / "commands" target_dir = project_path / ai_commands_dir if placeholder_dir.is_dir(): @@ -2284,10 +2333,11 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) # Determine skills directory and migrate any legacy Kimi dotted skills. + # (Legacy path only — integration path handles skills in setup().) migrated_legacy_kimi_skills = 0 removed_legacy_kimi_skills = 0 skills_dir: Optional[Path] = None - if selected_ai in NATIVE_SKILLS_AGENTS: + if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS: skills_dir = _get_skills_dir(project_path, selected_ai) if selected_ai == "kimi" and skills_dir.is_dir(): ( @@ -2295,7 +2345,7 @@ def init( removed_legacy_kimi_skills, ) = _migrate_legacy_kimi_dotted_skills(skills_dir) - if ai_skills: + if not use_integration and ai_skills: if selected_ai in NATIVE_SKILLS_AGENTS: bundled_found = _has_bundled_skills(project_path, selected_ai) if bundled_found: @@ -2383,6 +2433,11 @@ def init( } if use_integration: init_opts["integration"] = resolved_integration.key + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist): + init_opts["ai_skills"] = True save_init_options(project_path, init_opts) # Install preset if specified @@ -2484,17 +2539,27 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 - if selected_ai == "codex" and ai_skills: - steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") - step_num += 1 + # Determine skill display mode for the next-steps panel. + # Skills integrations (codex, kimi, agy) should show skill invocation syntax + # regardless of whether --ai-skills was explicitly passed. + _is_skills_integration = False + if use_integration: + from .integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) - codex_skill_mode = selected_ai == "codex" and ai_skills + codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) kimi_skill_mode = selected_ai == "kimi" - native_skill_mode = codex_skill_mode or kimi_skill_mode + agy_skill_mode = selected_ai == "agy" and _is_skills_integration + native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode + + if codex_skill_mode and not ai_skills: + # Integration path installed skills; show the helpful notice + steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: - if codex_skill_mode: + if codex_skill_mode or agy_skill_mode: return f"$speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4a8c2d1b29..8107ae7017 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -168,6 +168,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" + }, + "agy": { + "dir": ".agent/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", } } diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ed131103c1..bb87cec996 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -46,17 +46,21 @@ def _register_builtins() -> None: users install and invoke. """ # -- Imports (alphabetical) ------------------------------------------- + from .agy import AgyIntegration from .amp import AmpIntegration from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration + from .codex import CodexIntegration from .codebuddy import CodebuddyIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .gemini import GeminiIntegration + from .generic import GenericIntegration from .iflow import IflowIntegration from .junie import JunieIntegration from .kilocode import KilocodeIntegration + from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration @@ -70,17 +74,21 @@ def _register_builtins() -> None: from .windsurf import WindsurfIntegration # -- Registration (alphabetical) -------------------------------------- + _register(AgyIntegration()) _register(AmpIntegration()) _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) + _register(CodexIntegration()) _register(CodebuddyIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(GeminiIntegration()) + _register(GenericIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) + _register(KimiIntegration()) _register(KiroCliIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py new file mode 100644 index 0000000000..9cd522745e --- /dev/null +++ b/src/specify_cli/integrations/agy/__init__.py @@ -0,0 +1,41 @@ +"""Antigravity (agy) integration — skills-based agent. + +Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout. +Explicit command support was deprecated in version 1.20.5; +``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class AgyIntegration(SkillsIntegration): + """Integration for Antigravity IDE.""" + + key = "agy" + config = { + "name": "Antigravity", + "folder": ".agent/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".agent/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Antigravity since v1.20.5)", + ), + ] diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 new file mode 100644 index 0000000000..9eeb461657 --- /dev/null +++ b/src/specify_cli/integrations/agy/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh new file mode 100755 index 0000000000..d7303f6197 --- /dev/null +++ b/src/specify_cli/integrations/agy/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a88039b9a0..dac5063f5c 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -7,6 +7,8 @@ integrations (the common case — subclass, set three class attrs, done). - ``TomlIntegration`` — concrete base for TOML-format integrations (Gemini, Tabnine — subclass, set three class attrs, done). +- ``SkillsIntegration`` — concrete base for integrations that install + commands as agent skills (``speckit-/SKILL.md`` layout). """ from __future__ import annotations @@ -200,10 +202,14 @@ def write_file_and_record( ) -> Path: """Write *content* to *dest*, hash it, and record in *manifest*. - Creates parent directories as needed. Returns *dest*. + Creates parent directories as needed. Writes bytes directly to + avoid platform newline translation (CRLF on Windows). Any + ``\r\n`` sequences in *content* are normalised to ``\n`` before + writing. Returns *dest*. """ dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(content, encoding="utf-8") + normalized = content.replace("\r\n", "\n") + dest.write_bytes(normalized.encode("utf-8")) rel = dest.resolve().relative_to(project_root.resolve()) manifest.record_existing(rel) return dest @@ -633,3 +639,155 @@ def setup( created.extend(self.install_scripts(project_root, manifest)) return created + + +# --------------------------------------------------------------------------- +# SkillsIntegration — skills-format agents (Codex, Kimi, Agy) +# --------------------------------------------------------------------------- + + +class SkillsIntegration(IntegrationBase): + """Concrete base for integrations that install commands as agent skills. + + Skills use the ``speckit-/SKILL.md`` directory layout following + the `agentskills.io `_ spec. + + Subclasses set ``key``, ``config``, ``registrar_config`` (and + optionally ``context_file``) like any integration. They may also + override ``options()`` to declare additional CLI flags (e.g. + ``--skills``, ``--migrate-legacy``). + + ``setup()`` processes each shared command template into a + ``speckit-/SKILL.md`` file with skills-oriented frontmatter. + """ + + def skills_dest(self, project_root: Path) -> Path: + """Return the absolute path to the skills output directory. + + Derived from ``config["folder"]`` and the configured + ``commands_subdir`` (defaults to ``"skills"``). + + Raises ``ValueError`` when ``config`` or ``folder`` is missing. + """ + if not self.config: + raise ValueError( + f"{type(self).__name__}.config is not set." + ) + folder = self.config.get("folder") + if not folder: + raise ValueError( + f"{type(self).__name__}.config is missing required 'folder' entry." + ) + subdir = self.config.get("commands_subdir", "skills") + return project_root / folder / subdir + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install command templates as agent skills. + + Creates ``speckit-/SKILL.md`` for each shared command + template. Each SKILL.md has normalised frontmatter containing + ``name``, ``description``, ``compatibility``, and ``metadata``. + """ + import yaml + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + skills_dir = self.skills_dest(project_root).resolve() + try: + skills_dir.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Skills destination {skills_dir} escapes " + f"project root {project_root_resolved}" + ) from exc + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + + # Derive the skill name from the template stem + command_name = src_file.stem # e.g. "plan" + skill_name = f"speckit-{command_name.replace('.', '-')}" + + # Parse frontmatter for description + frontmatter: dict[str, Any] = {} + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) + if isinstance(fm, dict): + frontmatter = fm + except yaml.YAMLError: + pass + + # Process body through the standard template pipeline + processed_body = self.process_template( + raw, self.key, script_type, arg_placeholder + ) + # Strip the processed frontmatter — we rebuild it for skills. + # Preserve leading whitespace in the body to match release ZIP + # output byte-for-byte (the template body starts with \n after + # the closing ---). + if processed_body.startswith("---"): + parts = processed_body.split("---", 2) + if len(parts) >= 3: + processed_body = parts[2] + + # Select description — use the original template description + # to stay byte-for-byte identical with release ZIP output. + description = frontmatter.get("description", "") + if not description: + description = f"Spec Kit: {command_name} workflow" + + # Build SKILL.md with manually formatted frontmatter to match + # the release packaging script output exactly (double-quoted + # values, no yaml.safe_dump quoting differences). + def _quote(v: str) -> str: + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + skill_content = ( + f"---\n" + f"name: {_quote(skill_name)}\n" + f"description: {_quote(description)}\n" + f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"metadata:\n" + f" author: {_quote('github-spec-kit')}\n" + f" source: {_quote('templates/commands/' + src_file.name)}\n" + f"---\n" + f"{processed_body}" + ) + + # Write speckit-/SKILL.md + skill_dir = skills_dir / skill_name + skill_file = skill_dir / "SKILL.md" + dst = self.write_file_and_record( + skill_content, skill_file, project_root, manifest + ) + created.append(dst) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py new file mode 100644 index 0000000000..f6415f9bb2 --- /dev/null +++ b/src/specify_cli/integrations/codex/__init__.py @@ -0,0 +1,40 @@ +"""Codex CLI integration — skills-based agent. + +Codex uses the ``.agents/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class CodexIntegration(SkillsIntegration): + """Integration for OpenAI Codex CLI.""" + + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 new file mode 100644 index 0000000000..d73a5a4d34 --- /dev/null +++ b/src/specify_cli/integrations/codex/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Codex CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh new file mode 100755 index 0000000000..512d6e91d3 --- /dev/null +++ b/src/specify_cli/integrations/codex/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Codex CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py new file mode 100644 index 0000000000..4107c48690 --- /dev/null +++ b/src/specify_cli/integrations/generic/__init__.py @@ -0,0 +1,133 @@ +"""Generic integration — bring your own agent. + +Requires ``--commands-dir`` to specify the output directory for command +files. No longer special-cased in the core CLI — just another +integration with its own required option. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration +from ..manifest import IntegrationManifest + + +class GenericIntegration(MarkdownIntegration): + """Integration for user-specified (generic) agents.""" + + key = "generic" + config = { + "name": "Generic (bring your own agent)", + "folder": None, # Set dynamically from --commands-dir + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": "", # Set dynamically from --commands-dir + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = None + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--commands-dir", + required=True, + help="Directory for command files (e.g. .myagent/commands/)", + ), + ] + + @staticmethod + def _resolve_commands_dir( + parsed_options: dict[str, Any] | None, + opts: dict[str, Any], + ) -> str: + """Extract ``--commands-dir`` from parsed options or raw_options. + + Returns the directory string or raises ``ValueError``. + """ + parsed_options = parsed_options or {} + + commands_dir = parsed_options.get("commands_dir") + if commands_dir: + return commands_dir + + # Fall back to raw_options (--integration-options="--commands-dir ...") + raw = opts.get("raw_options") + if raw: + import shlex + tokens = shlex.split(raw) + for i, token in enumerate(tokens): + if token == "--commands-dir" and i + 1 < len(tokens): + return tokens[i + 1] + if token.startswith("--commands-dir="): + return token.split("=", 1)[1] + + raise ValueError( + "--commands-dir is required for the generic integration" + ) + + def commands_dest(self, project_root: Path) -> Path: + """Not supported for GenericIntegration — use setup() directly. + + GenericIntegration is stateless; the output directory comes from + ``parsed_options`` or ``raw_options`` at call time, not from + instance state. + """ + raise ValueError( + "GenericIntegration.commands_dest() cannot be called directly; " + "the output directory is resolved from parsed_options in setup()" + ) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install commands to the user-provided commands directory.""" + commands_dir = self._resolve_commands_dir(parsed_options, opts) + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = (project_root / commands_dir).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = "$ARGUMENTS" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 new file mode 100644 index 0000000000..2e9467f801 --- /dev/null +++ b/src/specify_cli/integrations/generic/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Generic integration: create/update context file +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh new file mode 100755 index 0000000000..d8ad30a7b8 --- /dev/null +++ b/src/specify_cli/integrations/generic/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Generic integration: create/update context file +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py new file mode 100644 index 0000000000..5421d48012 --- /dev/null +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -0,0 +1,124 @@ +"""Kimi Code integration — skills-based agent (Moonshot AI). + +Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +``/skill:speckit-`` invocation syntax. + +Includes legacy migration logic for projects initialised before Kimi +moved from dotted skill directories (``speckit.xxx``) to hyphenated +(``speckit-xxx``). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +class KimiIntegration(SkillsIntegration): + """Integration for Kimi Code CLI (Moonshot AI).""" + + key = "kimi" + config = { + "name": "Kimi Code", + "folder": ".kimi/", + "commands_subdir": "skills", + "install_url": "https://code.kimi.com/", + "requires_cli": True, + } + registrar_config = { + "dir": ".kimi/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "KIMI.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Kimi)", + ), + IntegrationOption( + "--migrate-legacy", + is_flag=True, + default=False, + help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + ), + ] + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install skills with optional legacy dotted-name migration.""" + parsed_options = parsed_options or {} + + # Run base setup first so hyphenated targets (speckit-*) exist, + # then migrate/clean legacy dotted dirs without risking user content loss. + created = super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + + if parsed_options.get("migrate_legacy", False): + skills_dir = self.skills_dest(project_root) + if skills_dir.is_dir(): + _migrate_legacy_kimi_dotted_skills(skills_dir) + + return created + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + Returns ``(migrated_count, removed_count)``. + """ + if not skills_dir.is_dir(): + return (0, 0) + + migrated_count = 0 + removed_count = 0 + + for legacy_dir in sorted(skills_dir.glob("speckit.*")): + if not legacy_dir.is_dir(): + continue + if not (legacy_dir / "SKILL.md").exists(): + continue + + suffix = legacy_dir.name[len("speckit."):] + if not suffix: + continue + + target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + + if not target_dir.exists(): + shutil.move(str(legacy_dir), str(target_dir)) + migrated_count += 1 + continue + + # Target exists — only remove legacy if SKILL.md is identical + target_skill = target_dir / "SKILL.md" + legacy_skill = legacy_dir / "SKILL.md" + if target_skill.is_file(): + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + has_extra = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + pass + + return (migrated_count, removed_count) diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 new file mode 100644 index 0000000000..aa6678d052 --- /dev/null +++ b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Kimi Code integration: create/update KIMI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh new file mode 100755 index 0000000000..2f81bc2a48 --- /dev/null +++ b/src/specify_cli/integrations/kimi/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Kimi Code integration: create/update KIMI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..4387c9ac8f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Shared test helpers for the Spec Kit test suite.""" + +import re + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from Rich-formatted CLI output.""" + return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 03b0e11866..cd0071783f 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,26 +3,24 @@ import json import os -import pytest - class TestInitIntegrationFlag: - def test_integration_and_ai_mutually_exclusive(self): + def test_integration_and_ai_mutually_exclusive(self, tmp_path): from typer.testing import CliRunner from specify_cli import app runner = CliRunner() result = runner.invoke(app, [ - "init", "test-project", "--ai", "claude", "--integration", "copilot", + "init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot", ]) assert result.exit_code != 0 assert "mutually exclusive" in result.output - def test_unknown_integration_rejected(self): + def test_unknown_integration_rejected(self, tmp_path): from typer.testing import CliRunner from specify_cli import app runner = CliRunner() result = runner.invoke(app, [ - "init", "test-project", "--integration", "nonexistent", + "init", str(tmp_path / "test-project"), "--integration", "nonexistent", ]) assert result.exit_code != 0 assert "Unknown integration" in result.output diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py new file mode 100644 index 0000000000..3efaa99362 --- /dev/null +++ b/tests/integrations/test_integration_agy.py @@ -0,0 +1,25 @@ +"""Tests for AgyIntegration (Antigravity).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestAgyIntegration(SkillsIntegrationTests): + KEY = "agy" + FOLDER = ".agent/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agent/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestAgyAutoPromote: + """--ai agy auto-promotes to integration path.""" + + def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): + """--ai agy (without --ai-skills) should auto-promote to integration.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"]) + + assert "--integration agy" in result.output diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py new file mode 100644 index 0000000000..23505c3062 --- /dev/null +++ b/tests/integrations/test_integration_base_skills.py @@ -0,0 +1,402 @@ +"""Reusable test mixin for standard SkillsIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``SkillsIntegrationTests``. + +Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, +adapted for the ``speckit-/SKILL.md`` skills layout. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import SkillsIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class SkillsIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".agents/" + COMMANDS_SUBDIR: str — e.g. "skills" + REGISTRAR_DIR: str — e.g. ".agents/skills" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_skills_integration(self): + assert isinstance(get_integration(self.KEY), SkillsIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "markdown" + assert i.registrar_config["args"] == "$ARGUMENTS" + assert i.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + assert f.exists() + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.skills_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0, "No skill files were created" + for f in skill_files: + # Each SKILL.md is in speckit-/ under the skills directory + assert f.resolve().parent.parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_skill_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + expected_commands = { + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + } + + # Derive command names from the skill directory names + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name # e.g. "speckit-plan" + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + + assert actual_commands == expected_commands + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + assert "source" in fm["metadata"] + + def test_skill_uses_template_descriptions(self, tmp_path): + """SKILL.md should use the original template description for ZIP parity.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + # Description must be a non-empty string (from the template) + assert isinstance(fm["description"], str) + assert len(fm["description"]) > 0, f"{f} has empty description" + + def test_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced, not raw templates.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content after the frontmatter.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + content = f.read_text(encoding="utf-8") + # Body is everything after the second --- + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_pre_existing_skills_not_removed(self, tmp_path): + """Pre-existing non-speckit skills should be left untouched.""" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(tmp_path) + foreign_dir = skills_dir / "other-tool" + foreign_dir.mkdir(parents=True) + (foreign_dir / "SKILL.md").write_text("# Foreign skill\n") + + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + + assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + assert f"--integration {self.KEY}" in result.output + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + + # -- IntegrationOption ------------------------------------------------ + + def test_options_include_skills_flag(self): + i = get_integration(self.KEY) + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + + # -- Complete file inventory ------------------------------------------ + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the full expected file list for a given script variant.""" + i = get_integration(self.KEY) + skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills") + + files = [] + # Skill files + for cmd in self._SKILL_COMMANDS: + files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") + # Integration metadata + files += [ + ".specify/init-options.json", + ".specify/integration.json", + f".specify/integrations/{self.KEY}.manifest.json", + f".specify/integrations/{self.KEY}/scripts/update-context.ps1", + f".specify/integrations/{self.KEY}/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ] + # Script variant + if script_variant == "sh": + files += [ + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/update-agent-context.sh", + ] + else: + files += [ + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/update-agent-context.ps1", + ] + # Templates + files += [ + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ] + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "ps", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py new file mode 100644 index 0000000000..eb633f02ba --- /dev/null +++ b/tests/integrations/test_integration_codex.py @@ -0,0 +1,25 @@ +"""Tests for CodexIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestCodexIntegration(SkillsIntegrationTests): + KEY = "codex" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestCodexAutoPromote: + """--ai codex auto-promotes to integration path.""" + + def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path): + """--ai codex (without --ai-skills) should auto-promote to integration.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"]) + + assert "--integration codex" in result.output diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py new file mode 100644 index 0000000000..2815456f21 --- /dev/null +++ b/tests/integrations/test_integration_generic.py @@ -0,0 +1,311 @@ +"""Tests for GenericIntegration.""" + +import os + +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import MarkdownIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestGenericIntegration: + """Tests for GenericIntegration — requires --commands-dir option.""" + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + from specify_cli.integrations import INTEGRATION_REGISTRY + assert "generic" in INTEGRATION_REGISTRY + + def test_is_markdown_integration(self): + assert isinstance(get_integration("generic"), MarkdownIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder_is_none(self): + i = get_integration("generic") + assert i.config["folder"] is None + + def test_config_requires_cli_false(self): + i = get_integration("generic") + assert i.config["requires_cli"] is False + + def test_context_file_is_none(self): + i = get_integration("generic") + assert i.context_file is None + + # -- Options ---------------------------------------------------------- + + def test_options_include_commands_dir(self): + i = get_integration("generic") + opts = i.options() + assert len(opts) == 1 + assert opts[0].name == "--commands-dir" + assert opts[0].required is True + assert opts[0].is_flag is False + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_requires_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={}) + + def test_setup_requires_nonempty_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={"commands_dir": ""}) + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/commands"}, + ) + expected_dir = tmp_path / ".myagent" / "commands" + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_setup_creates_md_files(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_templates_are_processed(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + m.save() + modified = created[0] + modified.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified.exists() + assert modified in skipped + + def test_different_commands_dirs(self, tmp_path): + """Generic should work with various user-specified paths.""" + for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]: + project = tmp_path / path.replace("/", "-") + project.mkdir() + i = get_integration("generic") + m = IntegrationManifest("generic", project) + created = i.setup( + project, m, + parsed_options={"commands_dir": path}, + ) + expected = project / path + assert expected.is_dir(), f"Dir {expected} not created for {path}" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" + assert scripts_dir.is_dir(), "Scripts directory not created for generic" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI -------------------------------------------------------------- + + def test_cli_generic_without_commands_dir_fails(self, tmp_path): + """--integration generic without --ai-commands-dir should fail.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(tmp_path / "test-generic"), "--integration", "generic", + "--script", "sh", "--no-git", + ]) + # Generic requires --commands-dir / --ai-commands-dir + # The integration path validates via setup() + assert result.exit_code != 0 + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/generic/scripts/update-context.ps1", + ".specify/integrations/generic/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/update-agent-context.sh", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-ps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "ps", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/generic/scripts/update-context.ps1", + ".specify/integrations/generic/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/update-agent-context.ps1", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py new file mode 100644 index 0000000000..25787e612e --- /dev/null +++ b/tests/integrations/test_integration_kimi.py @@ -0,0 +1,149 @@ +"""Tests for KimiIntegration — skills integration with legacy migration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestKimiIntegration(SkillsIntegrationTests): + KEY = "kimi" + FOLDER = ".kimi/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".kimi/skills" + CONTEXT_FILE = "KIMI.md" + + +class TestKimiOptions: + """Kimi declares --skills and --migrate-legacy options.""" + + def test_migrate_legacy_option(self): + i = get_integration("kimi") + opts = i.options() + migrate_opts = [o for o in opts if o.name == "--migrate-legacy"] + assert len(migrate_opts) == 1 + assert migrate_opts[0].is_flag is True + assert migrate_opts[0].default is False + + +class TestKimiLegacyMigration: + """Test Kimi dotted → hyphenated skill directory migration.""" + + def test_migrate_dotted_to_hyphenated(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Plan Skill\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 1 + assert removed == 0 + assert not legacy.exists() + assert (skills_dir / "speckit-plan" / "SKILL.md").exists() + + def test_skip_when_target_exists_different_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Old\n") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text("# New (different)\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + assert target.exists() + + def test_remove_when_target_exists_same_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Identical\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 1 + assert not legacy.exists() + assert target.exists() + + def test_preserve_legacy_with_extra_files(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Same\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + (legacy / "extra.md").write_text("user file") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + + def test_nonexistent_dir_returns_zeros(self, tmp_path): + migrated, removed = _migrate_legacy_kimi_dotted_skills( + tmp_path / ".kimi" / "skills" + ) + assert migrated == 0 + assert removed == 0 + + def test_setup_with_migrate_legacy_option(self, tmp_path): + """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" + i = get_integration("kimi") + + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiNextSteps: + """CLI output tests for kimi next-steps display.""" + + def test_next_steps_show_skill_invocation(self, tmp_path): + """Kimi next-steps guidance should display /skill:speckit-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "kimi-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kimi", "--no-git", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "/skill:speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index d6ae7afce1..6b2b27b777 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -1,5 +1,7 @@ """Tests for KiroCliIntegration.""" +import os + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +11,30 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" CONTEXT_FILE = "AGENTS.md" + + +class TestKiroAlias: + """--ai kiro alias normalizes to kiro-cli and auto-promotes.""" + + def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): + """--ai kiro should normalize to canonical kiro-cli and auto-promote.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "kiro-alias-proj" + target.mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(target) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kiro", + "--ignore-agent-tools", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "--integration kiro-cli" in result.output + assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index e70f3006ac..8ab1425148 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -11,13 +11,17 @@ from .conftest import StubIntegration -# Every integration key that must be registered (Stage 2 + Stage 3). +# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5). ALL_INTEGRATION_KEYS = [ "copilot", # Stage 3 — standard markdown integrations "claude", "qwen", "opencode", "junie", "kilocode", "auggie", "roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae", "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", + # Stage 4 — TOML integrations + "gemini", "tabnine", + # Stage 5 — skills, generic & option-driven integrations + "codex", "kimi", "agy", "generic", ] @@ -61,9 +65,16 @@ def test_key_registered(self, key): class TestRegistrarKeyAlignment: - """Every integration key must have a matching AGENT_CONFIGS entry.""" + """Every integration key must have a matching AGENT_CONFIGS entry. - @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS) + ``generic`` is excluded because it has no fixed directory — its + output path comes from ``--commands-dir`` at runtime. + """ + + @pytest.mark.parametrize( + "key", + [k for k in ALL_INTEGRATION_KEYS if k != "generic"], + ) def test_integration_key_in_registrar(self, key): from specify_cli.agents import CommandRegistrar assert key in CommandRegistrar.AGENT_CONFIGS, ( diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 7f9ecf66ab..e4ee41828c 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -10,7 +10,6 @@ - CLI validation: --ai-skills requires --ai """ -import re import zipfile import pytest import tempfile @@ -21,6 +20,7 @@ from unittest.mock import patch import specify_cli +from tests.conftest import strip_ansi from specify_cli import ( _get_skills_dir, @@ -684,207 +684,15 @@ def test_no_commands_dir_no_error(self, project_dir, templates_dir): assert result is True -# ===== New-Project Command Skip Tests ===== +# ===== Legacy Download Path Tests ===== -class TestNewProjectCommandSkip: - """Test that init() removes extracted commands for new projects only. +class TestLegacyDownloadPath: + """Tests for download_and_extract_template() called directly. - These tests run init() end-to-end via CliRunner with - download_and_extract_template patched to create local fixtures. + These test the legacy download/extract code that still exists in + __init__.py. They do NOT go through CLI auto-promote. """ - def _fake_extract(self, agent, project_path, **_kwargs): - """Simulate template extraction: create agent commands dir.""" - agent_cfg = AGENT_CONFIG.get(agent, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True, exist_ok=True) - (cmds_dir / "speckit.specify.md").write_text("# spec") - - def test_new_project_commands_removed_after_skills_succeed(self, tmp_path): - """For new projects, commands should be removed when skills succeed.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("claude", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - # Skills should have been called - mock_skills.assert_called_once() - - # Commands dir should have been removed after skills succeeded - cmds_dir = target / ".claude" / "commands" - assert not cmds_dir.exists() - - def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path): - """For non-standard agents, configured commands_subdir should be removed on success.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-kiro-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("kiro-cli", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - mock_skills.assert_called_once() - - prompts_dir = target / ".kiro" / "prompts" - assert not prompts_dir.exists() - - def test_codex_native_skills_preserved_without_conversion(self, tmp_path): - """Codex should keep bundled .agents/skills and skip install_ai_skills conversion.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-codex-proj" - - def fake_download(project_path, *args, **kwargs): - skill_dir = project_path / ".agents" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills") as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - mock_skills.assert_not_called() - assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists() - - def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path): - """Codex should attempt fallback conversion when bundled skills are missing.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "missing-codex-skills" - - with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 1 - mock_skills.assert_called_once() - assert mock_skills.call_args.kwargs.get("overwrite_existing") is True - assert "Expected bundled agent skills" in result.output - assert "fallback conversion failed" in result.output - - def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path): - """Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "foreign-codex-skills" - - def fake_download(project_path, *args, **kwargs): - skill_dir = project_path / ".agents" / "skills" / "other-tool" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - mock_skills.assert_called_once() - assert mock_skills.call_args.kwargs.get("overwrite_existing") is True - - def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path): - """Kimi init should migrate dotted legacy skills even when --ai-skills is not set.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "kimi-legacy-no-ai-skills" - - def fake_download(project_path, *args, **kwargs): - legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan" - legacy_dir.mkdir(parents=True, exist_ok=True) - (legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - assert not (target / ".kimi" / "skills" / "speckit.plan").exists() - assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists() - - def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch): - """Codex --here skills init should not delete a pre-existing .codex directory.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "codex-preserve-here" - target.mkdir() - existing_prompts = target / ".codex" / "prompts" - existing_prompts.mkdir(parents=True) - (existing_prompts / "custom.md").write_text("custom") - monkeypatch.chdir(target) - - with patch("specify_cli.download_and_extract_template", return_value=target), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True), \ - patch("specify_cli.is_git_repo", return_value=True), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - input="y\n", - ) - - assert result.exit_code == 0 - assert (target / ".codex").exists() - assert (existing_prompts / "custom.md").exists() - def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path): """Fresh-directory Codex skills init should not leave legacy .codex from archive.""" target = tmp_path / "fresh-codex-proj" @@ -948,62 +756,6 @@ def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, assert not (tmp_path / "evil.txt").exists() - def test_commands_preserved_when_skills_fail(self, tmp_path): - """If skills fail, commands should NOT be removed (safety net).""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "fail-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("claude", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - # Commands should still exist since skills failed - cmds_dir = target / ".claude" / "commands" - assert cmds_dir.exists() - assert (cmds_dir / "speckit.specify.md").exists() - - def test_here_mode_commands_preserved(self, tmp_path, monkeypatch): - """For --here on existing repos, commands must NOT be removed.""" - from typer.testing import CliRunner - - runner = CliRunner() - # Create a mock existing project with commands already present - target = tmp_path / "existing" - target.mkdir() - agent_folder = AGENT_CONFIG["claude"]["folder"] - cmds_dir = target / agent_folder.rstrip("/") / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text("# spec") - - # --here uses CWD, so chdir into the target - monkeypatch.chdir(target) - - def fake_download(project_path, *args, **kwargs): - pass # commands already exist, no need to re-create - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True), \ - patch("specify_cli.is_git_repo", return_value=True), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], input="y\n") - - assert result.exit_code == 0 - # Commands must remain for --here - assert cmds_dir.exists() - assert (cmds_dir / "speckit.specify.md").exists() - # ===== Skip-If-Exists Tests ===== @@ -1075,92 +827,61 @@ def test_all_known_commands_have_descriptions(self): class TestCliValidation: """Test --ai-skills CLI flag validation.""" - def test_ai_skills_without_ai_fails(self): + def test_ai_skills_without_ai_fails(self, tmp_path): """--ai-skills without --ai should fail with exit code 1.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) assert result.exit_code == 1 assert "--ai-skills requires --ai" in result.output - def test_ai_skills_without_ai_shows_usage(self): + def test_ai_skills_without_ai_shows_usage(self, tmp_path): """Error message should include usage hint.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) assert "Usage:" in result.output assert "--ai" in result.output - def test_agy_without_ai_skills_fails(self): - """--ai agy without --ai-skills should fail with exit code 1.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"]) - - assert result.exit_code == 1 - assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output - assert "--ai-skills" in result.output - - def test_codex_without_ai_skills_fails(self): - """--ai codex without --ai-skills should fail with exit code 1.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"]) - - assert result.exit_code == 1 - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output - assert "--ai-skills" in result.output - - def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): - """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" + def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): + """Interactive selector returning agy should auto-promote to integration path.""" from typer.testing import CliRunner - # Mock select_with_arrows to simulate the user picking 'agy' for AI, - # and return a deterministic default for any other prompts to avoid - # calling the real interactive implementation. def _fake_select_with_arrows(*args, **kwargs): options = kwargs.get("options") if options is None and len(args) >= 1: options = args[0] - # If the options include 'agy', simulate selecting it. if isinstance(options, dict) and "agy" in options: return "agy" if isinstance(options, (list, tuple)) and "agy" in options: return "agy" - # For any other prompt, return a deterministic, non-interactive default: - # pick the first option if available. if isinstance(options, dict) and options: return next(iter(options.keys())) if isinstance(options, (list, tuple)) and options: return options[0] - # If no options are provided, fall back to None (should not occur in normal use). return None monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - # Mock download_and_extract_template to prevent real HTTP downloads during testing - monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) - # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe? + runner = CliRunner() - # Create temp dir to avoid directory already exists errors or whatever - with runner.isolated_filesystem(): - result = runner.invoke(app, ["init", "test-proj", "--no-git"]) + target = tmp_path / "test-agy-interactive" + result = runner.invoke(app, ["init", str(target), "--no-git"]) - # Interactive selection should NOT raise the deprecation error! - assert result.exit_code == 0 - assert "Explicit command support was deprecated" not in result.output + assert result.exit_code == 0 + # Should NOT raise the old deprecation error + assert "Explicit command support was deprecated" not in result.output + # Should use integration path (same as --ai agy) + assert "agy" in result.output - def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch): - """Interactive selector returning codex without --ai-skills should automatically enable --ai-skills.""" + def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): + """Interactive selector returning codex should auto-promote to integration path.""" from typer.testing import CliRunner def _fake_select_with_arrows(*args, **kwargs): @@ -1182,48 +903,18 @@ def _fake_select_with_arrows(*args, **kwargs): monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - def _fake_download(*args, **kwargs): - project_path = Path(args[0]) - skill_dir = project_path / ".agents" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - - runner = CliRunner() - with runner.isolated_filesystem(): - result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"]) - - assert result.exit_code == 0 - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output - assert ".agents/skills" in result.output - assert "$speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output - - def test_kimi_next_steps_show_skill_invocation(self, monkeypatch): - """Kimi next-steps guidance should display /skill:speckit-* usage.""" - from typer.testing import CliRunner - - def _fake_download(*args, **kwargs): - project_path = Path(args[0]) - skill_dir = project_path / ".kimi" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - with runner.isolated_filesystem(): - result = runner.invoke( - app, - ["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"], - ) + target = tmp_path / "test-codex-interactive" + result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"]) - assert result.exit_code == 0 - assert "/skill:speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output + assert result.exit_code == 0 + # Should NOT raise the old deprecation error + assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output + # Skills should be installed via integration path + assert ".agents/skills" in result.output + assert "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output def test_ai_skills_flag_appears_in_help(self): """--ai-skills should appear in init --help output.""" @@ -1232,45 +923,10 @@ def test_ai_skills_flag_appears_in_help(self): runner = CliRunner() result = runner.invoke(app, ["init", "--help"]) - plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + plain = strip_ansi(result.output) assert "--ai-skills" in plain assert "agent skills" in plain.lower() - def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): - """--ai kiro should normalize to canonical kiro-cli and auto-promote to integration path.""" - import os - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "kiro-alias-proj" - target.mkdir() - - old_cwd = os.getcwd() - try: - os.chdir(target) - result = runner.invoke( - app, - [ - "init", - "--here", - "--ai", - "kiro", - "--ignore-agent-tools", - "--script", - "sh", - "--no-git", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0 - # kiro alias should auto-promote to integration path with nudge - assert "--integration kiro-cli" in result.output - # Command files should be created via integration path - assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() - def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" assert "q" not in AGENT_CONFIG @@ -1327,12 +983,12 @@ def test_error_message_lists_available_agents(self): output_lower = result.output.lower() assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"]) - def test_ai_commands_dir_consuming_flag(self): + def test_ai_commands_dir_consuming_flag(self, tmp_path): """--ai-commands-dir without value should not consume next flag.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"]) + result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"]) assert result.exit_code == 1 assert "Invalid value for --ai-commands-dir" in result.output diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a5ee4e03a6..df269d86c4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -16,6 +16,7 @@ from pathlib import Path from datetime import datetime, timezone +from tests.conftest import strip_ansi from specify_cli.extensions import ( CatalogEntry, CORE_COMMAND_NAMES, @@ -3126,11 +3127,12 @@ def test_list_shows_extension_id(self, extension_dir, project_dir): result = runner.invoke(app, ["extension", "list"]) assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) # Verify the extension ID is shown in the output - assert "test-ext" in result.output + assert "test-ext" in plain # Verify name and version are also shown - assert "Test Extension" in result.output - assert "1.0.0" in result.output + assert "Test Extension" in plain + assert "1.0.0" in plain class TestExtensionPriority: @@ -3360,7 +3362,8 @@ def test_list_shows_priority(self, extension_dir, project_dir): result = runner.invoke(app, ["extension", "list"]) assert result.exit_code == 0, result.output - assert "Priority: 7" in result.output + plain = strip_ansi(result.output) + assert "Priority: 7" in plain def test_set_priority_changes_priority(self, extension_dir, project_dir): """Test set-priority command changes extension priority.""" @@ -3381,7 +3384,8 @@ def test_set_priority_changes_priority(self, extension_dir, project_dir): result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) assert result.exit_code == 0, result.output - assert "priority changed: 10 → 5" in result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain # Reload registry to see updated value manager2 = ExtensionManager(project_dir) @@ -3403,7 +3407,8 @@ def test_set_priority_same_value_no_change(self, extension_dir, project_dir): result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) assert result.exit_code == 0, result.output - assert "already has priority 5" in result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain def test_set_priority_invalid_value(self, extension_dir, project_dir): """Test set-priority rejects invalid priority values.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index 1b2704c57f..cf02709b27 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -20,6 +20,7 @@ import yaml +from tests.conftest import strip_ansi from specify_cli.presets import ( PresetManifest, PresetRegistry, @@ -2441,7 +2442,8 @@ def test_set_priority_changes_priority(self, project_dir, pack_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output - assert "priority changed: 10 → 5" in result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain # Reload registry to see updated value manager2 = PresetManager(project_dir) @@ -2463,7 +2465,8 @@ def test_set_priority_same_value_no_change(self, project_dir, pack_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output - assert "already has priority 5" in result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain def test_set_priority_invalid_value(self, project_dir, pack_dir): """Test set-priority rejects invalid priority values.""" From d9ce7c1fc02745d2f914d6b0c708a228c74feb1f Mon Sep 17 00:00:00 2001 From: liuyiyu Date: Thu, 2 Apr 2026 21:47:39 +0800 Subject: [PATCH 173/321] Add repoindex 0402 (#2062) * Add repoindex to community catalog, -Extension ID: repoindex -Version 1.0.0 - Author: Yiyu Liu - Description: Generate repo index * udpate sort order for repoindex * Add repoindex to community catalog, -Extension ID: repoindex -Version 1.0.0 - Author: Yiyu Liu - Description: Generate repo index * udpate sort order for repoindex * Update main README adding repoindex intro * fix display issue * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/README.md b/README.md index 129d694130..782bd938b0 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ The following community-contributed extensions are available in [`catalog.commun | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f04b370678..dce3c06e2a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -941,6 +941,43 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "repoindex":{ + "name": "Repository Index", + "id": "repoindex", + "description": "Generate index of your repo for overview, architecuture and module", + "author": "Yiyu Liu", + "version": "1.0.0", + "download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/liuyiyu/spec-kit-repoindex", + "homepage": "https://github.com/liuyiyu/spec-kit-repoindex", + "documentation": "https://github.com/liuyiyu/spec-kit-repoindex/tree/main/docs", + "changelog": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "no need", + "version": ">=1.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "utility", + "brownfield", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-23T13:30:00Z", + "updated_at": "2026-03-23T13:30:00Z" + }, "retro": { "name": "Retro Extension", "id": "retro", @@ -1331,5 +1368,6 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" } + } } From a858c1d6da861e27fe92a1f8ab2f5e72699fcc77 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Thu, 2 Apr 2026 17:44:48 +0300 Subject: [PATCH 174/321] Install Claude Code as native skills and align preset/integration flows (#2051) * Use Claude skills for generated commands * Fix Claude integration and preset skill flows * Group Claude tests in integration suite * Align Claude skill frontmatter across generators * Fix native skill preset cleanup * Keep legacy AI skills test on legacy path * Move Claude here-mode test to CLI suite --- README.md | 12 +- src/specify_cli/__init__.py | 55 +++- src/specify_cli/agents.py | 23 +- src/specify_cli/extensions.py | 18 +- .../integrations/claude/__init__.py | 95 +++++- src/specify_cli/presets.py | 77 ++--- tests/integrations/test_cli.py | 27 ++ tests/integrations/test_integration_claude.py | 293 +++++++++++++++++- tests/test_ai_skills.py | 3 +- tests/test_extension_skills.py | 1 + tests/test_presets.py | 108 +++++++ 11 files changed, 633 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 782bd938b0..c581ce627d 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ Community projects that extend, visualize, or build on Spec Kit: | [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | | [Amp](https://ampcode.com/) | ✅ | | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | -| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | +| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | | [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | | [Cursor](https://cursor.sh/) | ✅ | | @@ -401,8 +401,8 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here -# Install agent skills with the project -specify init my-project --ai claude --ai-skills +# Claude Code installs skills with the project by default +specify init my-project --ai claude # Initialize in current directory with agent skills specify init --here --ai gemini --ai-skills @@ -416,7 +416,11 @@ specify check ### Available Slash Commands -After running `specify init`, your AI coding agent will have access to these slash commands for structured development. +After running `specify init`, your AI coding agent will have access to these structured development commands. + +Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`. + +Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`. For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7d1ecbc007..26116430c9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1640,6 +1640,8 @@ def install_ai_skills( ``True`` if at least one skill was installed or all skills were already present (idempotent re-run), ``False`` otherwise. """ + from .agents import CommandRegistrar + # Locate command templates in the agent's extracted commands directory. # download_and_extract_template() already placed the .md files here. agent_config = AGENT_CONFIG.get(selected_ai, {}) @@ -1741,15 +1743,12 @@ def install_ai_skills( if source_name.endswith(".agent.md"): source_name = source_name[:-len(".agent.md")] + ".md" - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"templates/commands/{source_name}", - }, - } + frontmatter_data = CommandRegistrar.build_skill_frontmatter( + selected_ai, + skill_name, + enhanced_desc, + f"templates/commands/{source_name}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -1859,6 +1858,23 @@ def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: AGENT_SKILLS_MIGRATIONS = { + "claude": { + "error": ( + "Claude Code now installs spec-kit as agent skills; " + "legacy .claude/commands projects are kept for backwards compatibility." + ), + "usage": "specify init --ai claude", + "interactive_note": ( + "'claude' was selected interactively; enabling [cyan]--ai-skills[/cyan] " + "automatically so spec-kit is installed to [cyan].claude/skills[/cyan]." + ), + "explicit_note": ( + "'claude' now installs spec-kit as agent skills; enabling " + "[cyan]--ai-skills[/cyan] automatically so commands are written to " + "[cyan].claude/skills[/cyan]." + ), + "auto_enable_explicit": True, + }, "agy": { "error": "Explicit command support was deprecated in Antigravity version 1.20.5.", "usage": "specify init --ai agy --ai-skills", @@ -1943,7 +1959,7 @@ def init( specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --ai claude --ai-skills # Install agent skills + specify init my-project --ai claude # Claude installs skills by default specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent specify init my-project --offline # Use bundled assets (no network access) @@ -1977,6 +1993,7 @@ def init( # Auto-promote: --ai → integration path with a nudge (if registered) use_integration = False + resolved_integration = None if integration: from .integrations import INTEGRATION_REGISTRY, get_integration resolved_integration = get_integration(integration) @@ -2098,11 +2115,13 @@ def init( # If selected interactively (no --ai provided), automatically enable # ai_skills so the agent remains usable without requiring an extra flag. # Preserve fail-fast behavior only for explicit '--ai ' without skills. - if ai_assistant: + migration = AGENT_SKILLS_MIGRATIONS[selected_ai] + if ai_assistant and not migration.get("auto_enable_explicit", False): _handle_agent_skills_migration(console, selected_ai) else: ai_skills = True - console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}") + note_key = "explicit_note" if ai_assistant else "interactive_note" + console.print(f"\n[yellow]Note:[/yellow] {migration[note_key]}") # Validate --ai-commands-dir usage. # Skip validation when --integration-options is provided — the integration @@ -2540,27 +2559,33 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy) should show skill invocation syntax - # regardless of whether --ai-skills was explicitly passed. + # Skills integrations (codex, claude, kimi, agy) should show skill + # invocation syntax regardless of whether --ai-skills was explicitly passed. _is_skills_integration = False if use_integration: from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) + claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration - native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") step_num += 1 + if claude_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: if codex_skill_mode or agy_skill_mode: return f"$speckit-{name}" + if claude_skill_mode: + return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 8107ae7017..50c01a22dc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -370,16 +370,35 @@ def render_skill_command( 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": f"{source_id}:{source_file}", + "source": source, }, } - return self.render_frontmatter(skill_frontmatter) + "\n" + body + if agent_name == "claude": + # Claude skills should only run when explicitly invoked. + skill_frontmatter["disable-model-invocation"] = True + return skill_frontmatter @staticmethod def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b898c65f2a..3420a7651b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -801,15 +801,12 @@ def _register_extension_skills( original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" - frontmatter_data = { - "name": skill_name, - "description": description, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"extension:{manifest.id}", - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + skill_name, + description, + f"extension:{manifest.id}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() # Derive a human-friendly title from the command name @@ -2138,11 +2135,14 @@ def _render_hook_invocation(self, command: Any) -> str: init_options = self._load_init_options() selected_ai = init_options.get("ai") codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) + claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: return f"${skill_name}" + if claude_skill_mode and skill_name: + return f"/{skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 00375ead51..f192c876cd 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -1,21 +1,106 @@ """Claude Code integration.""" -from ..base import MarkdownIntegration +from __future__ import annotations +from pathlib import Path +from typing import Any + +import yaml + +from ...agents import CommandRegistrar +from ..base import SkillsIntegration +from ..manifest import IntegrationManifest + + +class ClaudeIntegration(SkillsIntegration): + """Integration for Claude Code skills.""" -class ClaudeIntegration(MarkdownIntegration): key = "claude" config = { "name": "Claude Code", "folder": ".claude/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, } registrar_config = { - "dir": ".claude/commands", + "dir": ".claude/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } context_file = "CLAUDE.md" + + def command_filename(self, template_name: str) -> str: + """Claude skills live at .claude/skills//SKILL.md.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + return f"{skill_name}/SKILL.md" + + def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: + """Render a processed command template as a Claude skill.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + description = frontmatter.get( + "description", + f"Spec-kit workflow command: {template_name}", + ) + skill_frontmatter = CommandRegistrar.build_skill_frontmatter( + self.key, + skill_name, + description, + f"templates/commands/{template_name}.md", + ) + frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() + return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Claude skills into .claude/skills.""" + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.skills_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + registrar = CommandRegistrar() + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + frontmatter, body = registrar.parse_frontmatter(processed) + if not isinstance(frontmatter, dict): + frontmatter = {} + + rendered = self._render_skill(src_file.stem, frontmatter, body) + dst_file = self.write_file_and_record( + rendered, + dest / self.command_filename(src_file.stem), + project_root, + manifest, + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index a3f6406287..0c8bba1757 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -714,7 +714,14 @@ def _register_skills( selected_ai = init_opts.get("ai") if not isinstance(selected_ai, str): return [] + ai_skills_enabled = bool(init_opts.get("ai_skills")) registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + # Native skill agents (e.g. codex/kimi/agy) materialize brand-new + # preset skills in _register_commands() because their detected agent + # directory is already the skills directory. This flag is only for + # command-backed agents that also mirror commands into skills. + create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" written: List[str] = [] @@ -741,6 +748,10 @@ def _register_skills( target_skill_names.append(skill_name) if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) if not target_skill_names: continue @@ -760,15 +771,16 @@ def _register_skills( ) for target_skill_name in target_skill_names: - frontmatter_data = { - "name": target_skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"preset:{manifest.id}", - }, - } + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + skill_subdir.mkdir(parents=True, exist_ok=True) + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{manifest.id}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -778,7 +790,7 @@ def _register_skills( f"{body}\n" ) - skill_file = skills_dir / target_skill_name / "SKILL.md" + skill_file = skill_subdir / "SKILL.md" skill_file.write_text(skill_content, encoding="utf-8") written.append(target_skill_name) @@ -850,15 +862,12 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: original_desc or f"Spec-kit workflow command: {short_name}", ) - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"templates/commands/{short_name}.md", - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + enhanced_desc, + f"templates/commands/{short_name}.md", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_title = self._skill_title_from_command(short_name) skill_content = ( @@ -883,15 +892,12 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: command_name = extension_restore["command_name"] title_name = self._skill_title_from_command(command_name) - frontmatter_data = { - "name": skill_name, - "description": frontmatter.get("description", f"Extension command: {command_name}"), - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": extension_restore["source"], - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + frontmatter.get("description", f"Extension command: {command_name}"), + extension_restore["source"], + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -1040,14 +1046,15 @@ def remove(self, pack_id: str) -> bool: if registered_skills: self._unregister_skills(registered_skills, pack_dir) try: - from . import NATIVE_SKILLS_AGENTS + from .agents import CommandRegistrar except ImportError: - NATIVE_SKILLS_AGENTS = set() - registered_commands = { - agent_name: cmd_names - for agent_name, cmd_names in registered_commands.items() - if agent_name not in NATIVE_SKILLS_AGENTS - } + CommandRegistrar = None + if CommandRegistrar is not None: + registered_commands = { + agent_name: cmd_names + for agent_name, cmd_names in registered_commands.items() + if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" + } # Unregister non-skill command files from AI agents. if registered_commands: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index cd0071783f..2609571928 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -76,6 +76,33 @@ def test_ai_copilot_auto_promotes(self, tmp_path): assert "--integration copilot" in result.output assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-here-existing" + project.mkdir() + commands_dir = project / ".claude" / "commands" + commands_dir.mkdir(parents=True) + command_file = commands_dir / "speckit.specify.md" + command_file.write_text("# preexisting command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert "--integration claude" in result.output + assert command_file.exists() + assert command_file.read_text(encoding="utf-8") == "# preexisting command\n" + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + def test_shared_infra_skips_existing_files(self, tmp_path): """Pre-existing shared files are not overwritten by _install_shared_infra.""" from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 6867a295ea..8f8a6b05d1 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,11 +1,290 @@ """Tests for ClaudeIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import json +import os +from unittest.mock import patch +import yaml -class TestClaudeIntegration(MarkdownIntegrationTests): - KEY = "claude" - FOLDER = ".claude/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".claude/commands" - CONTEXT_FILE = "CLAUDE.md" +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestClaudeIntegration: + def test_registered(self): + assert "claude" in INTEGRATION_REGISTRY + assert get_integration("claude") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("claude"), IntegrationBase) + + def test_config_uses_skills(self): + integration = get_integration("claude") + assert integration.config["folder"] == ".claude/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("claude") + assert integration.registrar_config["dir"] == ".claude/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "$ARGUMENTS" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + integration = get_integration("claude") + assert integration.context_file == "CLAUDE.md" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".claude" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["disable-model-invocation"] is True + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_setup_installs_update_context_scripts(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" + assert scripts_dir.is_dir() + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} + assert ".specify/integrations/claude/scripts/update-context.sh" in tracked + assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + + def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-promote" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + "claude", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert "--integration claude" in result.output + assert ".claude/skills" in result.output + assert "/speckit-plan" in result.output + assert "/speckit.plan" not in result.output + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert not (project / ".claude" / "commands").exists() + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_integration_flag_creates_skill_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-integration" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + def test_interactive_claude_selection_uses_integration_path(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-interactive" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + with patch("specify_cli.select_with_arrows", return_value="claude"): + result = runner.invoke( + app, + [ + "init", + "--here", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "integration.json").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert skill_file.exists() + assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8") + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "fail-proj" + + with patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=False), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 0 + assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert not (target / ".claude" / "commands").exists() + + def test_claude_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "claude-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + + assert "Executing: `/speckit-plan`" in message + assert "EXECUTE_COMMAND: speckit.plan" in message + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): + from specify_cli import save_init_options + from specify_cli.presets import PresetManager + + project = tmp_path / "claude-preset-skill" + project.mkdir() + save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"}) + + skills_dir = project / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = tmp_path / "claude-skill-command" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Research workflow\n" + "---\n\n" + "preset:claude-skill-command\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "claude-skill-command", + "name": "Claude Skill Command", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "preset:claude-skill-command" in content + assert "name: speckit-research" in content + assert "disable-model-invocation: true" in content + + metadata = manager.registry.get("claude-skill-command") + assert "speckit-research" in metadata.get("registered_skills", []) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e4ee41828c..e43129c9cf 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -756,7 +756,6 @@ def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, assert not (tmp_path / "evil.txt").exists() - # ===== Skip-If-Exists Tests ===== class TestSkipIfExists: @@ -925,7 +924,7 @@ def test_ai_skills_flag_appears_in_help(self): plain = strip_ansi(result.output) assert "--ai-skills" in plain - assert "agent skills" in plain.lower() + assert "skills" in plain.lower() def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 47d40a3b93..5d5d84902b 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -269,6 +269,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): assert isinstance(parsed, dict) assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed + assert parsed["disable-model-invocation"] is True def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): """No skills should be created when ai_skills is false.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index cf02709b27..f2a08b91b4 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1975,6 +1975,7 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" + assert "disable-model-invocation: true" in content # Verify it was recorded in registry metadata = manager.registry.get("self-test") @@ -2060,6 +2061,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" + assert "disable-model-invocation: true" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" @@ -2350,6 +2352,55 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp metadata = manager.registry.get("self-test") assert "speckit-specify" in metadata.get("registered_skills", []) + def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi native skills should still receive brand-new preset commands.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = project_dir / ".kimi" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "kimi-new-skill" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Kimi research workflow\n" + "---\n\n" + "preset:kimi-new-skill\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "kimi-new-skill", + "name": "Kimi New Skill", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:kimi-new-skill" in content + assert "name: speckit-research" in content + + metadata = manager.registry.get("kimi-new-skill") + assert "speckit-research" in metadata.get("registered_skills", []) + def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") @@ -2402,6 +2453,63 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d assert ".specify/memory/constitution.md" in content assert "for kimi" in content + def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """Agy preset removal should restore native skills instead of deleting them.""" + self._write_init_options(project_dir, ai="agy", ai_skills=True) + skills_dir = project_dir / ".agent" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="before override") + + core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" + core_command.write_text( + "---\n" + "description: Restored core specify workflow\n" + "---\n\n" + "restored core body\n" + ) + + preset_dir = temp_dir / "agy-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + "description: Agy override\n" + "---\n\n" + "preset agy body\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "agy-override", + "name": "Agy Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset agy body" in skill_file.read_text() + + assert manager.remove("agy-override") is True + assert skill_file.exists() + restored = skill_file.read_text() + assert "restored core body" in restored + assert "name: speckit-specify" in restored + def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): """Non-dict init-options payloads should not crash preset install/remove flows.""" init_options = project_dir / ".specify" / "init-options.json" From b1832c9477c7956cabf810676823356db83ea035 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:34:34 -0500 Subject: [PATCH 175/321] =?UTF-8?q?Stage=206:=20Complete=20migration=20?= =?UTF-8?q?=E2=80=94=20remove=20legacy=20scaffold=20path=20(#1924)=20(#206?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage 6: Complete migration — remove legacy scaffold path (#1924) Remove the legacy GitHub download and offline scaffold code paths. All 26 agents now use the integration system exclusively. Code removal (~1073 lines from __init__.py): - download_template_from_github(), download_and_extract_template() - scaffold_from_core_pack(), _locate_release_script() - install_ai_skills(), _get_skills_dir (restored slim version for presets) - _has_bundled_skills(), _migrate_legacy_kimi_dotted_skills() - AGENT_SKILLS_MIGRATIONS, _handle_agent_skills_migration() - _parse_rate_limit_headers(), _format_rate_limit_error() - Three-way branch in init() collapsed to integration-only Config derivation (single source of truth): - AGENT_CONFIG derived from INTEGRATION_REGISTRY (replaced 180-line dict) - CommandRegistrar.AGENT_CONFIGS derived from INTEGRATION_REGISTRY (replaced 160-line dict) - Backward-compat constants kept for presets/extensions: SKILL_DESCRIPTIONS, NATIVE_SKILLS_AGENTS, DEFAULT_SKILLS_DIR Release pipeline cleanup: - Deleted create-release-packages.sh/.ps1 (948 lines of ZIP packaging) - Deleted create-github-release.sh, generate-release-notes.sh - Deleted simulate-release.sh, get-next-version.sh, update-version.sh - Removed .github/workflows/scripts/ directory entirely - release.yml is now self-contained: check, notes, release all inlined - Install instructions use uv tool install with version tag Test cleanup: - Deleted test_ai_skills.py (tested removed functions) - Deleted test_core_pack_scaffold.py (tested removed scaffold) - Cleaned test_agent_config_consistency.py (removed 19 release-script tests) - Fixed test_branch_numbering.py (removed dead monkeypatches) - Updated auto-promote tests (verify files created, not tip messages) 1089 tests pass, 0 failures, ruff clean. * fix: resolve merge conflicts with #2051 (claude as skills) - Fix circular import: move CommandRegistrar import in claude integration to inside method bodies (was at module level) - Lazy-populate AGENT_CONFIGS via _ensure_configs() to avoid circular import at class definition time - Set claude registrar_config to .claude/commands (extension/preset target) since the integration handles .claude/skills in setup() - Update tests from #2051 to match: registrar_config assertions, remove --integration tip assertions, remove install_ai_skills mocks 1086 tests pass. * fix: properly preserve claude skills migration from #2051 Restore ClaudeIntegration.registrar_config to .claude/skills (not .claude/commands) so extension/preset registrations write to the correct skills directory. Update tests that simulate claude setup to use .claude/skills and check for SKILL.md layout. Some tests still need updating for the full skills path — 10 remaining failures from the #2051 test expectations around the extension/preset skill registration flow. WIP: 1076/1086 pass. * fix: properly handle SKILL.md paths in extension update rollback and tests Fix extension update rollback using _compute_output_name() for SKILL.md agents (converts dots to hyphens in skill directory names). Previously the backup and cleanup code constructed paths with raw command names (e.g. speckit.test-ext.hello/SKILL.md) instead of the correct computed names (speckit-test-ext-hello/SKILL.md). Test fixes for claude skills migration: - Update claude tests to use .claude/skills paths and SKILL.md layout - Use qwen (not claude) for skills-guard tests since claude's agent dir IS the skills dir — creating it triggers command registration - Fix test_extension_command_registered_when_extension_present to check skills path format 1086 tests pass, 0 failures, ruff clean. * fix: address PR review — lazy init, assertions, deprecated flags - _ensure_configs(): catch ImportError (not Exception), don't set _configs_loaded on failure so retries work - Move _ensure_configs() before unregister loop (not inside it) - Module-level try/except catches ImportError specifically - Remove tautology assertion (or True) in test_extensions.py - Strengthen preset provenance assertion to check source: field - Mark --offline, --skip-tls, --debug, --github-token as hidden deprecated no-ops in init() 1086 tests pass. * fix: remove deleted release scripts from pyproject.toml force-include Removes force-include entries for create-release-packages.sh/.ps1 which were deleted but still referenced in [tool.hatch.build]. --- .github/workflows/release.yml | 62 +- .../workflows/scripts/check-release-exists.sh | 21 - .../scripts/create-github-release.sh | 72 - .../scripts/create-release-packages.ps1 | 560 ------- .../scripts/create-release-packages.sh | 388 ----- .../scripts/generate-release-notes.sh | 40 - .github/workflows/scripts/get-next-version.sh | 24 - .github/workflows/scripts/simulate-release.sh | 161 -- .github/workflows/scripts/update-version.sh | 23 - pyproject.toml | 2 - src/specify_cli/__init__.py | 1465 ++--------------- src/specify_cli/agents.py | 198 +-- .../integrations/claude/__init__.py | 15 +- tests/integrations/test_cli.py | 12 +- tests/integrations/test_integration_agy.py | 8 +- .../test_integration_base_markdown.py | 4 +- .../test_integration_base_skills.py | 4 +- .../test_integration_base_toml.py | 4 +- tests/integrations/test_integration_claude.py | 19 +- tests/integrations/test_integration_codex.py | 8 +- .../integrations/test_integration_kiro_cli.py | 1 - tests/test_agent_config_consistency.py | 216 +-- tests/test_ai_skills.py | 994 ----------- tests/test_branch_numbering.py | 29 +- tests/test_core_pack_scaffold.py | 613 ------- tests/test_extension_skills.py | 4 +- tests/test_extensions.py | 35 +- tests/test_presets.py | 62 +- 28 files changed, 290 insertions(+), 4754 deletions(-) delete mode 100644 .github/workflows/scripts/check-release-exists.sh delete mode 100755 .github/workflows/scripts/create-github-release.sh delete mode 100644 .github/workflows/scripts/create-release-packages.ps1 delete mode 100755 .github/workflows/scripts/create-release-packages.sh delete mode 100644 .github/workflows/scripts/generate-release-notes.sh delete mode 100644 .github/workflows/scripts/get-next-version.sh delete mode 100755 .github/workflows/scripts/simulate-release.sh delete mode 100644 .github/workflows/scripts/update-version.sh delete mode 100644 tests/test_ai_skills.py delete mode 100644 tests/test_core_pack_scaffold.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e29592cc0..7b903cf979 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,35 +27,63 @@ jobs: - name: Check if release already exists id: check_release run: | - chmod +x .github/workflows/scripts/check-release-exists.sh - .github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }} + VERSION="${{ steps.version.outputs.tag }}" + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release $VERSION already exists, skipping..." + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Release $VERSION does not exist, proceeding..." + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create release package variants - if: steps.check_release.outputs.exists == 'false' - run: | - chmod +x .github/workflows/scripts/create-release-packages.sh - .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} - - name: Generate release notes if: steps.check_release.outputs.exists == 'false' - id: release_notes run: | - chmod +x .github/workflows/scripts/generate-release-notes.sh - # Get the previous tag for changelog generation - PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "") - # Default to v0.0.0 if no previous tag is found (e.g., first release) + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + + # Find previous tag + PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1) + if [ -z "$PREVIOUS_TAG" ]; then + PREVIOUS_TAG="" + fi + + # Get commits since previous tag if [ -z "$PREVIOUS_TAG" ]; then - PREVIOUS_TAG="v0.0.0" + COMMIT_COUNT=$(git rev-list --count HEAD) + if [ "$COMMIT_COUNT" -gt 20 ]; then + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges HEAD~20..HEAD) + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges) + fi + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "$PREVIOUS_TAG"..HEAD) fi - .github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG" + + cat > release_notes.md << NOTES_EOF + ## Install + + \`\`\`bash + uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION} + specify init my-project + \`\`\` + + NOTES_EOF + + echo "## What's Changed" >> release_notes.md + echo "" >> release_notes.md + echo "$COMMITS" >> release_notes.md - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' run: | - chmod +x .github/workflows/scripts/create-github-release.sh - .github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }} + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + gh release create "$VERSION" \ + --title "Spec Kit - $VERSION_NO_V" \ + --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scripts/check-release-exists.sh b/.github/workflows/scripts/check-release-exists.sh deleted file mode 100644 index 88ef174f51..0000000000 --- a/.github/workflows/scripts/check-release-exists.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# check-release-exists.sh -# Check if a GitHub release already exists for the given version -# Usage: check-release-exists.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -if gh release view "$VERSION" >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Release $VERSION already exists, skipping..." -else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Release $VERSION does not exist, proceeding..." -fi diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh deleted file mode 100755 index 4a67d8dfef..0000000000 --- a/.github/workflows/scripts/create-github-release.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-github-release.sh -# Create a GitHub release with all template zip files -# Usage: create-github-release.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix from version for release title -VERSION_NO_V=${VERSION#v} - -gh release create "$VERSION" \ - .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-junie-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-junie-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-trae-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-trae-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-pi-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-pi-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ - --title "Spec Kit Templates - $VERSION_NO_V" \ - --notes-file release_notes.md diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 deleted file mode 100644 index 912dd00ecb..0000000000 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env pwsh -#requires -Version 7.0 - -<# -.SYNOPSIS - Build Spec Kit template release archives for each supported AI assistant and script type. - -.DESCRIPTION - create-release-packages.ps1 (workflow-local) - Build Spec Kit template release archives for each supported AI assistant and script type. - -.PARAMETER Version - Version string with leading 'v' (e.g., v0.2.0) - -.PARAMETER Agents - Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic - -.PARAMETER Scripts - Comma or space separated subset of script types to build (default: both) - Valid scripts: sh, ps - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps -#> - -param( - [Parameter(Mandatory=$true, Position=0)] - [string]$Version, - - [Parameter(Mandatory=$false)] - [string]$Agents = "", - - [Parameter(Mandatory=$false)] - [string]$Scripts = "" -) - -$ErrorActionPreference = "Stop" - -# Validate version format -if ($Version -notmatch '^v\d+\.\d+\.\d+$') { - Write-Error "Version must look like v0.0.0" - exit 1 -} - -Write-Host "Building release packages for $Version" - -# Create and use .genreleases directory for all build artifacts -$GenReleasesDir = ".genreleases" -if (Test-Path $GenReleasesDir) { - Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue -} -New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null - -function Rewrite-Paths { - param([string]$Content) - - $Content = $Content -replace '(/?)\bmemory/', '.specify/memory/' - $Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/' - $Content = $Content -replace '(/?)\btemplates/', '.specify/templates/' - return $Content -} - -function Generate-Commands { - param( - [string]$Agent, - [string]$Extension, - [string]$ArgFormat, - [string]$OutputDir, - [string]$ScriptVariant - ) - - New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - - $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - - foreach ($template in $templates) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - - # Read file content and normalize line endings - $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - - # Extract description from YAML frontmatter - $description = "" - if ($fileContent -match '(?m)^description:\s*(.+)$') { - $description = $matches[1] - } - - # Extract script command from YAML frontmatter - $scriptCommand = "" - if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { - $scriptCommand = $matches[1] - } - - if ([string]::IsNullOrEmpty($scriptCommand)) { - Write-Warning "No script command found for $ScriptVariant in $($template.Name)" - $scriptCommand = "(Missing script command for $ScriptVariant)" - } - - # Extract agent_script command from YAML frontmatter if present - $agentScriptCommand = "" - if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { - $agentScriptCommand = $matches[1].Trim() - } - - # Replace {SCRIPT} placeholder with the script command - $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { - $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand - } - - # Remove the scripts: and agent_scripts: sections from frontmatter - $lines = $body -split "`n" - $outputLines = @() - $inFrontmatter = $false - $skipScripts = $false - $dashCount = 0 - - foreach ($line in $lines) { - if ($line -match '^---$') { - $outputLines += $line - $dashCount++ - if ($dashCount -eq 1) { - $inFrontmatter = $true - } else { - $inFrontmatter = $false - } - continue - } - - if ($inFrontmatter) { - if ($line -match '^(scripts|agent_scripts):$') { - $skipScripts = $true - continue - } - if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { - $skipScripts = $false - } - if ($skipScripts -and $line -match '^\s+') { - continue - } - } - - $outputLines += $line - } - - $body = $outputLines -join "`n" - - # Apply other substitutions - $body = $body -replace '\{ARGS\}', $ArgFormat - $body = $body -replace '__AGENT__', $Agent - $body = Rewrite-Paths -Content $body - - # Generate output file based on extension - $outputFile = Join-Path $OutputDir "speckit.$name.$Extension" - - switch ($Extension) { - 'toml' { - $body = $body -replace '\\', '\\' - $output = "description = `"$description`"`n`nprompt = `"`"`"`n$body`n`"`"`"" - Set-Content -Path $outputFile -Value $output -NoNewline - } - 'md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - 'agent.md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - } - } -} - -function Generate-CopilotPrompts { - param( - [string]$AgentsDir, - [string]$PromptsDir - ) - - New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null - - $agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue - - foreach ($agentFile in $agentFiles) { - $basename = $agentFile.Name -replace '\.agent\.md$', '' - $promptFile = Join-Path $PromptsDir "$basename.prompt.md" - - $content = @" ---- -agent: $basename ---- -"@ - Set-Content -Path $promptFile -Value $content - } -} - -# Create skills in \\SKILL.md format. -# Skills use hyphenated names (e.g. speckit-plan). -# -# Technical debt note: -# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension -# overrides (at minimum: name/description/compatibility/metadata.{author,source}). -function New-Skills { - param( - [string]$SkillsDir, - [string]$ScriptVariant, - [string]$AgentName, - [string]$Separator = '-' - ) - - $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - - foreach ($template in $templates) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - $skillName = "speckit${Separator}$name" - $skillDir = Join-Path $SkillsDir $skillName - New-Item -ItemType Directory -Force -Path $skillDir | Out-Null - - $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - - # Extract description - $description = "Spec Kit: $name workflow" - if ($fileContent -match '(?m)^description:\s*(.+)$') { - $description = $matches[1] - } - - # Extract script command - $scriptCommand = "(Missing script command for $ScriptVariant)" - if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { - $scriptCommand = $matches[1] - } - - # Extract agent_script command from frontmatter if present - $agentScriptCommand = "" - if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { - $agentScriptCommand = $matches[1].Trim() - } - - # Replace {SCRIPT}, strip scripts sections, rewrite paths - $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { - $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand - } - - $lines = $body -split "`n" - $outputLines = @() - $inFrontmatter = $false - $skipScripts = $false - $dashCount = 0 - - foreach ($line in $lines) { - if ($line -match '^---$') { - $outputLines += $line - $dashCount++ - $inFrontmatter = ($dashCount -eq 1) - continue - } - if ($inFrontmatter) { - if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true; continue } - if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { $skipScripts = $false } - if ($skipScripts -and $line -match '^\s+') { continue } - } - $outputLines += $line - } - - $body = $outputLines -join "`n" - $body = $body -replace '\{ARGS\}', '$ARGUMENTS' - $body = $body -replace '__AGENT__', $AgentName - $body = Rewrite-Paths -Content $body - - # Strip existing frontmatter, keep only body - $templateBody = "" - $fmCount = 0 - $inBody = $false - foreach ($line in ($body -split "`n")) { - if ($line -match '^---$') { - $fmCount++ - if ($fmCount -eq 2) { $inBody = $true } - continue - } - if ($inBody) { $templateBody += "$line`n" } - } - - $skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`ncompatibility: `"Requires spec-kit project structure with .specify/ directory`"`nmetadata:`n author: `"github-spec-kit`"`n source: `"templates/commands/$name.md`"`n---`n`n$templateBody" - Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline - } -} - -function Build-Variant { - param( - [string]$Agent, - [string]$Script - ) - - $baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}" - Write-Host "Building $Agent ($Script) package..." - New-Item -ItemType Directory -Path $baseDir -Force | Out-Null - - # Copy base structure but filter scripts by variant - $specDir = Join-Path $baseDir ".specify" - New-Item -ItemType Directory -Path $specDir -Force | Out-Null - - # Copy memory directory - if (Test-Path "memory") { - Copy-Item -Path "memory" -Destination $specDir -Recurse -Force - Write-Host "Copied memory -> .specify" - } - - # Only copy the relevant script variant directory - if (Test-Path "scripts") { - $scriptsDestDir = Join-Path $specDir "scripts" - New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null - - switch ($Script) { - 'sh' { - if (Test-Path "scripts/bash") { - Copy-Item -Path "scripts/bash" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/bash -> .specify/scripts" - } - } - 'ps' { - if (Test-Path "scripts/powershell") { - Copy-Item -Path "scripts/powershell" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/powershell -> .specify/scripts" - } - } - } - - Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object { - Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force - } - } - - # Copy templates (excluding commands directory and vscode-settings.json) - if (Test-Path "templates") { - $templatesDestDir = Join-Path $specDir "templates" - New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null - - Get-ChildItem -Path "templates" -Recurse -File | Where-Object { - $_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json' - } | ForEach-Object { - $relativePath = $_.FullName.Substring((Resolve-Path "templates").Path.Length + 1) - $destFile = Join-Path $templatesDestDir $relativePath - $destFileDir = Split-Path $destFile -Parent - New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null - Copy-Item -Path $_.FullName -Destination $destFile -Force - } - Write-Host "Copied templates -> .specify/templates" - } - - # Generate agent-specific command files - switch ($Agent) { - 'claude' { - $cmdDir = Join-Path $baseDir ".claude/commands" - Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'gemini' { - $cmdDir = Join-Path $baseDir ".gemini/commands" - Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/gemini/GEMINI.md") { - Copy-Item -Path "agent_templates/gemini/GEMINI.md" -Destination (Join-Path $baseDir "GEMINI.md") - } - } - 'copilot' { - $agentsDir = Join-Path $baseDir ".github/agents" - Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script - - $promptsDir = Join-Path $baseDir ".github/prompts" - Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir - - $vscodeDir = Join-Path $baseDir ".vscode" - New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null - if (Test-Path "templates/vscode-settings.json") { - Copy-Item -Path "templates/vscode-settings.json" -Destination (Join-Path $vscodeDir "settings.json") - } - } - 'cursor-agent' { - $cmdDir = Join-Path $baseDir ".cursor/commands" - Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qwen' { - $cmdDir = Join-Path $baseDir ".qwen/commands" - Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/qwen/QWEN.md") { - Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") - } - } - 'opencode' { - $cmdDir = Join-Path $baseDir ".opencode/command" - Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'windsurf' { - $cmdDir = Join-Path $baseDir ".windsurf/workflows" - Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'junie' { - $cmdDir = Join-Path $baseDir ".junie/commands" - Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codex' { - $skillsDir = Join-Path $baseDir ".agents/skills" - New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-' - } - 'kilocode' { - $cmdDir = Join-Path $baseDir ".kilocode/workflows" - Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'auggie' { - $cmdDir = Join-Path $baseDir ".augment/commands" - Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'roo' { - $cmdDir = Join-Path $baseDir ".roo/commands" - Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codebuddy' { - $cmdDir = Join-Path $baseDir ".codebuddy/commands" - Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'amp' { - $cmdDir = Join-Path $baseDir ".agents/commands" - Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'kiro-cli' { - $cmdDir = Join-Path $baseDir ".kiro/prompts" - Generate-Commands -Agent 'kiro-cli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'bob' { - $cmdDir = Join-Path $baseDir ".bob/commands" - Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qodercli' { - $cmdDir = Join-Path $baseDir ".qoder/commands" - Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'shai' { - $cmdDir = Join-Path $baseDir ".shai/commands" - Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'tabnine' { - $cmdDir = Join-Path $baseDir ".tabnine/agent/commands" - Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - $tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md' - if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') } - } - 'agy' { - $cmdDir = Join-Path $baseDir ".agent/commands" - Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'vibe' { - $cmdDir = Join-Path $baseDir ".vibe/prompts" - Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'kimi' { - $skillsDir = Join-Path $baseDir ".kimi/skills" - New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' - } - 'trae' { - $rulesDir = Join-Path $baseDir ".trae/rules" - New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null - Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script - } - 'pi' { - $cmdDir = Join-Path $baseDir ".pi/prompts" - Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'iflow' { - $cmdDir = Join-Path $baseDir ".iflow/commands" - Generate-Commands -Agent 'iflow' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'generic' { - $cmdDir = Join-Path $baseDir ".speckit/commands" - Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - default { - throw "Unsupported agent '$Agent'." - } - } - - # Create zip archive - $zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip" - Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force - Write-Host "Created $zipFile" -} - -# Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic') -$AllScripts = @('sh', 'ps') - -function Normalize-List { - param([string]$Value) - - if ([string]::IsNullOrEmpty($Value)) { - return @() - } - - $items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique - return $items -} - -function Validate-Subset { - param( - [string]$Type, - [string[]]$Allowed, - [string[]]$Items - ) - - $ok = $true - foreach ($item in $Items) { - if ($item -notin $Allowed) { - Write-Error "Unknown $Type '$item' (allowed: $($Allowed -join ', '))" - $ok = $false - } - } - return $ok -} - -# Determine agent list -if (-not [string]::IsNullOrEmpty($Agents)) { - $AgentList = Normalize-List -Value $Agents - if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { - exit 1 - } -} else { - $AgentList = $AllAgents -} - -# Determine script list -if (-not [string]::IsNullOrEmpty($Scripts)) { - $ScriptList = Normalize-List -Value $Scripts - if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { - exit 1 - } -} else { - $ScriptList = $AllScripts -} - -Write-Host "Agents: $($AgentList -join ', ')" -Write-Host "Scripts: $($ScriptList -join ', ')" - -# Build all variants -foreach ($agent in $AgentList) { - foreach ($script in $ScriptList) { - Build-Variant -Agent $agent -Script $script - } -} - -Write-Host "`nArchives in ${GenReleasesDir}:" -Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object { - Write-Host " $($_.Name)" -} diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh deleted file mode 100755 index a83494c3a0..0000000000 --- a/.github/workflows/scripts/create-release-packages.sh +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-release-packages.sh (workflow-local) -# Build Spec Kit template release archives for each supported AI assistant and script type. -# Usage: .github/workflows/scripts/create-release-packages.sh -# Version argument should include leading 'v'. -# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all) -# SCRIPTS : space or comma separated subset of: sh ps (default: both) -# Examples: -# AGENTS=claude SCRIPTS=sh $0 v0.2.0 -# AGENTS="copilot,gemini" $0 v0.2.0 -# SCRIPTS=ps $0 v0.2.0 - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi -NEW_VERSION="$1" -if [[ ! $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Version must look like v0.0.0" >&2 - exit 1 -fi - -echo "Building release packages for $NEW_VERSION" - -# Create and use .genreleases directory for all build artifacts -# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir) -GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}" - -# Guard against unsafe GENRELEASES_DIR values before cleaning -if [[ -z "$GENRELEASES_DIR" ]]; then - echo "GENRELEASES_DIR must not be empty" >&2 - exit 1 -fi -case "$GENRELEASES_DIR" in - '/'|'.'|'..') - echo "Refusing to use unsafe GENRELEASES_DIR value: $GENRELEASES_DIR" >&2 - exit 1 - ;; -esac -if [[ "$GENRELEASES_DIR" == *".."* ]]; then - echo "Refusing to use GENRELEASES_DIR containing '..' path segments: $GENRELEASES_DIR" >&2 - exit 1 -fi - -mkdir -p "$GENRELEASES_DIR" -rm -rf "${GENRELEASES_DIR%/}/"* || true - -rewrite_paths() { - sed -E \ - -e 's@(/?)memory/@.specify/memory/@g' \ - -e 's@(/?)scripts/@.specify/scripts/@g' \ - -e 's@(/?)templates/@.specify/templates/@g' \ - -e 's@\.specify\.specify/@.specify/@g' -} - -generate_commands() { - local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5 - mkdir -p "$output_dir" - for template in templates/commands/*.md; do - [[ -f "$template" ]] || continue - local name description script_command agent_script_command body - name=$(basename "$template" .md) - - # Normalize line endings - file_content=$(tr -d '\r' < "$template") - - # Extract description and script command from YAML frontmatter - description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') - script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - - if [[ -z $script_command ]]; then - echo "Warning: no script command found for $script_variant in $template" >&2 - script_command="(Missing script command for $script_variant)" - fi - - # Extract agent_script command from YAML frontmatter if present - agent_script_command=$(printf '%s\n' "$file_content" | awk ' - /^agent_scripts:$/ { in_agent_scripts=1; next } - in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { - sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") - print - exit - } - in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } - ') - - # Replace {SCRIPT} placeholder with the script command - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if [[ -n $agent_script_command ]]; then - body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") - fi - - # Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure - body=$(printf '%s\n' "$body" | awk ' - /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } - in_frontmatter && /^scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } - in_frontmatter && skip_scripts && /^[[:space:]]/ { next } - { print } - ') - - # Apply other substitutions - body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths) - - case $ext in - toml) - body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g') - { echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;; - md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - agent.md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - esac - done -} - -generate_copilot_prompts() { - local agents_dir=$1 prompts_dir=$2 - mkdir -p "$prompts_dir" - - # Generate a .prompt.md file for each .agent.md file - for agent_file in "$agents_dir"/speckit.*.agent.md; do - [[ -f "$agent_file" ]] || continue - - local basename=$(basename "$agent_file" .agent.md) - local prompt_file="$prompts_dir/${basename}.prompt.md" - - cat > "$prompt_file" <//SKILL.md format. -# Skills use hyphenated names (e.g. speckit-plan). -# -# Technical debt note: -# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension -# overrides (at minimum: name/description/compatibility/metadata.{author,source}). -create_skills() { - local skills_dir="$1" - local script_variant="$2" - local agent_name="$3" - local separator="${4:-"-"}" - - for template in templates/commands/*.md; do - [[ -f "$template" ]] || continue - local name - name=$(basename "$template" .md) - local skill_name="speckit${separator}${name}" - local skill_dir="${skills_dir}/${skill_name}" - mkdir -p "$skill_dir" - - local file_content - file_content=$(tr -d '\r' < "$template") - - # Extract description from frontmatter - local description - description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') - [[ -z "$description" ]] && description="Spec Kit: ${name} workflow" - - # Extract script command - local script_command - script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - [[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)" - - # Extract agent_script command from frontmatter if present - local agent_script_command - agent_script_command=$(printf '%s\n' "$file_content" | awk ' - /^agent_scripts:$/ { in_agent_scripts=1; next } - in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { - sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") - print - exit - } - in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } - ') - - # Build body: replace placeholders, strip scripts sections, rewrite paths - local body - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - if [[ -n $agent_script_command ]]; then - body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") - fi - body=$(printf '%s\n' "$body" | awk ' - /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } - in_frontmatter && /^scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } - in_frontmatter && skip_scripts && /^[[:space:]]/ { next } - { print } - ') - body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths) - - # Strip existing frontmatter and prepend skills frontmatter. - local template_body - template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found') - - { - printf -- '---\n' - printf 'name: "%s"\n' "$skill_name" - printf 'description: "%s"\n' "$description" - printf 'compatibility: "%s"\n' "Requires spec-kit project structure with .specify/ directory" - printf -- 'metadata:\n' - printf ' author: "%s"\n' "github-spec-kit" - printf ' source: "%s"\n' "templates/commands/${name}.md" - printf -- '---\n\n' - printf '%s\n' "$template_body" - } > "$skill_dir/SKILL.md" - done -} - -build_variant() { - local agent=$1 script=$2 - local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}" - echo "Building $agent ($script) package..." - mkdir -p "$base_dir" - - # Copy base structure but filter scripts by variant - SPEC_DIR="$base_dir/.specify" - mkdir -p "$SPEC_DIR" - - [[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; } - - # Only copy the relevant script variant directory - if [[ -d scripts ]]; then - mkdir -p "$SPEC_DIR/scripts" - case $script in - sh) - [[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; } - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - ps) - [[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; } - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - esac - fi - - [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" | while IFS= read -r f; do d="$SPEC_DIR/$(dirname "$f")"; mkdir -p "$d"; cp "$f" "$d/"; done; echo "Copied templates -> .specify/templates"; } - - case $agent in - claude) - mkdir -p "$base_dir/.claude/commands" - generate_commands claude md "\$ARGUMENTS" "$base_dir/.claude/commands" "$script" ;; - gemini) - mkdir -p "$base_dir/.gemini/commands" - generate_commands gemini toml "{{args}}" "$base_dir/.gemini/commands" "$script" - [[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md "$base_dir/GEMINI.md" ;; - copilot) - mkdir -p "$base_dir/.github/agents" - generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script" - generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts" - mkdir -p "$base_dir/.vscode" - [[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json" - ;; - cursor-agent) - mkdir -p "$base_dir/.cursor/commands" - generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;; - qwen) - mkdir -p "$base_dir/.qwen/commands" - generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script" - [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;; - opencode) - mkdir -p "$base_dir/.opencode/command" - generate_commands opencode md "\$ARGUMENTS" "$base_dir/.opencode/command" "$script" ;; - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; - junie) - mkdir -p "$base_dir/.junie/commands" - generate_commands junie md "\$ARGUMENTS" "$base_dir/.junie/commands" "$script" ;; - codex) - mkdir -p "$base_dir/.agents/skills" - create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;; - kilocode) - mkdir -p "$base_dir/.kilocode/workflows" - generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;; - auggie) - mkdir -p "$base_dir/.augment/commands" - generate_commands auggie md "\$ARGUMENTS" "$base_dir/.augment/commands" "$script" ;; - roo) - mkdir -p "$base_dir/.roo/commands" - generate_commands roo md "\$ARGUMENTS" "$base_dir/.roo/commands" "$script" ;; - codebuddy) - mkdir -p "$base_dir/.codebuddy/commands" - generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;; - qodercli) - mkdir -p "$base_dir/.qoder/commands" - generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; - amp) - mkdir -p "$base_dir/.agents/commands" - generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;; - shai) - mkdir -p "$base_dir/.shai/commands" - generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;; - tabnine) - mkdir -p "$base_dir/.tabnine/agent/commands" - generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script" - [[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;; - kiro-cli) - mkdir -p "$base_dir/.kiro/prompts" - generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; - agy) - mkdir -p "$base_dir/.agent/commands" - generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;; - bob) - mkdir -p "$base_dir/.bob/commands" - generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; - vibe) - mkdir -p "$base_dir/.vibe/prompts" - generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; - kimi) - mkdir -p "$base_dir/.kimi/skills" - create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;; - trae) - mkdir -p "$base_dir/.trae/rules" - generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; - pi) - mkdir -p "$base_dir/.pi/prompts" - generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;; - iflow) - mkdir -p "$base_dir/.iflow/commands" - generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;; - generic) - mkdir -p "$base_dir/.speckit/commands" - generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; - esac - ( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . ) - echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" -} - -# Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic) -ALL_SCRIPTS=(sh ps) - -validate_subset() { - local type=$1; shift - local allowed_str="$1"; shift - local invalid=0 - for it in "$@"; do - local found=0 - for a in $allowed_str; do - if [[ "$it" == "$a" ]]; then found=1; break; fi - done - if [[ $found -eq 0 ]]; then - echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2 - invalid=1 - fi - done - return $invalid -} - -read_list() { tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?" ":"") $i);out=1}}}END{printf("\n")}'; } - -if [[ -n ${AGENTS:-} ]]; then - read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)" - validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1 -else - AGENT_LIST=("${ALL_AGENTS[@]}") -fi - -if [[ -n ${SCRIPTS:-} ]]; then - read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)" - validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1 -else - SCRIPT_LIST=("${ALL_SCRIPTS[@]}") -fi - -echo "Agents: ${AGENT_LIST[*]}" -echo "Scripts: ${SCRIPT_LIST[*]}" - -for agent in "${AGENT_LIST[@]}"; do - for script in "${SCRIPT_LIST[@]}"; do - build_variant "$agent" "$script" - done -done - -echo "Archives in $GENRELEASES_DIR:" -ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip diff --git a/.github/workflows/scripts/generate-release-notes.sh b/.github/workflows/scripts/generate-release-notes.sh deleted file mode 100644 index d8f5dab1fc..0000000000 --- a/.github/workflows/scripts/generate-release-notes.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# generate-release-notes.sh -# Generate release notes from git history -# Usage: generate-release-notes.sh - -if [[ $# -ne 2 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -NEW_VERSION="$1" -LAST_TAG="$2" - -# Get commits since last tag -if [ "$LAST_TAG" = "v0.0.0" ]; then - # Check how many commits we have and use that as the limit - COMMIT_COUNT=$(git rev-list --count HEAD) - if [ "$COMMIT_COUNT" -gt 10 ]; then - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~10..HEAD) - else - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:"- %s") - fi -else - COMMITS=$(git log --oneline --pretty=format:"- %s" $LAST_TAG..HEAD) -fi - -# Create release notes -cat > release_notes.md << EOF -This is the latest set of releases that you can use with your agent of choice. We recommend using the Specify CLI to scaffold your projects, however you can download these independently and manage them yourself. - -## Changelog - -$COMMITS - -EOF - -echo "Generated release notes:" -cat release_notes.md diff --git a/.github/workflows/scripts/get-next-version.sh b/.github/workflows/scripts/get-next-version.sh deleted file mode 100644 index 9770b9fdc3..0000000000 --- a/.github/workflows/scripts/get-next-version.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# get-next-version.sh -# Calculate the next version based on the latest git tag and output GitHub Actions variables -# Usage: get-next-version.sh - -# Get the latest tag, or use v0.0.0 if no tags exist -LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") -echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT - -# Extract version number and increment -VERSION=$(echo $LATEST_TAG | sed 's/v//') -IFS='.' read -ra VERSION_PARTS <<< "$VERSION" -MAJOR=${VERSION_PARTS[0]:-0} -MINOR=${VERSION_PARTS[1]:-0} -PATCH=${VERSION_PARTS[2]:-0} - -# Increment patch version -PATCH=$((PATCH + 1)) -NEW_VERSION="v$MAJOR.$MINOR.$PATCH" - -echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT -echo "New version will be: $NEW_VERSION" diff --git a/.github/workflows/scripts/simulate-release.sh b/.github/workflows/scripts/simulate-release.sh deleted file mode 100755 index a3960d0317..0000000000 --- a/.github/workflows/scripts/simulate-release.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# simulate-release.sh -# Simulate the release process locally without pushing to GitHub -# Usage: simulate-release.sh [version] -# If version is omitted, auto-increments patch version - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}" -echo "======================================" -echo "" - -# Step 1: Determine version -if [[ -n "${1:-}" ]]; then - VERSION="${1#v}" - TAG="v$VERSION" - echo -e "${GREEN}📝 Using manual version: $VERSION${NC}" -else - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}" - - VERSION=$(echo $LATEST_TAG | sed 's/v//') - IFS='.' read -ra VERSION_PARTS <<< "$VERSION" - MAJOR=${VERSION_PARTS[0]:-0} - MINOR=${VERSION_PARTS[1]:-0} - PATCH=${VERSION_PARTS[2]:-0} - - PATCH=$((PATCH + 1)) - VERSION="$MAJOR.$MINOR.$PATCH" - TAG="v$VERSION" - echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}" -fi - -echo "" - -# Step 2: Check if tag exists -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}" - echo " Please use a different version or delete the tag first." - exit 1 -fi -echo -e "${GREEN}✓ Tag $TAG is available${NC}" - -# Step 3: Backup current state -echo "" -echo -e "${YELLOW}💾 Creating backup of current state...${NC}" -BACKUP_DIR=$(mktemp -d) -cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak" -cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak" -echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}" - -# Step 4: Update pyproject.toml -echo "" -echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}" -sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml -rm -f pyproject.toml.tmp -echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}" - -# Step 5: Update CHANGELOG.md -echo "" -echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}" -DATE=$(date +%Y-%m-%d) - -# Get the previous tag to compare commits -PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - -if [[ -n "$PREVIOUS_TAG" ]]; then - echo " Generating changelog from commits since $PREVIOUS_TAG" - # Get commits since last tag, format as bullet points - COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") -else - echo " No previous tag found - this is the first release" - COMMITS="- Initial release" -fi - -# Create temp file with new entry -{ - head -n 8 CHANGELOG.md - echo "" - echo "## [$VERSION] - $DATE" - echo "" - echo "### Changed" - echo "" - echo "$COMMITS" - echo "" - tail -n +9 CHANGELOG.md -} > CHANGELOG.md.tmp -mv CHANGELOG.md.tmp CHANGELOG.md -echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}" - -# Step 6: Show what would be committed -echo "" -echo -e "${YELLOW}📋 Changes that would be committed:${NC}" -git diff pyproject.toml CHANGELOG.md - -# Step 7: Create temporary tag (no push) -echo "" -echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}" -git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true -echo -e "${GREEN}✓ Tag $TAG created locally${NC}" - -# Step 8: Simulate release artifact creation -echo "" -echo -e "${YELLOW}📦 Simulating release package creation...${NC}" -echo " (High-level simulation only; packaging script is not executed)" -echo "" - -# Check if script exists and is executable -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then - echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}" - echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\"" - echo "" - echo "This simulation does not enumerate individual package files to avoid" - echo "drifting from the actual behavior of create-release-packages.sh." -else - echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}" -fi - -# Step 9: Simulate release notes generation -echo "" -echo -e "${YELLOW}📄 Simulating release notes generation...${NC}" -echo "" -PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "") -if [[ -n "$PREVIOUS_TAG" ]]; then - echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}" - git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10 - echo "" -else - echo -e "${BLUE}No previous tag found - this would be the first release${NC}" -fi - -# Step 10: Summary -echo "" -echo -e "${GREEN}🎉 Simulation Complete!${NC}" -echo "======================================" -echo "" -echo -e "${BLUE}Summary:${NC}" -echo " Version: $VERSION" -echo " Tag: $TAG" -echo " Backup: $BACKUP_DIR" -echo "" -echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}" -echo "" -echo -e "${BLUE}Next steps:${NC}" -echo " 1. Review the changes above" -echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit" -echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG" -echo " 4. To restore from backup: cp $BACKUP_DIR/* ." -echo "" -echo -e "${BLUE}To run the actual release:${NC}" -echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml" -echo " Click 'Run workflow' and enter version: $VERSION" -echo "" diff --git a/.github/workflows/scripts/update-version.sh b/.github/workflows/scripts/update-version.sh deleted file mode 100644 index 12bd9cd1d6..0000000000 --- a/.github/workflows/scripts/update-version.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# update-version.sh -# Update version in pyproject.toml (for release artifacts only) -# Usage: update-version.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix for Python versioning -PYTHON_VERSION=${VERSION#v} - -if [ -f "pyproject.toml" ]; then - sed -i "s/version = \".*\"/version = \"$PYTHON_VERSION\"/" pyproject.toml - echo "Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)" -else - echo "Warning: pyproject.toml not found, skipping version update" -fi diff --git a/pyproject.toml b/pyproject.toml index dbb24e59fe..bfd5f25c25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,6 @@ packages = ["src/specify_cli"] "templates/commands" = "specify_cli/core_pack/commands" "scripts/bash" = "specify_cli/core_pack/scripts/bash" "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" -".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh" -".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 26116430c9..5f0cf0bdfd 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -42,7 +42,6 @@ import httpx from rich.console import Console from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn from rich.text import Text from rich.live import Live from rich.align import Align @@ -54,7 +53,7 @@ import readchar import ssl import truststore -from datetime import datetime, timezone +from datetime import datetime ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -68,248 +67,16 @@ def _github_auth_headers(cli_token: str | None = None) -> dict: token = _github_token(cli_token) return {"Authorization": f"Bearer {token}"} if token else {} -def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: - """Extract and parse GitHub rate-limit headers.""" - info = {} - - # Standard GitHub rate-limit headers - if "X-RateLimit-Limit" in headers: - info["limit"] = headers.get("X-RateLimit-Limit") - if "X-RateLimit-Remaining" in headers: - info["remaining"] = headers.get("X-RateLimit-Remaining") - if "X-RateLimit-Reset" in headers: - reset_epoch = int(headers.get("X-RateLimit-Reset", "0")) - if reset_epoch: - reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc) - info["reset_epoch"] = reset_epoch - info["reset_time"] = reset_time - info["reset_local"] = reset_time.astimezone() - - # Retry-After header (seconds or HTTP-date) - if "Retry-After" in headers: - retry_after = headers.get("Retry-After") - try: - info["retry_after_seconds"] = int(retry_after) - except ValueError: - # HTTP-date format - not implemented, just store as string - info["retry_after"] = retry_after - - return info +def _build_agent_config() -> dict[str, dict[str, Any]]: + """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" + from .integrations import INTEGRATION_REGISTRY + config: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if integration.config: + config[key] = dict(integration.config) + return config -def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str: - """Format a user-friendly error message with rate-limit information.""" - rate_info = _parse_rate_limit_headers(headers) - - lines = [f"GitHub API returned status {status_code} for {url}"] - lines.append("") - - if rate_info: - lines.append("[bold]Rate Limit Information:[/bold]") - if "limit" in rate_info: - lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour") - if "remaining" in rate_info: - lines.append(f" • Remaining: {rate_info['remaining']}") - if "reset_local" in rate_info: - reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z") - lines.append(f" • Resets at: {reset_str}") - if "retry_after_seconds" in rate_info: - lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds") - lines.append("") - - # Add troubleshooting guidance - lines.append("[bold]Troubleshooting Tips:[/bold]") - lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.") - lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN") - lines.append(" environment variable to increase rate limits.") - lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.") - - return "\n".join(lines) - -# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory -AGENT_CONFIG = { - "copilot": { - "name": "GitHub Copilot", - "folder": ".github/", - "commands_subdir": "agents", # Special: uses agents/ not commands/ - "install_url": None, # IDE-based, no CLI check needed - "requires_cli": False, - }, - "claude": { - "name": "Claude Code", - "folder": ".claude/", - "commands_subdir": "commands", - "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", - "requires_cli": True, - }, - "gemini": { - "name": "Gemini CLI", - "folder": ".gemini/", - "commands_subdir": "commands", - "install_url": "https://github.com/google-gemini/gemini-cli", - "requires_cli": True, - }, - "cursor-agent": { - "name": "Cursor", - "folder": ".cursor/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "qwen": { - "name": "Qwen Code", - "folder": ".qwen/", - "commands_subdir": "commands", - "install_url": "https://github.com/QwenLM/qwen-code", - "requires_cli": True, - }, - "opencode": { - "name": "opencode", - "folder": ".opencode/", - "commands_subdir": "command", # Special: singular 'command' not 'commands' - "install_url": "https://opencode.ai", - "requires_cli": True, - }, - "codex": { - "name": "Codex CLI", - "folder": ".agents/", - "commands_subdir": "skills", # Codex now uses project skills directly - "install_url": "https://github.com/openai/codex", - "requires_cli": True, - }, - "windsurf": { - "name": "Windsurf", - "folder": ".windsurf/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "junie": { - "name": "Junie", - "folder": ".junie/", - "commands_subdir": "commands", - "install_url": "https://junie.jetbrains.com/", - "requires_cli": True, - }, - "kilocode": { - "name": "Kilo Code", - "folder": ".kilocode/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "auggie": { - "name": "Auggie CLI", - "folder": ".augment/", - "commands_subdir": "commands", - "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", - "requires_cli": True, - }, - "codebuddy": { - "name": "CodeBuddy", - "folder": ".codebuddy/", - "commands_subdir": "commands", - "install_url": "https://www.codebuddy.ai/cli", - "requires_cli": True, - }, - "qodercli": { - "name": "Qoder CLI", - "folder": ".qoder/", - "commands_subdir": "commands", - "install_url": "https://qoder.com/cli", - "requires_cli": True, - }, - "roo": { - "name": "Roo Code", - "folder": ".roo/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "kiro-cli": { - "name": "Kiro CLI", - "folder": ".kiro/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ - "install_url": "https://kiro.dev/docs/cli/", - "requires_cli": True, - }, - "amp": { - "name": "Amp", - "folder": ".agents/", - "commands_subdir": "commands", - "install_url": "https://ampcode.com/manual#install", - "requires_cli": True, - }, - "shai": { - "name": "SHAI", - "folder": ".shai/", - "commands_subdir": "commands", - "install_url": "https://github.com/ovh/shai", - "requires_cli": True, - }, - "tabnine": { - "name": "Tabnine CLI", - "folder": ".tabnine/agent/", - "commands_subdir": "commands", - "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", - "requires_cli": True, - }, - "agy": { - "name": "Antigravity", - "folder": ".agent/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "bob": { - "name": "IBM Bob", - "folder": ".bob/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "vibe": { - "name": "Mistral Vibe", - "folder": ".vibe/", - "commands_subdir": "prompts", - "install_url": "https://github.com/mistralai/mistral-vibe", - "requires_cli": True, - }, - "kimi": { - "name": "Kimi Code", - "folder": ".kimi/", - "commands_subdir": "skills", # Kimi uses /skill: with .kimi/skills//SKILL.md - "install_url": "https://code.kimi.com/", - "requires_cli": True, - }, - "trae": { - "name": "Trae", - "folder": ".trae/", - "commands_subdir": "rules", # Trae uses .trae/rules/ for project rules - "install_url": None, # IDE-based - "requires_cli": False, - }, - "pi": { - "name": "Pi Coding Agent", - "folder": ".pi/", - "commands_subdir": "prompts", - "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", - "requires_cli": True, - }, - "iflow": { - "name": "iFlow CLI", - "folder": ".iflow/", - "commands_subdir": "commands", - "install_url": "https://docs.iflow.cn/en/cli/quickstart", - "requires_cli": True, - }, - "generic": { - "name": "Generic (bring your own agent)", - "folder": None, # Set dynamically via --ai-commands-dir - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - }, -} +AGENT_CONFIG = _build_agent_config() AI_ASSISTANT_ALIASES = { "kiro": "kiro-cli", @@ -837,314 +604,6 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, return merged -def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]: - repo_owner = "github" - repo_name = "spec-kit" - if client is None: - client = httpx.Client(verify=ssl_context) - - if verbose: - console.print("[cyan]Fetching latest release information...[/cyan]") - api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - - try: - response = client.get( - api_url, - timeout=30, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) - status = response.status_code - if status != 200: - # Format detailed error message with rate-limit info - error_msg = _format_rate_limit_error(status, response.headers, api_url) - if debug: - error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}" - raise RuntimeError(error_msg) - try: - release_data = response.json() - except ValueError as je: - raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") - except Exception as e: - console.print("[red]Error fetching release information[/red]") - console.print(Panel(str(e), title="Fetch Error", border_style="red")) - raise typer.Exit(1) - - assets = release_data.get("assets", []) - pattern = f"spec-kit-template-{ai_assistant}-{script_type}" - matching_assets = [ - asset for asset in assets - if pattern in asset["name"] and asset["name"].endswith(".zip") - ] - - asset = matching_assets[0] if matching_assets else None - - if asset is None: - console.print(f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])") - asset_names = [a.get('name', '?') for a in assets] - console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow")) - raise typer.Exit(1) - - download_url = asset["browser_download_url"] - filename = asset["name"] - file_size = asset["size"] - - if verbose: - console.print(f"[cyan]Found template:[/cyan] {filename}") - console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes") - console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}") - - zip_path = download_dir / filename - if verbose: - console.print("[cyan]Downloading template...[/cyan]") - - try: - with client.stream( - "GET", - download_url, - timeout=60, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) as response: - if response.status_code != 200: - # Handle rate-limiting on download as well - error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url) - if debug: - error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}" - raise RuntimeError(error_msg) - total_size = int(response.headers.get('content-length', 0)) - with open(zip_path, 'wb') as f: - if total_size == 0: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - else: - if show_progress: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - console=console, - ) as progress: - task = progress.add_task("Downloading...", total=total_size) - downloaded = 0 - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - downloaded += len(chunk) - progress.update(task, completed=downloaded) - else: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - except Exception as e: - console.print("[red]Error downloading template[/red]") - detail = str(e) - if zip_path.exists(): - zip_path.unlink() - console.print(Panel(detail, title="Download Error", border_style="red")) - raise typer.Exit(1) - if verbose: - console.print(f"Downloaded: {filename}") - metadata = { - "filename": filename, - "size": file_size, - "release": release_data["tag_name"], - "asset_url": download_url - } - return zip_path, metadata - -def download_and_extract_template( - project_path: Path, - ai_assistant: str, - script_type: str, - is_current_dir: bool = False, - *, - skip_legacy_codex_prompts: bool = False, - verbose: bool = True, - tracker: StepTracker | None = None, - client: httpx.Client = None, - debug: bool = False, - github_token: str = None, -) -> Path: - """Download the latest release and extract it to create a new project. - Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) - - Note: - ``skip_legacy_codex_prompts`` suppresses the legacy top-level - ``.codex`` directory from older template archives in Codex skills mode. - The name is kept for backward compatibility with existing callers. - """ - current_dir = Path.cwd() - - if tracker: - tracker.start("fetch", "contacting GitHub API") - try: - zip_path, meta = download_template_from_github( - ai_assistant, - current_dir, - script_type=script_type, - verbose=verbose and tracker is None, - show_progress=(tracker is None), - client=client, - debug=debug, - github_token=github_token - ) - if tracker: - tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") - tracker.add("download", "Download template") - tracker.complete("download", meta['filename']) - except Exception as e: - if tracker: - tracker.error("fetch", str(e)) - else: - if verbose: - console.print(f"[red]Error downloading template:[/red] {e}") - raise - - if tracker: - tracker.add("extract", "Extract template") - tracker.start("extract") - elif verbose: - console.print("Extracting template...") - - try: - if not is_current_dir: - project_path.mkdir(parents=True) - - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - def _validate_zip_members_within(root: Path) -> None: - """Validate all ZIP members stay within ``root`` (Zip Slip guard).""" - root_resolved = root.resolve() - for member in zip_ref.namelist(): - member_path = (root / member).resolve() - try: - member_path.relative_to(root_resolved) - except ValueError: - raise RuntimeError( - f"Unsafe path in ZIP archive: {member} " - "(potential path traversal)" - ) - - zip_contents = zip_ref.namelist() - if tracker: - tracker.start("zip-list") - tracker.complete("zip-list", f"{len(zip_contents)} entries") - elif verbose: - console.print(f"[cyan]ZIP contains {len(zip_contents)} items[/cyan]") - - if is_current_dir: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - _validate_zip_members_within(temp_path) - zip_ref.extractall(temp_path) - - extracted_items = list(temp_path.iterdir()) - if tracker: - tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"temp {len(extracted_items)} items") - elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]") - - source_dir = temp_path - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - source_dir = extracted_items[0] - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print("[cyan]Found nested directory structure[/cyan]") - - for item in source_dir.iterdir(): - # In Codex skills mode, do not materialize the legacy - # top-level .codex directory from older prompt-based - # template archives. - if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex": - continue - dest_path = project_path / item.name - if item.is_dir(): - if dest_path.exists(): - if verbose and not tracker: - console.print(f"[yellow]Merging directory:[/yellow] {item.name}") - for sub_item in item.rglob('*'): - if sub_item.is_file(): - rel_path = sub_item.relative_to(item) - dest_file = dest_path / rel_path - dest_file.parent.mkdir(parents=True, exist_ok=True) - # Special handling for .vscode/settings.json - merge instead of overwrite - if dest_file.name == "settings.json" and dest_file.parent.name == ".vscode": - handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker) - else: - shutil.copy2(sub_item, dest_file) - else: - shutil.copytree(item, dest_path) - else: - if dest_path.exists() and verbose and not tracker: - console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") - shutil.copy2(item, dest_path) - if verbose and not tracker: - console.print("[cyan]Template files merged into current directory[/cyan]") - else: - _validate_zip_members_within(project_path) - zip_ref.extractall(project_path) - - extracted_items = list(project_path.iterdir()) - if tracker: - tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"{len(extracted_items)} top-level items") - elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]") - for item in extracted_items: - console.print(f" - {item.name} ({'dir' if item.is_dir() else 'file'})") - - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - nested_dir = extracted_items[0] - temp_move_dir = project_path.parent / f"{project_path.name}_temp" - - shutil.move(str(nested_dir), str(temp_move_dir)) - - project_path.rmdir() - - shutil.move(str(temp_move_dir), str(project_path)) - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print("[cyan]Flattened nested directory structure[/cyan]") - - # For fresh-directory Codex skills init, suppress legacy - # top-level .codex layout extracted from older archives. - if skip_legacy_codex_prompts and ai_assistant == "codex": - legacy_codex_dir = project_path / ".codex" - if legacy_codex_dir.is_dir(): - shutil.rmtree(legacy_codex_dir, ignore_errors=True) - - except Exception as e: - if tracker: - tracker.error("extract", str(e)) - else: - if verbose: - console.print(f"[red]Error extracting template:[/red] {e}") - if debug: - console.print(Panel(str(e), title="Extraction Error", border_style="red")) - - if not is_current_dir and project_path.exists(): - shutil.rmtree(project_path) - raise typer.Exit(1) - else: - if tracker: - tracker.complete("extract") - finally: - if tracker: - tracker.add("cleanup", "Remove temporary archive") - - if zip_path.exists(): - zip_path.unlink() - if tracker: - tracker.complete("cleanup") - elif verbose: - console.print(f"Cleaned up: {zip_path.name}") - - return project_path - - def _locate_core_pack() -> Path | None: """Return the filesystem path to the bundled core_pack directory, or None. @@ -1162,41 +621,6 @@ def _locate_core_pack() -> Path | None: return None -def _locate_release_script() -> tuple[Path, str]: - """Return (script_path, shell_cmd) for the platform-appropriate release script. - - Checks the bundled core_pack first, then falls back to the source checkout. - Returns the bash script on Unix and the PowerShell script on Windows. - Raises FileNotFoundError if neither can be found. - """ - if os.name == "nt": - name = "create-release-packages.ps1" - shell = shutil.which("pwsh") - if not shell: - raise FileNotFoundError( - "'pwsh' (PowerShell 7+) not found on PATH. " - "The bundled release script requires PowerShell 7+ (pwsh), " - "not Windows PowerShell 5.x (powershell.exe). " - "Install from https://aka.ms/powershell to use offline scaffolding." - ) - else: - name = "create-release-packages.sh" - shell = "bash" - - # Wheel install: core_pack/release_scripts/ - candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name - if candidate.is_file(): - return candidate, shell - - # Source-checkout fallback - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / ".github" / "workflows" / "scripts" / name - if candidate.is_file(): - return candidate, shell - - raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") - - def _install_shared_infra( project_path: Path, script_type: str, @@ -1275,189 +699,6 @@ def _install_shared_infra( return True -def scaffold_from_core_pack( - project_path: Path, - ai_assistant: str, - script_type: str, - is_current_dir: bool = False, - *, - tracker: StepTracker | None = None, -) -> bool: - """Scaffold a project from bundled core_pack assets — no network access required. - - Invokes the bundled create-release-packages script (bash on Unix, PowerShell - on Windows) to generate the full project scaffold for a single agent. This - guarantees byte-for-byte parity between ``specify init`` and the GitHub - release ZIPs because both use the exact same script. - - Returns True on success. Returns False if offline scaffolding failed for - any reason, including missing or unreadable assets, missing required tools - (bash, pwsh, zip), release-script failure or timeout, or unexpected runtime - exceptions. When ``--offline`` is active the caller should treat False as - a hard error rather than falling back to a network download. - """ - # --- Locate asset sources --- - core = _locate_core_pack() - - # Command templates - if core and (core / "commands").is_dir(): - commands_dir = core / "commands" - else: - repo_root = Path(__file__).parent.parent.parent - commands_dir = repo_root / "templates" / "commands" - if not commands_dir.is_dir(): - if tracker: - tracker.error("scaffold", "command templates not found") - return False - - # Scripts directory (parent of bash/ and powershell/) - if core and (core / "scripts").is_dir(): - scripts_dir = core / "scripts" - else: - repo_root = Path(__file__).parent.parent.parent - scripts_dir = repo_root / "scripts" - if not scripts_dir.is_dir(): - if tracker: - tracker.error("scaffold", "scripts directory not found") - return False - - # Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.) - if core and (core / "templates").is_dir(): - templates_dir = core / "templates" - else: - repo_root = Path(__file__).parent.parent.parent - templates_dir = repo_root / "templates" - if not templates_dir.is_dir(): - if tracker: - tracker.error("scaffold", "page templates not found") - return False - - # Release script - try: - release_script, shell_cmd = _locate_release_script() - except FileNotFoundError as exc: - if tracker: - tracker.error("scaffold", str(exc)) - return False - - # Preflight: verify required external tools are available - if os.name != "nt": - if not shutil.which("bash"): - msg = "'bash' not found on PATH. Required for offline scaffolding." - if tracker: - tracker.error("scaffold", msg) - return False - if not shutil.which("zip"): - msg = "'zip' not found on PATH. Required for offline scaffolding. Install with: apt install zip / brew install zip" - if tracker: - tracker.error("scaffold", msg) - return False - - if tracker: - tracker.start("scaffold", "applying bundled assets") - - try: - if not is_current_dir: - project_path.mkdir(parents=True, exist_ok=True) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - - # Set up a repo-like directory layout in the temp dir so the - # release script finds templates/commands/, scripts/, etc. - tmpl_cmds = tmp / "templates" / "commands" - tmpl_cmds.mkdir(parents=True) - for f in commands_dir.iterdir(): - if f.is_file(): - shutil.copy2(f, tmpl_cmds / f.name) - - # Page templates (needed for vscode-settings.json etc.) - if templates_dir.is_dir(): - tmpl_root = tmp / "templates" - for f in templates_dir.iterdir(): - if f.is_file(): - shutil.copy2(f, tmpl_root / f.name) - - # Scripts (bash/ and powershell/) - for subdir in ("bash", "powershell"): - src = scripts_dir / subdir - if src.is_dir(): - dst = tmp / "scripts" / subdir - dst.mkdir(parents=True, exist_ok=True) - for f in src.iterdir(): - if f.is_file(): - shutil.copy2(f, dst / f.name) - - # Run the release script for this single agent + script type - env = os.environ.copy() - # Pin GENRELEASES_DIR inside the temp dir so a user-exported - # value cannot redirect output or cause rm -rf outside the sandbox. - env["GENRELEASES_DIR"] = str(tmp / ".genreleases") - if os.name == "nt": - cmd = [ - shell_cmd, "-File", str(release_script), - "-Version", "v0.0.0", - "-Agents", ai_assistant, - "-Scripts", script_type, - ] - else: - cmd = [shell_cmd, str(release_script), "v0.0.0"] - env["AGENTS"] = ai_assistant - env["SCRIPTS"] = script_type - - try: - result = subprocess.run( - cmd, cwd=str(tmp), env=env, - capture_output=True, text=True, - timeout=120, - ) - except subprocess.TimeoutExpired: - msg = "release script timed out after 120 seconds" - if tracker: - tracker.error("scaffold", msg) - else: - console.print(f"[red]Error:[/red] {msg}") - return False - - if result.returncode != 0: - msg = result.stderr.strip() or result.stdout.strip() or "unknown error" - if tracker: - tracker.error("scaffold", f"release script failed: {msg}") - else: - console.print(f"[red]Release script failed:[/red] {msg}") - return False - - # Copy the generated files to the project directory - build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}" - if not build_dir.is_dir(): - if tracker: - tracker.error("scaffold", "release script produced no output") - return False - - for item in build_dir.rglob("*"): - if item.is_file(): - rel = item.relative_to(build_dir) - dest = project_path / rel - dest.parent.mkdir(parents=True, exist_ok=True) - # When scaffolding into an existing directory (--here), - # use the same merge semantics as the GitHub-download path. - if is_current_dir and dest.name == "settings.json" and dest.parent.name == ".vscode": - handle_vscode_settings(item, dest, rel, verbose=False, tracker=tracker) - else: - shutil.copy2(item, dest) - - if tracker: - tracker.complete("scaffold", "bundled assets applied") - return True - - except Exception as e: - if tracker: - tracker.error("scaffold", str(e)) - else: - console.print(f"[red]Error scaffolding from bundled assets:[/red] {e}") - return False - - def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": @@ -1571,340 +812,35 @@ def load_init_options(project_path: Path) -> dict[str, Any]: return {} -# Default skills directory for agents not in AGENT_CONFIG -DEFAULT_SKILLS_DIR = ".agents/skills" - -# Agents whose downloaded template already contains skills in the final layout. -# -# Technical debt note: -# - Spec-kit currently has multiple SKILL.md generators: -# 1) release packaging scripts that build the template zip (native skills), -# 2) `install_ai_skills()` which converts extracted command templates to skills, -# 3) extension/preset overrides via `agents.CommandRegistrar.render_skill_command()`. -# - Keep the skills frontmatter schema aligned across all generators -# (at minimum: name/description/compatibility/metadata.{author,source}). -# - When adding fields here, update the release scripts and override writers too. -NATIVE_SKILLS_AGENTS = {"codex", "kimi"} - -# Enhanced descriptions for each spec-kit command skill -SKILL_DESCRIPTIONS = { - "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", - "plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.", - "tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.", - "implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.", - "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.", - "clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.", - "constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.", - "checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.", - "taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.", -} - - def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: - """Resolve the agent-specific skills directory for the given AI assistant. + """Resolve the agent-specific skills directory. - Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to - ``DEFAULT_SKILLS_DIR`` for unknown agents. + Returns ``project_path / / "skills"``, falling back + to ``project_path / ".agents/skills"`` for unknown agents. """ agent_config = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: return project_path / agent_folder.rstrip("/") / "skills" + return project_path / ".agents" / "skills" - return project_path / DEFAULT_SKILLS_DIR - - -def install_ai_skills( - project_path: Path, - selected_ai: str, - tracker: StepTracker | None = None, - *, - overwrite_existing: bool = False, -) -> bool: - """Install Prompt.MD files from templates/commands/ as agent skills. - - Skills are written to the agent-specific skills directory following the - `agentskills.io `_ specification. - Installation is additive by default — existing files are never removed and - prompt command files in the agent's commands directory are left untouched. - - Args: - project_path: Target project directory. - selected_ai: AI assistant key from ``AGENT_CONFIG``. - tracker: Optional progress tracker. - overwrite_existing: When True, overwrite any existing ``SKILL.md`` file - in the target skills directory (including user-authored content). - Defaults to False. - - Returns: - ``True`` if at least one skill was installed or all skills were - already present (idempotent re-run), ``False`` otherwise. - """ - from .agents import CommandRegistrar - - # Locate command templates in the agent's extracted commands directory. - # download_and_extract_template() already placed the .md files here. - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - commands_subdir = agent_config.get("commands_subdir", "commands") - if agent_folder: - templates_dir = project_path / agent_folder.rstrip("/") / commands_subdir - else: - templates_dir = project_path / commands_subdir - - # Only consider speckit.*.md templates so that user-authored command - # files (e.g. custom slash commands, agent files) coexisting in the - # same commands directory are not incorrectly converted into skills. - template_glob = "speckit.*.md" - - if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): - # Fallback: try the repo-relative path (for running from source checkout) - # This also covers agents whose extracted commands are in a different - # format (e.g. gemini/tabnine use .toml, not .md). - script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ - fallback_dir = script_dir / "templates" / "commands" - if fallback_dir.exists() and any(fallback_dir.glob("*.md")): - templates_dir = fallback_dir - template_glob = "*.md" - - if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): - if tracker: - tracker.error("ai-skills", "command templates not found") - else: - console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") - return False - - command_files = sorted(templates_dir.glob(template_glob)) - if not command_files: - if tracker: - tracker.skip("ai-skills", "no command templates found") - else: - console.print("[yellow]No command templates found to install[/yellow]") - return False - - # Resolve the correct skills directory for this agent - skills_dir = _get_skills_dir(project_path, selected_ai) - skills_dir.mkdir(parents=True, exist_ok=True) - - if tracker: - tracker.start("ai-skills") - - installed_count = 0 - skipped_count = 0 - for command_file in command_files: - try: - content = command_file.read_text(encoding="utf-8") - - # Parse YAML frontmatter - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - # File starts with --- but has no closing --- - console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") - frontmatter = {} - body = content - else: - frontmatter = {} - body = content - - command_name = command_file.stem - # Normalize: extracted commands may be named "speckit..md" - # or "speckit..agent.md"; strip the "speckit." prefix and - # any trailing ".agent" suffix so skill names stay clean and - # SKILL_DESCRIPTIONS lookups work. - if command_name.startswith("speckit."): - command_name = command_name[len("speckit."):] - if command_name.endswith(".agent"): - command_name = command_name[:-len(".agent")] - skill_name = f"speckit-{command_name.replace('.', '-')}" - - # Create skill directory (additive — never removes existing content) - skill_dir = skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - - # Select the best description available - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}") - - # Build SKILL.md following agentskills.io spec - # Use yaml.safe_dump to safely serialise the frontmatter and - # avoid YAML injection from descriptions containing colons, - # quotes, or newlines. - # Normalize source filename for metadata — strip speckit. prefix - # so it matches the canonical templates/commands/.md path. - source_name = command_file.name - if source_name.startswith("speckit."): - source_name = source_name[len("speckit."):] - if source_name.endswith(".agent.md"): - source_name = source_name[:-len(".agent.md")] + ".md" - - frontmatter_data = CommandRegistrar.build_skill_frontmatter( - selected_ai, - skill_name, - enhanced_desc, - f"templates/commands/{source_name}", - ) - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {command_name.title()} Skill\n\n" - f"{body}\n" - ) - - skill_file = skill_dir / "SKILL.md" - if skill_file.exists(): - if not overwrite_existing: - # Default behavior: do not overwrite user-customized skills on re-runs - skipped_count += 1 - continue - skill_file.write_text(skill_content, encoding="utf-8") - installed_count += 1 - - except Exception as e: - console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]") - continue - - if tracker: - if installed_count > 0 and skipped_count > 0: - tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}") - elif installed_count > 0: - tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}") - elif skipped_count > 0: - tracker.complete("ai-skills", f"{skipped_count} skills already present") - else: - tracker.error("ai-skills", "no skills installed") - else: - if installed_count > 0: - console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/") - elif skipped_count > 0: - console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/") - else: - console.print("[yellow]No skills were installed[/yellow]") - - return installed_count > 0 or skipped_count > 0 - - -def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool: - """Return True when a native-skills agent has spec-kit bundled skills.""" - skills_dir = _get_skills_dir(project_path, selected_ai) - if not skills_dir.is_dir(): - return False - return any(skills_dir.glob("speckit-*/SKILL.md")) - - -def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. - - Temporary migration helper: - - Intended removal window: after 2026-06-25. - - Purpose: one-time cleanup for projects initialized before Kimi moved to - hyphenated skills (speckit-xxx). - - Returns: - Tuple[migrated_count, removed_count] - - migrated_count: old dotted dir renamed to hyphenated dir - - removed_count: old dotted dir deleted when equivalent hyphenated dir existed - """ - if not skills_dir.is_dir(): - return (0, 0) - - migrated_count = 0 - removed_count = 0 - - for legacy_dir in sorted(skills_dir.glob("speckit.*")): - if not legacy_dir.is_dir(): - continue - if not (legacy_dir / "SKILL.md").exists(): - continue - - suffix = legacy_dir.name[len("speckit."):] - if not suffix: - continue - - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" - - if not target_dir.exists(): - shutil.move(str(legacy_dir), str(target_dir)) - migrated_count += 1 - continue - - # If the new target already exists, avoid destructive cleanup unless - # both SKILL.md files are byte-identical. - target_skill = target_dir / "SKILL.md" - legacy_skill = legacy_dir / "SKILL.md" - if target_skill.is_file(): - try: - if target_skill.read_bytes() == legacy_skill.read_bytes(): - # Preserve legacy directory when it contains extra user files. - has_extra_entries = any( - child.name != "SKILL.md" for child in legacy_dir.iterdir() - ) - if not has_extra_entries: - shutil.rmtree(legacy_dir) - removed_count += 1 - except OSError: - # Best-effort migration: preserve legacy dir on read failures. - pass - - return (migrated_count, removed_count) - - -AGENT_SKILLS_MIGRATIONS = { - "claude": { - "error": ( - "Claude Code now installs spec-kit as agent skills; " - "legacy .claude/commands projects are kept for backwards compatibility." - ), - "usage": "specify init --ai claude", - "interactive_note": ( - "'claude' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically so spec-kit is installed to [cyan].claude/skills[/cyan]." - ), - "explicit_note": ( - "'claude' now installs spec-kit as agent skills; enabling " - "[cyan]--ai-skills[/cyan] automatically so commands are written to " - "[cyan].claude/skills[/cyan]." - ), - "auto_enable_explicit": True, - }, - "agy": { - "error": "Explicit command support was deprecated in Antigravity version 1.20.5.", - "usage": "specify init --ai agy --ai-skills", - "interactive_note": ( - "'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically for compatibility (explicit .agent/commands usage is deprecated)." - ), - }, - "codex": { - "error": ( - "Custom prompt-based spec-kit initialization is deprecated for Codex CLI; " - "use agent skills instead." - ), - "usage": "specify init --ai codex --ai-skills", - "interactive_note": ( - "'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically for compatibility (.agents/skills is the recommended Codex layout)." - ), - }, +# Constants kept for backward compatibility with presets and extensions. +DEFAULT_SKILLS_DIR = ".agents/skills" +NATIVE_SKILLS_AGENTS = {"codex", "kimi"} +SKILL_DESCRIPTIONS = { + "specify": "Create or update feature specifications from natural language descriptions.", + "plan": "Generate technical implementation plans from feature specifications.", + "tasks": "Break down implementation plans into actionable task lists.", + "implement": "Execute all tasks from the task breakdown to build the feature.", + "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.", + "clarify": "Structured clarification workflow for underspecified requirements.", + "constitution": "Create or update project governing principles and development guidelines.", + "checklist": "Generate custom quality checklists for validating requirements completeness and clarity.", + "taskstoissues": "Convert tasks from tasks.md into GitHub issues.", } -def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: - """Print a fail-fast migration error for agents that now require skills.""" - migration = AGENT_SKILLS_MIGRATIONS[agent_key] - console.print(f"\n[red]Error:[/red] {migration['error']}") - console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") - console.print(f"[yellow]Usage:[/yellow] {migration['usage']}") - raise typer.Exit(1) - @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -1915,11 +851,11 @@ def init( no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), - skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), - debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), - github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), + debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), + github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), - offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), + offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), @@ -1988,45 +924,37 @@ def init( # --integration and --ai are mutually exclusive if integration and ai_assistant: console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") - console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path") raise typer.Exit(1) - # Auto-promote: --ai → integration path with a nudge (if registered) - use_integration = False - resolved_integration = None + # Resolve the integration — either from --integration or --ai + from .integrations import INTEGRATION_REGISTRY, get_integration if integration: - from .integrations import INTEGRATION_REGISTRY, get_integration resolved_integration = get_integration(integration) if not resolved_integration: console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") available = ", ".join(sorted(INTEGRATION_REGISTRY)) console.print(f"[yellow]Available integrations:[/yellow] {available}") raise typer.Exit(1) - use_integration = True - # Map integration key to the ai_assistant variable for downstream compatibility ai_assistant = integration elif ai_assistant: - from .integrations import get_integration resolved_integration = get_integration(ai_assistant) - if resolved_integration: - use_integration = True - console.print( - f"[dim]Tip: Use [bold]--integration {ai_assistant}[/bold] instead of " - f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]" - ) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") + raise typer.Exit(1) - # Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path - if use_integration: + # Deprecation warnings for --ai-skills and --ai-commands-dir (only when + # an integration has been resolved from --ai or --integration) + if ai_assistant or integration: if ai_skills: from .integrations.base import SkillsIntegration as _SkillsCheck if isinstance(resolved_integration, _SkillsCheck): console.print( - "[dim]Note: --ai-skills is not needed with --integration; " + "[dim]Note: --ai-skills is not needed; " "skills are the default for this integration.[/dim]" ) else: console.print( - "[dim]Note: --ai-skills has no effect with --integration " + "[dim]Note: --ai-skills has no effect with " f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" ) if ai_commands_dir and resolved_integration.key != "generic": @@ -2101,27 +1029,11 @@ def init( ) # Auto-promote interactively selected agents to the integration path - # when a matching integration is registered (same behavior as --ai). - if not use_integration: - from .integrations import get_integration as _get_int - _resolved = _get_int(selected_ai) - if _resolved: - use_integration = True - resolved_integration = _resolved - - # Agents that have moved from explicit commands/prompts to agent skills. - # Skip this check when using the integration path — skills are the default. - if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: - # If selected interactively (no --ai provided), automatically enable - # ai_skills so the agent remains usable without requiring an extra flag. - # Preserve fail-fast behavior only for explicit '--ai ' without skills. - migration = AGENT_SKILLS_MIGRATIONS[selected_ai] - if ai_assistant and not migration.get("auto_enable_explicit", False): - _handle_agent_skills_migration(console, selected_ai) - else: - ai_skills = True - note_key = "explicit_note" if ai_assistant else "interactive_note" - console.print(f"\n[yellow]Note:[/yellow] {migration[note_key]}") + if not ai_assistant: + resolved_integration = get_integration(selected_ai) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") + raise typer.Exit(1) # Validate --ai-commands-dir usage. # Skip validation when --integration-options is provided — the integration @@ -2129,15 +1041,8 @@ def init( if selected_ai == "generic" and not integration_options: if not ai_commands_dir: console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") - console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]") + console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') raise typer.Exit(1) - elif ai_commands_dir and not use_integration: - console.print( - f"[red]Error:[/red] --ai-commands-dir can only be used with the " - f"'generic' integration via --ai generic or --integration generic " - f"(not '{selected_ai}')" - ) - raise typer.Exit(1) current_dir = Path.cwd() @@ -2204,49 +1109,14 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - # Determine whether to use bundled assets or download from GitHub (default). - # --offline opts in to bundled assets; without it, always use GitHub. - # When --offline is set, scaffold_from_core_pack() will try the wheel's - # core_pack/ first, then fall back to source-checkout paths. If neither - # location has the required assets it returns False and we error out. - _core = _locate_core_pack() - - use_github = not offline - - if use_github and _core is not None: - console.print( - "[yellow]Note:[/yellow] Bundled assets are available in this install. " - "Use [bold]--offline[/bold] to skip the GitHub download — faster, " - "no network required, and guaranteed version match.\n" - "This will become the default in v0.6.0." - ) - - if use_integration: - tracker.add("integration", "Install integration") - tracker.add("shared-infra", "Install shared infrastructure") - elif use_github: - for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), - ]: - tracker.add(key, label) - else: - tracker.add("scaffold", "Apply bundled assets") + tracker.add("integration", "Install integration") + tracker.add("shared-infra", "Install shared infrastructure") for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ]: - tracker.add(key, label) - if ai_skills: - tracker.add("ai-skills", "Install agent skills") - for key, label in [ - ("cleanup", "Cleanup"), ("git", "Initialize git repository"), - ("final", "Finalize") + ("final", "Finalize"), ]: tracker.add(key, label) @@ -2256,170 +1126,53 @@ def init( with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - - if use_integration: - # Integration-based scaffolding (new path) - from .integrations.manifest import IntegrationManifest - tracker.start("integration") - manifest = IntegrationManifest( - resolved_integration.key, project_path, version=get_speckit_version() - ) + # Integration-based scaffolding + from .integrations.manifest import IntegrationManifest + tracker.start("integration") + manifest = IntegrationManifest( + resolved_integration.key, project_path, version=get_speckit_version() + ) - # Forward all legacy CLI flags to the integration as parsed_options. - # Integrations receive every option and decide what to use; - # irrelevant keys are simply ignored by the integration's setup(). - integration_parsed_options: dict[str, Any] = {} - if ai_commands_dir: - integration_parsed_options["commands_dir"] = ai_commands_dir - if ai_skills: - integration_parsed_options["skills"] = True - - resolved_integration.setup( - project_path, manifest, - parsed_options=integration_parsed_options or None, - script_type=selected_script, - raw_options=integration_options, - ) - manifest.save() - - # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" - integration_json = project_path / ".specify" / "integration.json" - integration_json.parent.mkdir(parents=True, exist_ok=True) - integration_json.write_text(json.dumps({ - "integration": resolved_integration.key, - "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, - }, indent=2) + "\n", encoding="utf-8") - - tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) - - # Install shared infrastructure (scripts, templates) - tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker) - tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - - elif use_github: - with httpx.Client(verify=local_ssl_context) as local_client: - download_and_extract_template( - project_path, - selected_ai, - selected_script, - here, - skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills), - verbose=False, - tracker=tracker, - client=local_client, - debug=debug, - github_token=github_token, - ) - else: - scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) - if not scaffold_ok: - # --offline explicitly requested: never attempt a network download - console.print( - "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" - "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" - "Remove --offline to attempt a GitHub download instead." - ) - # Surface the specific failure reason from the tracker - for step in tracker.steps: - if step["key"] == "scaffold" and step["detail"]: - console.print(f"[red]Detail:[/red] {step['detail']}") - break - # Clean up partial project directory (same as the GitHub-download failure path) - if not here and project_path.exists(): - shutil.rmtree(project_path) - raise typer.Exit(1) - # For generic agent, rename placeholder directory to user-specified path - if not use_integration and selected_ai == "generic" and ai_commands_dir: - placeholder_dir = project_path / ".speckit" / "commands" - target_dir = project_path / ai_commands_dir - if placeholder_dir.is_dir(): - target_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(placeholder_dir), str(target_dir)) - # Clean up empty .speckit dir if it's now empty - speckit_dir = project_path / ".speckit" - if speckit_dir.is_dir() and not any(speckit_dir.iterdir()): - speckit_dir.rmdir() + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True + + resolved_integration.setup( + project_path, manifest, + parsed_options=integration_parsed_options or None, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + + # Write .specify/integration.json + script_ext = "sh" if selected_script == "sh" else "ps1" + integration_json = project_path / ".specify" / "integration.json" + integration_json.parent.mkdir(parents=True, exist_ok=True) + integration_json.write_text(json.dumps({ + "integration": resolved_integration.key, + "version": get_speckit_version(), + "scripts": { + "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", + }, + }, indent=2) + "\n", encoding="utf-8") + + tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) + + # Install shared infrastructure (scripts, templates) + tracker.start("shared-infra") + _install_shared_infra(project_path, selected_script, tracker=tracker) + tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_executable_scripts(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker) - # Determine skills directory and migrate any legacy Kimi dotted skills. - # (Legacy path only — integration path handles skills in setup().) - migrated_legacy_kimi_skills = 0 - removed_legacy_kimi_skills = 0 - skills_dir: Optional[Path] = None - if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS: - skills_dir = _get_skills_dir(project_path, selected_ai) - if selected_ai == "kimi" and skills_dir.is_dir(): - ( - migrated_legacy_kimi_skills, - removed_legacy_kimi_skills, - ) = _migrate_legacy_kimi_dotted_skills(skills_dir) - - if not use_integration and ai_skills: - if selected_ai in NATIVE_SKILLS_AGENTS: - bundled_found = _has_bundled_skills(project_path, selected_ai) - if bundled_found: - detail = f"bundled skills → {skills_dir.relative_to(project_path)}" - if migrated_legacy_kimi_skills or removed_legacy_kimi_skills: - detail += ( - f" (migrated {migrated_legacy_kimi_skills}, " - f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)" - ) - if tracker: - tracker.start("ai-skills") - tracker.complete("ai-skills", detail) - else: - console.print(f"[green]✓[/green] Using {detail}") - else: - # Compatibility fallback: convert command templates to skills - # when an older template archive does not include native skills. - # This keeps `specify init --here --ai codex --ai-skills` usable - # in repos that already contain unrelated skills under .agents/skills. - fallback_ok = install_ai_skills( - project_path, - selected_ai, - tracker=tracker, - overwrite_existing=True, - ) - if not fallback_ok: - raise RuntimeError( - f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, " - "but none were found and fallback conversion failed. " - "Re-run with an up-to-date template." - ) - else: - skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) - - # When --ai-skills is used on a NEW project and skills were - # successfully installed, remove the command files that the - # template archive just created. Skills replace commands, so - # keeping both would be confusing. For --here on an existing - # repo we leave pre-existing commands untouched to avoid a - # breaking change. We only delete AFTER skills succeed so the - # project always has at least one of {commands, skills}. - if skills_ok and not here: - agent_cfg = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - if cmds_dir.exists(): - try: - shutil.rmtree(cmds_dir) - except OSError: - # Best-effort cleanup: skills are already installed, - # so leaving stale commands is non-fatal. - console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") - if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -2441,22 +1194,18 @@ def init( # Must be saved BEFORE preset install so _get_skills_dir() works. init_opts = { "ai": selected_ai, - "ai_skills": ai_skills, - "ai_commands_dir": ai_commands_dir, + "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, - "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), } - if use_integration: - init_opts["integration"] = resolved_integration.key - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): - init_opts["ai_skills"] = True + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist): + init_opts["ai_skills"] = True save_init_options(project_path, init_opts) # Install preset if specified @@ -2490,10 +1239,6 @@ def init( except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") - # Scaffold path has no zip archive to clean up - if not use_github: - tracker.skip("cleanup", "not needed (no download)") - tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise @@ -2559,12 +1304,9 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, claude, kimi, agy) should show skill - # invocation syntax regardless of whether --ai-skills was explicitly passed. - _is_skills_integration = False - if use_integration: - from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + # Skills integrations (codex, kimi, agy) should show skill invocation syntax. + from .integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) @@ -4414,6 +3156,7 @@ def extension_update( shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) # 3. Backup command files for all agents + from .agents import CommandRegistrar as _AgentReg registered_commands = backup_registry_entry.get("registered_commands", {}) for agent_name, cmd_names in registered_commands.items(): if agent_name not in registrar.AGENT_CONFIGS: @@ -4422,7 +3165,8 @@ def extension_update( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" if cmd_file.exists(): backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) @@ -4576,7 +3320,8 @@ def extension_update( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" # Delete if it exists and wasn't in our backup if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: cmd_file.unlink() diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 50c01a22dc..386fa5df44 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,18 @@ 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. @@ -23,159 +35,26 @@ class CommandRegistrar: and companion files (e.g. Copilot .prompt.md). """ - # 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-agent": { - "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": ".agents/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - }, - "windsurf": { - "dir": ".windsurf/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "junie": { - "dir": ".junie/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kilocode": { - "dir": ".kilocode/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "auggie": { - "dir": ".augment/commands", - "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" - }, - "pi": { - "dir": ".pi/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", - }, - "trae": { - "dir": ".trae/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "iflow": { - "dir": ".iflow/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "vibe": { - "dir": ".vibe/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "agy": { - "dir": ".agent/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.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]: @@ -506,6 +385,7 @@ def register_commands( 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}") @@ -605,6 +485,7 @@ def register_commands_for_all_agents( """ results = {} + self._ensure_configs() for agent_name, agent_config in self.AGENT_CONFIGS.items(): agent_dir = project_root / agent_config["dir"] @@ -632,6 +513,7 @@ def unregister_commands( 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 @@ -649,3 +531,13 @@ def unregister_commands( 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 + diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index f192c876cd..4825720074 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -7,7 +7,6 @@ import yaml -from ...agents import CommandRegistrar from ..base import SkillsIntegration from ..manifest import IntegrationManifest @@ -43,15 +42,18 @@ def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: s "description", f"Spec-kit workflow command: {template_name}", ) - skill_frontmatter = CommandRegistrar.build_skill_frontmatter( - self.key, - skill_name, - description, - f"templates/commands/{template_name}.md", + skill_frontmatter = self._build_skill_fm( + skill_name, description, f"templates/commands/{template_name}.md" ) frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + def _build_skill_fm(self, name: str, description: str, source: str) -> dict: + from specify_cli.agents import CommandRegistrar + return CommandRegistrar.build_skill_frontmatter( + self.key, name, description, source + ) + def setup( self, project_root: Path, @@ -83,6 +85,7 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + from specify_cli.agents import CommandRegistrar registrar = CommandRegistrar() created: list[Path] = [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 2609571928..945ce6ac62 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -73,7 +73,6 @@ def test_ai_copilot_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "--integration copilot" in result.output assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): @@ -82,9 +81,11 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): project = tmp_path / "claude-here-existing" project.mkdir() - commands_dir = project / ".claude" / "commands" + commands_dir = project / ".claude" / "skills" commands_dir.mkdir(parents=True) - command_file = commands_dir / "speckit.specify.md" + skill_dir = commands_dir / "speckit-specify" + skill_dir.mkdir(parents=True) + command_file = skill_dir / "SKILL.md" command_file.write_text("# preexisting command\n", encoding="utf-8") old_cwd = os.getcwd() @@ -98,9 +99,10 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0, result.output - assert "--integration claude" in result.output assert command_file.exists() - assert command_file.read_text(encoding="utf-8") == "# preexisting command\n" + # init replaces skills (not additive); verify the file has valid skill content + assert command_file.exists() + assert "speckit-specify" in command_file.read_text(encoding="utf-8") assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() def test_shared_infra_skips_existing_files(self, tmp_path): diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 3efaa99362..21cb1d832e 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -15,11 +15,13 @@ class TestAgyAutoPromote: """--ai agy auto-promotes to integration path.""" def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): - """--ai agy (without --ai-skills) should auto-promote to integration.""" + """--ai agy should work the same as --integration agy.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"]) + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) - assert "--integration agy" in result.output + assert result.exit_code == 0, f"init --ai agy failed: {result.output}" + assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 75319eb944..e274b52242 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -176,7 +176,9 @@ def test_ai_flag_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 23505c3062..007386611c 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -261,7 +261,9 @@ def test_ai_flag_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index e7b506782f..8b0935290a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -226,7 +226,9 @@ def test_ai_flag_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8f8a6b05d1..998485469f 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -102,10 +102,6 @@ def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0, result.output - assert "--integration claude" in result.output - assert ".claude/skills" in result.output - assert "/speckit-plan" in result.output - assert "/speckit.plan" not in result.output assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert not (project / ".claude" / "commands").exists() @@ -189,25 +185,20 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): assert init_options["integration"] == "claude" def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): + """Claude init should succeed even without install_ai_skills.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() target = tmp_path / "fail-proj" - with patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], - ) + result = runner.invoke( + app, + ["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"], + ) assert result.exit_code == 0 assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() - assert not (target / ".claude" / "commands").exists() def test_claude_hooks_render_skill_invocation(self, tmp_path): from specify_cli.extensions import HookExecutor diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index eb633f02ba..cc15d27cb5 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -15,11 +15,13 @@ class TestCodexAutoPromote: """--ai codex auto-promotes to integration path.""" def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path): - """--ai codex (without --ai-skills) should auto-promote to integration.""" + """--ai codex should work the same as --integration codex.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"]) + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"]) - assert "--integration codex" in result.output + assert result.exit_code == 0, f"init --ai codex failed: {result.output}" + assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index 6b2b27b777..e3b260bf05 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -36,5 +36,4 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 - assert "--integration kiro-cli" in result.output assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index fe5c01cf75..8e293baa17 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,4 +1,4 @@ -"""Consistency checks for agent configuration across runtime and packaging scripts.""" +"""Consistency checks for agent configuration across runtime surfaces.""" import re from pathlib import Path @@ -41,52 +41,6 @@ def test_runtime_codex_uses_native_skills(self): assert AGENT_CONFIG["codex"]["folder"] == ".agents/" assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" - def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): - """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "kiro-cli" in sh_agents - assert "kiro-cli" in ps_agents - assert "shai" in sh_agents - assert "shai" in ps_agents - assert "agy" in sh_agents - assert "agy" in ps_agents - assert "q" not in sh_agents - assert "q" not in ps_agents - - def test_release_ps_switch_has_shai_and_agy_generation(self): - """PowerShell release builder must generate files for shai and agy agents.""" - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None - assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None - - def test_release_sh_switch_has_shai_and_agy_generation(self): - """Bash release builder must generate files for shai and agy agents.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - - assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None - assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None - - def test_release_scripts_generate_codex_skills(self): - """Release scripts should generate Codex skills in .agents/skills.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".agents/skills" in sh_text - assert ".agents/skills" in ps_text - assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None - assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None - def test_init_ai_help_includes_roo_and_kiro_alias(self): """CLI help text for --ai should stay in sync with agent config and alias guidance.""" assert "roo" in AI_ASSISTANT_HELP @@ -102,22 +56,6 @@ def test_devcontainer_kiro_installer_uses_pinned_checksum(self): assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text - def test_release_output_targets_kiro_prompt_dir(self): - """Packaging and release scripts should no longer emit amazonq artifacts.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert ".kiro/prompts" in sh_text - assert ".kiro/prompts" in ps_text - assert ".amazonq/prompts" not in sh_text - assert ".amazonq/prompts" not in ps_text - - assert "spec-kit-template-kiro-cli-sh-" in gh_release_text - assert "spec-kit-template-kiro-cli-ps-" in gh_release_text - assert "spec-kit-template-q-sh-" not in gh_release_text - assert "spec-kit-template-q-ps-" not in gh_release_text - def test_agent_context_scripts_use_kiro_cli(self): """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -149,38 +87,6 @@ def test_extension_registrar_includes_tabnine(self): assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_release_agent_lists_include_tabnine(self): - """Bash and PowerShell release scripts should include tabnine in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "tabnine" in sh_agents - assert "tabnine" in ps_agents - - def test_release_scripts_generate_tabnine_toml_commands(self): - """Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".tabnine/agent/commands" in sh_text - assert ".tabnine/agent/commands" in ps_text - assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None - - def test_github_release_includes_tabnine_packages(self): - """GitHub release script should include tabnine template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-tabnine-sh-" in gh_release_text - assert "spec-kit-template-tabnine-ps-" in gh_release_text - def test_agent_context_scripts_include_tabnine(self): """Agent context scripts should support tabnine agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -213,22 +119,6 @@ def test_kimi_in_extension_registrar(self): assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include kimi in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "kimi" in sh_agents - assert "kimi" in ps_agents - def test_kimi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") @@ -239,13 +129,6 @@ def test_kimi_in_powershell_validate_set(self): assert "kimi" in validate_set_values - def test_kimi_in_github_release_output(self): - """GitHub release script should include kimi template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-kimi-sh-" in gh_release_text - assert "spec-kit-template-kimi-ps-" in gh_release_text - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -270,38 +153,6 @@ def test_trae_in_extension_registrar(self): assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == ".md" - def test_trae_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include trae in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "trae" in sh_agents - assert "trae" in ps_agents - - def test_trae_in_release_scripts_generate_commands(self): - """Release scripts should generate markdown commands for trae in .trae/rules.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".trae/rules" in sh_text - assert ".trae/rules" in ps_text - assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None - - def test_trae_in_github_release_output(self): - """GitHub release script should include trae template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-trae-sh-" in gh_release_text - assert "spec-kit-template-trae-ps-" in gh_release_text - def test_trae_in_agent_context_scripts(self): """Agent context scripts should support trae agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -347,32 +198,6 @@ def test_pi_in_extension_registrar(self): assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include pi in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "pi" in sh_agents - assert "pi" in ps_agents - - def test_release_scripts_generate_pi_prompt_templates(self): - """Release scripts should generate Markdown prompt templates for pi in .pi/prompts.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".pi/prompts" in sh_text - assert ".pi/prompts" in ps_text - assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None - assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None - def test_pi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") @@ -383,13 +208,6 @@ def test_pi_in_powershell_validate_set(self): assert "pi" in validate_set_values - def test_pi_in_github_release_output(self): - """GitHub release script should include pi template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-pi-sh-" in gh_release_text - assert "spec-kit-template-pi-ps-" in gh_release_text - def test_agent_context_scripts_include_pi(self): """Agent context scripts should support pi agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -422,38 +240,6 @@ def test_iflow_in_extension_registrar(self): assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include iflow in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "iflow" in sh_agents - assert "iflow" in ps_agents - - def test_iflow_in_release_scripts_build_variant(self): - """Release scripts should generate Markdown commands for iflow in .iflow/commands.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".iflow/commands" in sh_text - assert ".iflow/commands" in ps_text - assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None - - def test_iflow_in_github_release_output(self): - """GitHub release script should include iflow template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-iflow-sh-" in gh_release_text - assert "spec-kit-template-iflow-ps-" in gh_release_text - def test_iflow_in_agent_context_scripts(self): """Agent context scripts should support iflow agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py deleted file mode 100644 index e43129c9cf..0000000000 --- a/tests/test_ai_skills.py +++ /dev/null @@ -1,994 +0,0 @@ -""" -Unit tests for AI agent skills installation. - -Tests cover: -- Skills directory resolution for different agents (_get_skills_dir) -- YAML frontmatter parsing and SKILL.md generation (install_ai_skills) -- Cleanup of duplicate command files when --ai-skills is used -- Missing templates directory handling -- Malformed template error handling -- CLI validation: --ai-skills requires --ai -""" - -import zipfile -import pytest -import tempfile -import shutil -import yaml -import typer -from pathlib import Path -from unittest.mock import patch - -import specify_cli -from tests.conftest import strip_ansi - -from specify_cli import ( - _get_skills_dir, - _migrate_legacy_kimi_dotted_skills, - install_ai_skills, - DEFAULT_SKILLS_DIR, - SKILL_DESCRIPTIONS, - AGENT_CONFIG, - app, -) - - -# ===== Fixtures ===== - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for tests.""" - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) - shutil.rmtree(tmpdir) - - -@pytest.fixture -def project_dir(temp_dir): - """Create a mock project directory.""" - proj_dir = temp_dir / "test-project" - proj_dir.mkdir() - return proj_dir - - -@pytest.fixture -def templates_dir(project_dir): - """Create mock command templates in the project's agent commands directory. - - This simulates what download_and_extract_template() does: it places - command .md files into project_path//commands/. - install_ai_skills() now reads from here instead of from the repo - source tree. - """ - tpl_root = project_dir / ".claude" / "commands" - tpl_root.mkdir(parents=True, exist_ok=True) - - # Template with valid YAML frontmatter - (tpl_root / "speckit.specify.md").write_text( - "---\n" - "description: Create or update the feature specification.\n" - "handoffs:\n" - " - label: Build Plan\n" - " agent: speckit.plan\n" - "scripts:\n" - " sh: scripts/bash/create-new-feature.sh\n" - "---\n" - "\n" - "# Specify Command\n" - "\n" - "Run this to create a spec.\n", - encoding="utf-8", - ) - - # Template with minimal frontmatter - (tpl_root / "speckit.plan.md").write_text( - "---\n" - "description: Generate implementation plan.\n" - "---\n" - "\n" - "# Plan Command\n" - "\n" - "Plan body content.\n", - encoding="utf-8", - ) - - # Template with no frontmatter - (tpl_root / "speckit.tasks.md").write_text( - "# Tasks Command\n" - "\n" - "Body without frontmatter.\n", - encoding="utf-8", - ) - - # Template with empty YAML frontmatter (yaml.safe_load returns None) - (tpl_root / "speckit.empty_fm.md").write_text( - "---\n" - "---\n" - "\n" - "# Empty Frontmatter Command\n" - "\n" - "Body with empty frontmatter.\n", - encoding="utf-8", - ) - - return tpl_root - - -@pytest.fixture -def commands_dir_claude(project_dir): - """Create a populated .claude/commands directory simulating template extraction.""" - cmd_dir = project_dir / ".claude" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: - (cmd_dir / name).write_text(f"# {name}\nContent here\n") - return cmd_dir - - -@pytest.fixture -def commands_dir_gemini(project_dir): - """Create a populated .gemini/commands directory (TOML format).""" - cmd_dir = project_dir / ".gemini" / "commands" - cmd_dir.mkdir(parents=True) - for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]: - (cmd_dir / name).write_text(f'[command]\nname = "{name}"\n') - return cmd_dir - - -@pytest.fixture -def commands_dir_qwen(project_dir): - """Create a populated .qwen/commands directory (Markdown format).""" - cmd_dir = project_dir / ".qwen" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: - (cmd_dir / name).write_text(f"# {name}\nContent here\n") - return cmd_dir - - -# ===== _get_skills_dir Tests ===== - -class TestGetSkillsDir: - """Test the _get_skills_dir() helper function.""" - - def test_claude_skills_dir(self, project_dir): - """Claude should use .claude/skills/.""" - result = _get_skills_dir(project_dir, "claude") - assert result == project_dir / ".claude" / "skills" - - def test_gemini_skills_dir(self, project_dir): - """Gemini should use .gemini/skills/.""" - result = _get_skills_dir(project_dir, "gemini") - assert result == project_dir / ".gemini" / "skills" - - def test_tabnine_skills_dir(self, project_dir): - """Tabnine should use .tabnine/agent/skills/.""" - result = _get_skills_dir(project_dir, "tabnine") - assert result == project_dir / ".tabnine" / "agent" / "skills" - - def test_copilot_skills_dir(self, project_dir): - """Copilot should use .github/skills/.""" - result = _get_skills_dir(project_dir, "copilot") - assert result == project_dir / ".github" / "skills" - - def test_codex_skills_dir_from_agent_config(self, project_dir): - """Codex should resolve skills directory from AGENT_CONFIG folder.""" - result = _get_skills_dir(project_dir, "codex") - assert result == project_dir / ".agents" / "skills" - - def test_cursor_agent_skills_dir(self, project_dir): - """Cursor should use .cursor/skills/.""" - result = _get_skills_dir(project_dir, "cursor-agent") - assert result == project_dir / ".cursor" / "skills" - - def test_kiro_cli_skills_dir(self, project_dir): - """Kiro CLI should use .kiro/skills/.""" - result = _get_skills_dir(project_dir, "kiro-cli") - assert result == project_dir / ".kiro" / "skills" - - def test_pi_skills_dir(self, project_dir): - """Pi should use .pi/skills/.""" - result = _get_skills_dir(project_dir, "pi") - assert result == project_dir / ".pi" / "skills" - - def test_unknown_agent_uses_default(self, project_dir): - """Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" - result = _get_skills_dir(project_dir, "nonexistent-agent") - assert result == project_dir / DEFAULT_SKILLS_DIR - - def test_all_configured_agents_resolve(self, project_dir): - """Every agent in AGENT_CONFIG should resolve to a valid path.""" - for agent_key in AGENT_CONFIG: - result = _get_skills_dir(project_dir, agent_key) - assert result is not None - assert str(result).startswith(str(project_dir)) - # Should always end with "skills" - assert result.name == "skills" - -class TestKimiLegacySkillMigration: - """Test temporary migration from Kimi dotted skill names to hyphenated names.""" - - def test_migrates_legacy_dotted_skill_directory(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 1 - assert removed == 0 - assert not legacy_dir.exists() - assert (skills_dir / "speckit-plan" / "SKILL.md").exists() - - def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 1 - assert not legacy_dir.exists() - assert (target_dir / "SKILL.md").read_text() == "legacy" - - def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("new") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 0 - assert legacy_dir.exists() - assert (legacy_dir / "SKILL.md").read_text() == "legacy" - assert (target_dir / "SKILL.md").read_text() == "new" - - def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - (legacy_dir / "notes.txt").write_text("custom") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 0 - assert legacy_dir.exists() - assert (legacy_dir / "notes.txt").read_text() == "custom" - - -# ===== install_ai_skills Tests ===== - -class TestInstallAiSkills: - """Test SKILL.md generation and installation logic.""" - - def test_skills_installed_with_correct_structure(self, project_dir, templates_dir): - """Verify SKILL.md files have correct agentskills.io structure.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - - skills_dir = project_dir / ".claude" / "skills" - assert skills_dir.exists() - - # Check that skill directories were created - skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) - assert "speckit-plan" in skill_dirs - assert "speckit-specify" in skill_dirs - assert "speckit-tasks" in skill_dirs - assert "speckit-empty_fm" in skill_dirs - - # Verify SKILL.md content for speckit-specify - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Check agentskills.io frontmatter - assert content.startswith("---\n") - assert "name: speckit-specify" in content - assert "description:" in content - assert "compatibility:" in content - assert "metadata:" in content - assert "author: github-spec-kit" in content - assert "source: templates/commands/specify.md" in content - - # Check body content is included - assert "# Speckit Specify Skill" in content - assert "Run this to create a spec." in content - - def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir): - """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - - # Extract and parse frontmatter - assert content.startswith("---\n") - parts = content.split("---", 2) - assert len(parts) >= 3 - parsed = yaml.safe_load(parts[1]) - assert isinstance(parsed, dict) - assert "name" in parsed - assert parsed["name"] == "speckit-specify" - assert "description" in parsed - - def test_empty_yaml_frontmatter(self, project_dir, templates_dir): - """Templates with empty YAML frontmatter (---\\n---) should not crash.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - - skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "name: speckit-empty_fm" in content - assert "Body with empty frontmatter." in content - - def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir): - """SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - - # Parse the generated YAML to compare the description value - # (yaml.safe_dump may wrap long strings across multiple lines) - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - - if "specify" in SKILL_DESCRIPTIONS: - assert parsed["description"] == SKILL_DESCRIPTIONS["specify"] - - def test_template_without_frontmatter(self, project_dir, templates_dir): - """Templates without YAML frontmatter should still produce valid skills.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Should still have valid SKILL.md structure - assert "name: speckit-tasks" in content - assert "Body without frontmatter." in content - - def test_missing_templates_directory(self, project_dir): - """Returns False when no command templates exist anywhere.""" - # No .claude/commands/ exists, and __file__ fallback won't find anything - fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - result = install_ai_skills(project_dir, "claude") - - assert result is False - - # Skills directory should not exist - skills_dir = project_dir / ".claude" / "skills" - assert not skills_dir.exists() - - def test_empty_templates_directory(self, project_dir): - """Returns False when commands directory has no .md files.""" - # Create empty .claude/commands/ - empty_cmds = project_dir / ".claude" / "commands" - empty_cmds.mkdir(parents=True) - - # Block the __file__ fallback so it can't find real templates - fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - result = install_ai_skills(project_dir, "claude") - - assert result is False - - def test_malformed_yaml_frontmatter(self, project_dir): - """Malformed YAML in a template should be handled gracefully, not crash.""" - # Create .claude/commands/ with a broken template - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "speckit.broken.md").write_text( - "---\n" - "description: [unclosed bracket\n" - " invalid: yaml: content: here\n" - "---\n" - "\n" - "# Broken\n", - encoding="utf-8", - ) - - # Should not raise — errors are caught per-file - result = install_ai_skills(project_dir, "claude") - - # The broken template should be skipped but not crash the process - assert result is False - - def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir): - """Installing skills should not remove non-speckit files in the skills dir.""" - # Pre-create a custom skill - custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill" - custom_dir.mkdir(parents=True) - custom_file = custom_dir / "SKILL.md" - custom_file.write_text("# My Custom Skill\n") - - install_ai_skills(project_dir, "claude") - - # Custom skill should still exist - assert custom_file.exists() - assert custom_file.read_text() == "# My Custom Skill\n" - - def test_return_value(self, project_dir, templates_dir): - """install_ai_skills returns True when skills installed, False otherwise.""" - assert install_ai_skills(project_dir, "claude") is True - - def test_return_false_when_no_templates(self, project_dir): - """install_ai_skills returns False when no templates found.""" - fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - assert install_ai_skills(project_dir, "claude") is False - - def test_non_md_commands_dir_falls_back(self, project_dir): - """When extracted commands are .toml (e.g. gemini), fall back to repo templates.""" - # Simulate gemini template extraction: .gemini/commands/ with .toml files only - cmds_dir = project_dir / ".gemini" / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n') - (cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n') - - # The __file__ fallback should find the real repo templates/commands/*.md - result = install_ai_skills(project_dir, "gemini") - - assert result is True - skills_dir = project_dir / ".gemini" / "skills" - assert skills_dir.exists() - # Should have installed skills from the fallback .md templates - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - # .toml commands should be untouched - assert (cmds_dir / "speckit.specify.toml").exists() - - def test_qwen_md_commands_dir_installs_skills(self, project_dir): - """Qwen now uses Markdown format; skills should install directly from .qwen/commands/.""" - cmds_dir = project_dir / ".qwen" / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text( - "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" - ) - (cmds_dir / "speckit.plan.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "qwen") - - assert result is True - skills_dir = project_dir / ".qwen" / "skills" - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - # .md commands should be untouched - assert (cmds_dir / "speckit.specify.md").exists() - assert (cmds_dir / "speckit.plan.md").exists() - - def test_pi_prompt_dir_installs_skills(self, project_dir): - """Pi should install skills directly from .pi/prompts/.""" - prompts_dir = project_dir / ".pi" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "speckit.specify.md").write_text( - "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" - ) - (prompts_dir / "speckit.plan.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "pi") - - assert result is True - skills_dir = project_dir / ".pi" / "skills" - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - assert (prompts_dir / "speckit.specify.md").exists() - assert (prompts_dir / "speckit.plan.md").exists() - - @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) - def test_skills_install_for_all_agents(self, temp_dir, agent_key): - """install_ai_skills should produce skills for every configured agent.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - # Place .md templates in the agent's commands directory - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - # Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md - fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md" - (cmds_dir / fname).write_text( - "---\ndescription: Test command\n---\n\n# Test\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - expected_skill_name = "speckit-specify" - assert expected_skill_name in skill_dirs - assert (skills_dir / expected_skill_name / "SKILL.md").exists() - - def test_copilot_ignores_non_speckit_agents(self, project_dir): - """Non-speckit markdown in .github/agents/ must not produce skills.""" - agents_dir = project_dir / ".github" / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - (agents_dir / "speckit.plan.agent.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - (agents_dir / "my-custom-agent.agent.md").write_text( - "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "copilot") - - assert result is True - skills_dir = _get_skills_dir(project_dir, "copilot") - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert "speckit-plan" in skill_dirs - assert "speckit-my-custom-agent.agent" not in skill_dirs - assert "speckit-my-custom-agent" not in skill_dirs - - @pytest.mark.parametrize("agent_key,custom_file", [ - ("claude", "review.md"), - ("cursor-agent", "deploy.md"), - ("qwen", "my-workflow.md"), - ]) - def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file): - """User-authored command files must not produce skills for any agent.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text( - "---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n" - ) - (cmds_dir / custom_file).write_text( - "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert "speckit-specify" in skill_dirs - custom_stem = Path(custom_file).stem - assert f"speckit-{custom_stem}" not in skill_dirs - - def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir): - """Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files.""" - agents_dir = project_dir / ".github" / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - # Only a user-authored agent, no speckit.* templates - (agents_dir / "my-custom-agent.agent.md").write_text( - "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "copilot") - - # Should succeed via fallback to templates/commands/ - assert result is True - skills_dir = _get_skills_dir(project_dir, "copilot") - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # Should have skills from fallback templates, not from the custom agent - assert "speckit-plan" in skill_dirs - assert not any("my-custom" in d for d in skill_dirs) - - @pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"]) - def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key): - """Fallback to templates/commands/ when agent dir has no speckit.*.md files.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - # Only a user-authored command, no speckit.* templates - (cmds_dir / "my-custom-command.md").write_text( - "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - # Should succeed via fallback to templates/commands/ - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert not any("my-custom" in d for d in skill_dirs) - -class TestCommandCoexistence: - """Verify install_ai_skills never touches command files. - - Cleanup of freshly-extracted commands for NEW projects is handled - in init(), not in install_ai_skills(). These tests confirm that - install_ai_skills leaves existing commands intact. - """ - - def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): - """install_ai_skills must NOT remove pre-existing .claude/commands files.""" - # Verify commands exist before (templates_dir adds 4 speckit.* files, - # commands_dir_claude overlaps with 3 of them) - before = list(commands_dir_claude.glob("speckit.*")) - assert len(before) >= 3 - - install_ai_skills(project_dir, "claude") - - # Commands must still be there — install_ai_skills never touches them - remaining = list(commands_dir_claude.glob("speckit.*")) - assert len(remaining) == len(before) - - def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini): - """install_ai_skills must NOT remove pre-existing .gemini/commands files.""" - assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3 - - install_ai_skills(project_dir, "gemini") - - remaining = list(commands_dir_gemini.glob("speckit.*")) - assert len(remaining) == 3 - - def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen): - """install_ai_skills must NOT remove pre-existing .qwen/commands files.""" - assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3 - - install_ai_skills(project_dir, "qwen") - - remaining = list(commands_dir_qwen.glob("speckit.*")) - assert len(remaining) == 3 - - def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): - """install_ai_skills must not remove the commands directory.""" - install_ai_skills(project_dir, "claude") - - assert commands_dir_claude.exists() - - def test_no_commands_dir_no_error(self, project_dir, templates_dir): - """No error when installing skills — commands dir has templates and is preserved.""" - result = install_ai_skills(project_dir, "claude") - - # Should succeed since templates are in .claude/commands/ via fixture - assert result is True - - -# ===== Legacy Download Path Tests ===== - -class TestLegacyDownloadPath: - """Tests for download_and_extract_template() called directly. - - These test the legacy download/extract code that still exists in - __init__.py. They do NOT go through CLI auto-promote. - """ - - def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path): - """Fresh-directory Codex skills init should not leave legacy .codex from archive.""" - target = tmp_path / "fresh-codex-proj" - archive = tmp_path / "codex-template.zip" - - with zipfile.ZipFile(archive, "w") as zf: - zf.writestr("template-root/.codex/prompts/speckit.specify.md", "legacy") - zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") - - fake_meta = { - "filename": archive.name, - "size": archive.stat().st_size, - "release": "vtest", - "asset_url": "https://example.invalid/template.zip", - } - - with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): - specify_cli.download_and_extract_template( - target, - "codex", - "sh", - is_current_dir=False, - skip_legacy_codex_prompts=True, - verbose=False, - ) - - assert target.exists() - assert (target / ".specify").exists() - assert not (target / ".codex").exists() - - @pytest.mark.parametrize("is_current_dir", [False, True]) - def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir): - """Extraction should reject ZIP members escaping the target directory.""" - target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj") - if is_current_dir: - target.mkdir() - monkeypatch.chdir(target) - - archive = tmp_path / "malicious-template.zip" - with zipfile.ZipFile(archive, "w") as zf: - zf.writestr("../evil.txt", "pwned") - zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") - - fake_meta = { - "filename": archive.name, - "size": archive.stat().st_size, - "release": "vtest", - "asset_url": "https://example.invalid/template.zip", - } - - with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): - with pytest.raises(typer.Exit): - specify_cli.download_and_extract_template( - target, - "codex", - "sh", - is_current_dir=is_current_dir, - skip_legacy_codex_prompts=True, - verbose=False, - ) - - assert not (tmp_path / "evil.txt").exists() - -# ===== Skip-If-Exists Tests ===== - -class TestSkipIfExists: - """Test that install_ai_skills does not overwrite existing SKILL.md files.""" - - def test_existing_skill_not_overwritten(self, project_dir, templates_dir): - """Pre-existing SKILL.md should not be replaced on re-run.""" - # Pre-create a custom SKILL.md for speckit-specify - skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True) - custom_content = "# My Custom Specify Skill\nUser-modified content\n" - (skill_dir / "SKILL.md").write_text(custom_content) - - result = install_ai_skills(project_dir, "claude") - - # The custom SKILL.md should be untouched - assert (skill_dir / "SKILL.md").read_text() == custom_content - - # But other skills should still be installed - assert result is True - assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists() - - def test_fresh_install_writes_all_skills(self, project_dir, templates_dir): - """On first install (no pre-existing skills), all should be written.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - skills_dir = project_dir / ".claude" / "skills" - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # All 4 templates should produce skills (specify, plan, tasks, empty_fm) - assert len(skill_dirs) == 4 - - def test_existing_skill_overwritten_when_enabled(self, project_dir, templates_dir): - """When overwrite_existing=True, pre-existing SKILL.md should be replaced.""" - skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True) - custom_content = "# My Custom Specify Skill\nUser-modified content\n" - skill_file = skill_dir / "SKILL.md" - skill_file.write_text(custom_content) - - result = install_ai_skills(project_dir, "claude", overwrite_existing=True) - - assert result is True - updated_content = skill_file.read_text() - assert updated_content != custom_content - assert "name: speckit-specify" in updated_content - - -# ===== SKILL_DESCRIPTIONS Coverage Tests ===== - -class TestSkillDescriptions: - """Test SKILL_DESCRIPTIONS constants.""" - - def test_all_known_commands_have_descriptions(self): - """All standard spec-kit commands should have enhanced descriptions.""" - expected_commands = [ - "specify", "plan", "tasks", "implement", "analyze", - "clarify", "constitution", "checklist", "taskstoissues", - ] - for cmd in expected_commands: - assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'" - assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short" - - -# ===== CLI Validation Tests ===== - -class TestCliValidation: - """Test --ai-skills CLI flag validation.""" - - def test_ai_skills_without_ai_fails(self, tmp_path): - """--ai-skills without --ai should fail with exit code 1.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) - - assert result.exit_code == 1 - assert "--ai-skills requires --ai" in result.output - - def test_ai_skills_without_ai_shows_usage(self, tmp_path): - """Error message should include usage hint.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) - - assert "Usage:" in result.output - assert "--ai" in result.output - - def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): - """Interactive selector returning agy should auto-promote to integration path.""" - from typer.testing import CliRunner - - def _fake_select_with_arrows(*args, **kwargs): - options = kwargs.get("options") - if options is None and len(args) >= 1: - options = args[0] - - if isinstance(options, dict) and "agy" in options: - return "agy" - if isinstance(options, (list, tuple)) and "agy" in options: - return "agy" - - if isinstance(options, dict) and options: - return next(iter(options.keys())) - if isinstance(options, (list, tuple)) and options: - return options[0] - - return None - - monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - runner = CliRunner() - target = tmp_path / "test-agy-interactive" - result = runner.invoke(app, ["init", str(target), "--no-git"]) - - assert result.exit_code == 0 - # Should NOT raise the old deprecation error - assert "Explicit command support was deprecated" not in result.output - # Should use integration path (same as --ai agy) - assert "agy" in result.output - - def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): - """Interactive selector returning codex should auto-promote to integration path.""" - from typer.testing import CliRunner - - def _fake_select_with_arrows(*args, **kwargs): - options = kwargs.get("options") - if options is None and len(args) >= 1: - options = args[0] - - if isinstance(options, dict) and "codex" in options: - return "codex" - if isinstance(options, (list, tuple)) and "codex" in options: - return "codex" - - if isinstance(options, dict) and options: - return next(iter(options.keys())) - if isinstance(options, (list, tuple)) and options: - return options[0] - - return None - - monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - runner = CliRunner() - target = tmp_path / "test-codex-interactive" - result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"]) - - assert result.exit_code == 0 - # Should NOT raise the old deprecation error - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output - # Skills should be installed via integration path - assert ".agents/skills" in result.output - assert "$speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output - - def test_ai_skills_flag_appears_in_help(self): - """--ai-skills should appear in init --help output.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--help"]) - - plain = strip_ansi(result.output) - assert "--ai-skills" in plain - assert "skills" in plain.lower() - - def test_q_removed_from_agent_config(self): - """Amazon Q legacy key should not remain in AGENT_CONFIG.""" - assert "q" not in AGENT_CONFIG - assert "kiro-cli" in AGENT_CONFIG - - -class TestParameterOrderingIssue: - """Test fix for GitHub issue #1641: parameter ordering issues.""" - - def test_ai_flag_consuming_here_flag(self): - """--ai without value should not consume --here flag (issue #1641).""" - from typer.testing import CliRunner - - runner = CliRunner() - # This used to fail with "Must specify project name" because --here was consumed by --ai - result = runner.invoke(app, ["init", "--ai-skills", "--ai", "--here"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai" in result.output - assert "--here" in result.output # Should mention the invalid value - - def test_ai_flag_consuming_ai_skills_flag(self): - """--ai without value should not consume --ai-skills flag.""" - from typer.testing import CliRunner - - runner = CliRunner() - # This should fail with helpful error about missing --ai value - result = runner.invoke(app, ["init", "--here", "--ai", "--ai-skills"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai" in result.output - assert "--ai-skills" in result.output # Should mention the invalid value - - def test_error_message_provides_hint(self): - """Error message should provide helpful hint about missing value.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--ai", "--here"]) - - assert result.exit_code == 1 - assert "Hint:" in result.output or "hint" in result.output.lower() - assert "forget to provide a value" in result.output.lower() - - def test_error_message_lists_available_agents(self): - """Error message should list available agents.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--ai", "--here"]) - - assert result.exit_code == 1 - # Should mention some known agents - output_lower = result.output.lower() - assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"]) - - def test_ai_commands_dir_consuming_flag(self, tmp_path): - """--ai-commands-dir without value should not consume next flag.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai-commands-dir" in result.output - assert "--here" in result.output diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 74eadf22ef..9b28082cbd 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -30,18 +30,13 @@ def test_save_branch_numbering_sequential(self, tmp_path: Path): saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) assert saved["branch_numbering"] == "sequential" - def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch): + def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - project_dir = tmp_path / "proj" runner = CliRunner() - result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 saved = json.loads((project_dir / ".specify/init-options.json").read_text()) @@ -56,34 +51,24 @@ def test_invalid_branch_numbering_rejected(self, tmp_path: Path): from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"]) assert result.exit_code == 1 assert "Invalid --branch-numbering" in result.output - def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch): + def test_valid_branch_numbering_sequential(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") - def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch): + def test_valid_branch_numbering_timestamp(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py deleted file mode 100644 index 92b747a296..0000000000 --- a/tests/test_core_pack_scaffold.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Validation tests for offline/air-gapped scaffolding (PR #1803). - -For every supported AI agent (except "generic") the scaffold output is verified -against invariants and compared byte-for-byte with the canonical output produced -by create-release-packages.sh. - -Since scaffold_from_core_pack() now invokes the release script at runtime, the -parity test (section 9) runs the script independently and compares the results -to ensure the integration is correct. - -Per-agent invariants verified -────────────────────────────── - • Command files are written to the directory declared in AGENT_CONFIG - • File count matches the number of source templates - • Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest) - • No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__) - • Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others - • Path rewrites applied: scripts/ → .specify/scripts/ etc. - • TOML files have "description" and "prompt" fields - • Markdown files have parseable YAML frontmatter - • Copilot: companion speckit.*.prompt.md files are generated in prompts/ - • .specify/scripts/ contains at least one script file - • .specify/templates/ contains at least one template file - -Parity invariant -──────────────── - Every file produced by scaffold_from_core_pack() must be byte-for-byte - identical to the same file in the ZIP produced by the release script. -""" - -import os -import re -import shutil -import subprocess -import tomllib -import zipfile -from pathlib import Path - -import pytest -import yaml - -from specify_cli import ( - AGENT_CONFIG, - _TOML_AGENTS, - _locate_core_pack, - scaffold_from_core_pack, -) - -_REPO_ROOT = Path(__file__).parent.parent -_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh" - - -def _find_bash() -> str | None: - """Return the path to a usable bash on this machine, or None.""" - # Prefer PATH lookup so non-standard install locations (Nix, CI) are found. - on_path = shutil.which("bash") - if on_path: - return on_path - candidates = [ - "/opt/homebrew/bin/bash", - "/usr/local/bin/bash", - "/bin/bash", - "/usr/bin/bash", - ] - for candidate in candidates: - try: - result = subprocess.run( - [candidate, "--version"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - return candidate - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - return None - - -def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Path) -> Path: - """Run create-release-packages.sh for *agent*/*script_type* and return the - path to the generated ZIP. *output_dir* receives the build artifacts so - the repo working tree stays clean.""" - env = os.environ.copy() - env["AGENTS"] = agent - env["SCRIPTS"] = script_type - env["GENRELEASES_DIR"] = str(output_dir) - - result = subprocess.run( - [bash, str(_RELEASE_SCRIPT), "v0.0.0"], - capture_output=True, text=True, - cwd=str(_REPO_ROOT), - env=env, - timeout=300, - ) - - if result.returncode != 0: - pytest.fail( - f"Release script failed with exit code {result.returncode}\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - - zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" - zip_path = output_dir / zip_pattern - if not zip_path.exists(): - pytest.fail( - f"Release script did not produce expected ZIP: {zip_path}\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - return zip_path - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -# Number of source command templates (one per .md file in templates/commands/) - - -def _commands_dir() -> Path: - """Return the command templates directory (source-checkout or core_pack).""" - core = _locate_core_pack() - if core and (core / "commands").is_dir(): - return core / "commands" - # Source-checkout fallback - repo_root = Path(__file__).parent.parent - return repo_root / "templates" / "commands" - - -def _get_source_template_stems() -> list[str]: - """Return the stems of source command template files (e.g. ['specify', 'plan', ...]).""" - return sorted(p.stem for p in _commands_dir().glob("*.md")) - - -def _expected_cmd_dir(project_path: Path, agent: str) -> Path: - """Return the expected command-files directory for a given agent.""" - cfg = AGENT_CONFIG[agent] - folder = (cfg.get("folder") or "").rstrip("/") - subdir = cfg.get("commands_subdir", "commands") - if folder: - return project_path / folder / subdir - return project_path / ".speckit" / subdir - - -# Agents whose commands are laid out as //SKILL.md. -# Maps agent -> separator used in skill directory names. -_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"} - - -def _expected_ext(agent: str) -> str: - if agent in _TOML_AGENTS: - return "toml" - if agent == "copilot": - return "agent.md" - if agent in _SKILL_AGENTS: - return "SKILL.md" - return "md" - - -def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: - """List generated command files, handling skills-based directory layouts.""" - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - return sorted(cmd_dir.glob(f"speckit{sep}*/SKILL.md")) - ext = _expected_ext(agent) - return sorted(cmd_dir.glob(f"speckit.*.{ext}")) - - -def _collect_relative_files(root: Path) -> dict[str, bytes]: - """Walk *root* and return {relative_posix_path: file_bytes}.""" - result: dict[str, bytes] = {} - for p in root.rglob("*"): - if p.is_file(): - result[p.relative_to(root).as_posix()] = p.read_bytes() - return result - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def source_template_stems() -> list[str]: - return _get_source_template_stems() - - -@pytest.fixture(scope="session") -def scaffolded_sh(tmp_path_factory): - """Session-scoped cache: scaffold once per agent with script_type='sh'.""" - cache = {} - def _get(agent: str) -> Path: - if agent not in cache: - project = tmp_path_factory.mktemp(f"scaffold_sh_{agent}") - ok = scaffold_from_core_pack(project, agent, "sh") - assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" - cache[agent] = project - return cache[agent] - return _get - - -@pytest.fixture(scope="session") -def scaffolded_ps(tmp_path_factory): - """Session-scoped cache: scaffold once per agent with script_type='ps'.""" - cache = {} - def _get(agent: str) -> Path: - if agent not in cache: - project = tmp_path_factory.mktemp(f"scaffold_ps_{agent}") - ok = scaffold_from_core_pack(project, agent, "ps") - assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" - cache[agent] = project - return cache[agent] - return _get - - -# --------------------------------------------------------------------------- -# Parametrize over all agents except "generic" -# --------------------------------------------------------------------------- - -_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"] - - -# --------------------------------------------------------------------------- -# 1. Bundled scaffold — directory structure -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_scripts(agent, scaffolded_sh): - """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" - project = scaffolded_sh(agent) - - scripts_dir = project / ".specify" / "scripts" / "bash" - assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" - assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'" - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_templates(agent, scaffolded_sh): - """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" - project = scaffolded_sh(agent) - - tpl_dir = project / ".specify" / "templates" - assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" - assert any(tpl_dir.iterdir()), ".specify/templates/ is empty" - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_dir_location(agent, scaffolded_sh): - """Command files land in the directory declared by AGENT_CONFIG.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - assert cmd_dir.is_dir(), ( - f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'" - ) - - -# --------------------------------------------------------------------------- -# 2. Bundled scaffold — file count -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems): - """One command file is generated per source template for every agent.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - generated = _list_command_files(cmd_dir, agent) - - if cmd_dir.is_dir(): - dir_listing = list(cmd_dir.iterdir()) - else: - dir_listing = f"" - - assert len(generated) == len(source_template_stems), ( - f"Agent '{agent}': expected {len(source_template_stems)} command files " - f"({_expected_ext(agent)}), found {len(generated)}. Dir: {dir_listing}" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems): - """Each source template stem maps to a corresponding speckit.. file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for stem in source_template_stems: - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - expected = cmd_dir / f"speckit{sep}{stem}" / "SKILL.md" - else: - ext = _expected_ext(agent) - expected = cmd_dir / f"speckit.{stem}.{ext}" - assert expected.is_file(), ( - f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'" - ) - - -# --------------------------------------------------------------------------- -# 3. Bundled scaffold — content invariants -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_script_placeholder(agent, scaffolded_sh): - """{SCRIPT} must not appear in any generated command file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "{SCRIPT}" not in content, ( - f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_agent_placeholder(agent, scaffolded_sh): - """__AGENT__ must not appear in any generated command file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "__AGENT__" not in content, ( - f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_args_placeholder(agent, scaffolded_sh): - """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "{ARGS}" not in content, ( - f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -# Build a set of template stems that actually contain {ARGS} in their source. -_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset( - p.stem - for p in _commands_dir().glob("*.md") - if "{ARGS}" in p.read_text(encoding="utf-8") -) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_argument_token_format(agent, scaffolded_sh): - """For templates that carry an {ARGS} token: - - TOML agents must emit {{args}} - - Markdown agents must emit $ARGUMENTS - Templates without {ARGS} (e.g. implement, plan) are skipped. - """ - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - - for f in _list_command_files(cmd_dir, agent): - # Recover the stem from the file path - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - stem = f.parent.name.removeprefix(f"speckit{sep}") - else: - ext = _expected_ext(agent) - stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}") - if stem not in _TEMPLATES_WITH_ARGS: - continue # this template has no argument token - - content = f.read_text(encoding="utf-8") - if agent in _TOML_AGENTS: - assert "{{args}}" in content, ( - f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'" - ) - else: - assert "$ARGUMENTS" in content, ( - f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_path_rewrites_applied(agent, scaffolded_sh): - """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants. - - YAML frontmatter 'source:' metadata fields are excluded — they reference - the original template path for provenance, not a runtime path. - """ - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if not f.is_file(): - continue - content = f.read_text(encoding="utf-8") - - # Strip YAML frontmatter before checking — source: metadata is not a runtime path - body = content - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - body = parts[2] - - # Should not contain bare (non-.specify/) script paths - assert not re.search(r'(?= 3, f"Incomplete frontmatter in '{f.name}'" - fm = yaml.safe_load(parts[1]) - assert fm is not None, f"Empty frontmatter in '{f.name}'" - assert "description" in fm, ( - f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'" - ) - - -# --------------------------------------------------------------------------- -# 6. Copilot-specific: companion .prompt.md files -# --------------------------------------------------------------------------- - -def test_copilot_companion_prompt_files(scaffolded_sh, source_template_stems): - """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" - project = scaffolded_sh("copilot") - - prompts_dir = project / ".github" / "prompts" - assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" - - for stem in source_template_stems: - prompt_file = prompts_dir / f"speckit.{stem}.prompt.md" - assert prompt_file.is_file(), ( - f"Companion prompt file '{prompt_file.name}' missing for copilot" - ) - - -def test_copilot_prompt_file_content(scaffolded_sh, source_template_stems): - """Copilot companion .prompt.md files must reference their parent .agent.md.""" - project = scaffolded_sh("copilot") - - prompts_dir = project / ".github" / "prompts" - for stem in source_template_stems: - f = prompts_dir / f"speckit.{stem}.prompt.md" - content = f.read_text(encoding="utf-8") - assert f"agent: speckit.{stem}" in content, ( - f"Companion '{f.name}' does not reference 'speckit.{stem}'" - ) - - -# --------------------------------------------------------------------------- -# 7. PowerShell script variant -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems): - """scaffold_from_core_pack with script_type='ps' creates correct files.""" - project = scaffolded_ps(agent) - - scripts_dir = project / ".specify" / "scripts" / "powershell" - assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" - assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty" - - cmd_dir = _expected_cmd_dir(project, agent) - generated = _list_command_files(cmd_dir, agent) - assert len(generated) == len(source_template_stems) - - -# --------------------------------------------------------------------------- -# 8. Parity: bundled vs. real create-release-packages.sh ZIP -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def release_script_trees(tmp_path_factory): - """Session-scoped cache: run release script once per (agent, script_type).""" - cache: dict[tuple[str, str], dict[str, bytes]] = {} - bash = _find_bash() - - def _get(agent: str, script_type: str) -> dict[str, bytes] | None: - if bash is None: - return None - key = (agent, script_type) - if key not in cache: - tmp = tmp_path_factory.mktemp(f"release_{agent}_{script_type}") - gen_dir = tmp / "genreleases" - gen_dir.mkdir() - zip_path = _run_release_script(agent, script_type, bash, gen_dir) - extracted = tmp / "extracted" - extracted.mkdir() - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(extracted) - cache[key] = _collect_relative_files(extracted) - return cache[key] - return _get - - -@pytest.mark.parametrize("script_type", ["sh", "ps"]) -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_parity_bundled_vs_release_script(agent, script_type, scaffolded_sh, scaffolded_ps, release_script_trees): - """scaffold_from_core_pack() file tree is identical to the ZIP produced by - create-release-packages.sh for every agent and script type. - - This is the true end-to-end parity check: the Python offline path must - produce exactly the same artifacts as the canonical shell release script. - - Both sides are session-cached: each agent/script_type combination is - scaffolded and release-scripted only once across all tests. - """ - script_tree = release_script_trees(agent, script_type) - if script_tree is None: - pytest.skip("bash required to run create-release-packages.sh") - - # Reuse session-cached scaffold output - if script_type == "sh": - bundled_dir = scaffolded_sh(agent) - else: - bundled_dir = scaffolded_ps(agent) - - bundled_tree = _collect_relative_files(bundled_dir) - - only_bundled = set(bundled_tree) - set(script_tree) - only_script = set(script_tree) - set(bundled_tree) - - assert not only_bundled, ( - f"Agent '{agent}' ({script_type}): files only in bundled output (not in release ZIP):\n " - + "\n ".join(sorted(only_bundled)) - ) - assert not only_script, ( - f"Agent '{agent}' ({script_type}): files only in release ZIP (not in bundled output):\n " - + "\n ".join(sorted(only_script)) - ) - - for name in bundled_tree: - assert bundled_tree[name] == script_tree[name], ( - f"Agent '{agent}' ({script_type}): file '{name}' content differs between " - f"bundled output and release script ZIP" - ) - - -# --------------------------------------------------------------------------- -# Section 10 – pyproject.toml force-include covers all template files -# --------------------------------------------------------------------------- - -def test_pyproject_force_include_covers_all_templates(): - """Every file in templates/ (excluding commands/) must be listed in - pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section. - - This prevents new template files from being silently omitted from the - wheel, which would break ``specify init --offline``. - """ - templates_dir = _REPO_ROOT / "templates" - # Collect all files directly in templates/ (not in subdirectories like commands/) - repo_template_files = sorted( - f.name for f in templates_dir.iterdir() - if f.is_file() - ) - assert repo_template_files, "Expected at least one template file in templates/" - - pyproject_path = _REPO_ROOT / "pyproject.toml" - with open(pyproject_path, "rb") as f: - pyproject = tomllib.load(f) - force_include = pyproject.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("force-include", {}) - - missing = [ - name for name in repo_template_files - if f"templates/{name}" not in force_include - ] - assert not missing, ( - "Template files not listed in pyproject.toml force-include " - "(offline scaffolding will miss them):\n " - + "\n ".join(missing) - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 5d5d84902b..8a9f19e74e 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -41,14 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: """Create and return the expected skills directory for the given agent.""" # Match the logic in _get_skills_dir() from specify_cli - from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR + from specify_cli import AGENT_CONFIG agent_config = AGENT_CONFIG.get(ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: skills_dir = project_root / agent_folder.rstrip("/") / "skills" else: - skills_dir = project_root / DEFAULT_SKILLS_DIR + skills_dir = project_root / ".agents" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) return skills_dir diff --git a/tests/test_extensions.py b/tests/test_extensions.py index df269d86c4..350b368eac 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1017,7 +1017,7 @@ def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self): def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) ExtensionManager(project_dir) # Initialize manager (side effects only) @@ -1034,13 +1034,12 @@ def test_register_commands_for_claude(self, extension_dir, project_dir): assert "speckit.test-ext.hello" in registered # Check command file was created - cmd_file = claude_dir / "speckit.test-ext.hello.md" + cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() content = cmd_file.read_text() assert "description: Test hello command" in content - assert "" in content - assert "" in content + assert "test-ext" in content def test_command_with_aliases(self, project_dir, temp_dir): """Test registering a command with aliases.""" @@ -1078,7 +1077,7 @@ def test_command_with_aliases(self, project_dir, temp_dir): (ext_dir / "commands").mkdir() (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest") - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(ext_dir / "extension.yml") @@ -1088,8 +1087,8 @@ def test_command_with_aliases(self, project_dir, temp_dir): assert len(registered) == 2 assert "speckit.ext-alias.cmd" in registered assert "speckit.ext-alias.shortcut" in registered - assert (claude_dir / "speckit.ext-alias.cmd.md").exists() - assert (claude_dir / "speckit.ext-alias.shortcut.md").exists() + assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists() + assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists() def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): """Codex skill cleanup should use the same mapped names as registration.""" @@ -1466,7 +1465,7 @@ def test_register_commands_for_copilot(self, extension_dir, project_dir): content = cmd_file.read_text() assert "description: Test hello command" in content - assert "" in content + assert "test-ext" in content def test_copilot_companion_prompt_created(self, extension_dir, project_dir): """Test that companion .prompt.md files are created in .github/prompts/.""" @@ -1541,7 +1540,7 @@ def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir): def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): """Test that non-copilot agents do NOT create .prompt.md files.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(extension_dir / "extension.yml") @@ -1592,7 +1591,7 @@ class TestIntegration: def test_full_install_and_remove_workflow(self, extension_dir, project_dir): """Test complete installation and removal workflow.""" # Create Claude directory - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) @@ -1610,7 +1609,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): assert installed[0]["id"] == "test-ext" # Verify command registered - cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md" + cmd_file = project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() # Verify registry has registered commands (now a dict keyed by agent) @@ -3008,7 +3007,7 @@ def test_update_success_preserves_installed_at(self, tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) @@ -3057,7 +3056,7 @@ def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0") @@ -3068,14 +3067,16 @@ def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path): registered_commands = backup_registry_entry.get("registered_commands", {}) command_files = [] - registrar = CommandRegistrar() + from specify_cli.agents import CommandRegistrar as AgentRegistrar + agent_registrar = AgentRegistrar() for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: + if agent_name not in agent_registrar.AGENT_CONFIGS: continue - agent_cfg = registrar.AGENT_CONFIGS[agent_name] + agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name] commands_dir = project_dir / agent_cfg["dir"] for cmd_name in cmd_names: - cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}" + output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg) + cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}" command_files.append(cmd_path) assert command_files, "Expected at least one registered command file" diff --git a/tests/test_presets.py b/tests/test_presets.py index f2a08b91b4..d22264f806 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1772,19 +1772,20 @@ def test_self_test_command_file_exists(self): assert "preset:self-test" in content def test_self_test_registers_commands_for_claude(self, project_dir): - """Test that installing self-test registers commands in .claude/commands/.""" - # Create Claude agent directory to simulate Claude being set up - claude_dir = project_dir / ".claude" / "commands" + """Test that installing self-test registers skills in .claude/skills/.""" + # Create Claude skills directory to simulate Claude being set up + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") - # Check the command was registered - cmd_file = claude_dir / "speckit.specify.md" - assert cmd_file.exists(), "Command not registered in .claude/commands/" + # Check the skill was registered + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered in .claude/skills/" content = cmd_file.read_text() - assert "preset:self-test" in content + assert "self-test" in content + assert "source:" in content # skill frontmatter includes metadata.source def test_self_test_registers_commands_for_gemini(self, project_dir): """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" @@ -1804,13 +1805,13 @@ def test_self_test_registers_commands_for_gemini(self, project_dir): def test_self_test_unregisters_commands_on_remove(self, project_dir): """Test that removing self-test cleans up registered commands.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") - cmd_file = claude_dir / "speckit.specify.md" + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists() manager.remove("self-test") @@ -1826,7 +1827,7 @@ def test_self_test_no_commands_without_agent_dirs(self, project_dir): def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): """Test that extension command overrides are skipped if the extension isn't installed.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) preset_dir = temp_dir / "ext-override-preset" @@ -1869,7 +1870,7 @@ def test_extension_command_skipped_when_extension_missing(self, project_dir, tem def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): """Test that extension command overrides ARE registered when the extension is installed.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) @@ -1905,8 +1906,8 @@ def test_extension_command_registered_when_extension_present(self, project_dir, manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") - cmd_file = claude_dir / "speckit.fakeext.cmd.md" - assert cmd_file.exists(), "Command not registered despite extension being present" + cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered despite extension being present" # ===== Init Options and Skills Tests ===== @@ -1964,7 +1965,7 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): self._create_skill(skills_dir, "speckit-specify") # Also create the claude commands dir so commands get registered - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Install self-test preset (has a command override for speckit.specify) manager = PresetManager(project_dir) @@ -1983,12 +1984,10 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): """When --ai-skills was NOT used, preset install should not touch skills.""" - self._write_init_options(project_dir, ai="claude", ai_skills=False) - skills_dir = project_dir / ".claude" / "skills" + self._write_init_options(project_dir, ai="qwen", ai_skills=False) + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2019,18 +2018,16 @@ def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): """When no init-options.json exists, preset install should not touch skills.""" - skills_dir = project_dir / ".claude" / "skills" + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") skill_file = skills_dir / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - assert "untouched" in content + file_content = skill_file.read_text() + assert "untouched" in file_content def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): """When a preset is removed, skills should be restored from core templates.""" @@ -2038,7 +2035,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Set up core command template in the project so restoration works core_cmds = project_dir / ".specify" / "templates" / "commands" @@ -2068,7 +2065,7 @@ def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify", body="old") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) @@ -2094,13 +2091,11 @@ def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): """Preset install should skip non-directory skill targets.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" + self._write_init_options(project_dir, ai="qwen") + skills_dir = project_dir / ".qwen" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) (skills_dir / "speckit-specify").write_text("not-a-directory") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2114,8 +2109,6 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d self._write_init_options(project_dir, ai="claude") # Don't create skills dir — simulate --ai-skills never created them - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2516,16 +2509,15 @@ def test_preset_skill_registration_handles_non_dict_init_options(self, project_d init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text("[]") - skills_dir = project_dir / ".claude" / "skills" + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(self_test_dir, "0.1.5") - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "untouched" in content + skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "untouched" in skill_content class TestPresetSetPriority: From 663d679f3baef6b2feeaeece05e553a5c0c146ee Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:38:48 -0500 Subject: [PATCH 176/321] chore: release 0.4.5, begin 0.4.6.dev0 development (#2064) * chore: bump version to 0.4.5 * chore: begin 0.4.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8394968a26..04e9ea9e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ +## [0.4.5] - 2026-04-02 + +### Changed + +- Stage 6: Complete migration — remove legacy scaffold path (#1924) (#2063) +- Install Claude Code as native skills and align preset/integration flows (#2051) +- Add repoindex 0402 (#2062) +- Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052) +- feat(scripts): add --dry-run flag to create-new-feature (#1998) +- fix: support feature branch numbers with 4+ digits (#2040) +- Add community content disclaimers (#2058) +- docs: add community extensions website link to README and extensions docs (#2014) +- docs: remove dead Cognitive Squad and Understanding extension links and from extensions/catalog.community.json (#2057) +- Add fix-findings extension to community catalog (#2039) +- Stage 4: TOML integrations — gemini and tabnine migrated to plugin architecture (#2050) +- feat: add 5 lifecycle extensions to community catalog (#2049) +- Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture (#2038) +- chore: release 0.4.4, begin 0.4.5.dev0 development (#2048) + ## [0.4.4] - 2026-04-01 ### Changed diff --git a/pyproject.toml b/pyproject.toml index bfd5f25c25..3c476f22c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.5.dev0" +version = "0.4.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From c8ccb0609d169f36fb8d8f82418774322bcd2071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 2 Apr 2026 20:52:17 +0200 Subject: [PATCH 177/321] Update cc-sdd reference to cc-spex in Community Friends (#2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cc-sdd project has been renamed to cc-spex (v3.0.0). Assisted-By: 🤖 Claude Code --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c581ce627d..f4d571d209 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ See Spec-Driven Development in action across different scenarios with these comm Community projects that extend, visualize, or build on Spec Kit: -- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. +- **[cc-spex](https://github.com/rhuss/cc-spex)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. - **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. From 08f69e3d3e6df4c1887960d76ff4c3bffdc62aad Mon Sep 17 00:00:00 2001 From: PChemGuy <39730837+pchemguy@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:01:48 +0300 Subject: [PATCH 178/321] Introduces DEVELOPMENT.md (#2069) * Create DEVELOPMENT.md outline * AI-generated DEVELOPMENT.md draft * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Create Untitled 2.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Compact DEVELOPMENT.md * Create DEVELOPMENT.md outline * AI-generated DEVELOPMENT.md draft * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Create Untitled 2.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Update DEVELOPMENT.md * Compact DEVELOPMENT.md * Update DEVELOPMENT.md --- DEVELOPMENT.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000000..dc35bc6fe0 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,25 @@ +# Development Notes + +Spec Kit is a toolkit for spec-driven development. At its core, it is a coordinated set of prompts, templates, scripts, and CLI/integration assets that define and deliver a spec-driven workflow for AI coding agents. This document is a starting point for people modifying Spec Kit itself, with a compact orientation to the key project documents and repository organization. + +**Essential project documents:** + +| Document | Role | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| [README.md](README.md) | Primary user-facing overview of Spec Kit and its workflow. | +| [DEVELOPMENT.md](DEVELOPMENT.md) | This document. | +| [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. | +| [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. | +| [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, and required development practices. | +| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. | + +**Main repository components:** + +| Directory | Role | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `templates/` | Prompt assets and templates that define the core workflow behavior and generated artifacts. | +| `scripts/` | Supporting scripts used by the workflow, setup, and repository tooling. | +| `src/specify_cli/` | Python source for the `specify` CLI, including agent-specific assets. | +| `extensions/` | Extension-related docs, catalogs, and supporting assets. | +| `presets/` | Preset-related docs, catalogs, and supporting assets. | From b8e785123425fec90c2b41b19bf37bae299c97a1 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Suazo <97453318+ericnoam@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:40:43 +0200 Subject: [PATCH 179/321] feat: add Forgecode agent support (#2034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Forgecode (forge) agent support - Add 'forgecode' to AGENT_CONFIGS in agents.py with .forge/commands directory, markdown format, and {{parameters}} argument placeholder - Add 'forgecode' to AGENT_CONFIG in __init__.py with .forge/ folder, install URL, and requires_cli=True - Add forgecode binary check in check_tool() mapping agent key 'forgecode' to the actual 'forge' CLI binary - Add forgecode case to build_variant() in create-release-packages.sh generating commands into .forge/commands/ with {{parameters}} - Add forgecode to ALL_AGENTS in create-release-packages.sh * fix: strip handoffs frontmatter and replace $ARGUMENTS for forgecode The forgecode agent hangs when listing commands because the 'handoffs' frontmatter field (a Claude Code-specific feature) contains 'send: true' entries that forge tries to act on when indexing .forge/commands/ files. Additionally, $ARGUMENTS in command bodies was never replaced with {{parameters}}, so user input was not passed through to commands. Python path (agents.py): - Add strip_frontmatter_keys: [handoffs] to the forgecode AGENT_CONFIG entry so register_commands drops the key before rendering Bash path (create-release-packages.sh): - Add extra_strip_key parameter to generate_commands; pass 'handoffs' for the forgecode case in build_variant - Use regex prefix match (~ "^"extra_key":") instead of exact equality to handle trailing whitespace after the YAML key - Add sed replacement of $ARGUMENTS -> $arg_format in the body pipeline so {{parameters}} is substituted in forgecode command files * feat: add name field injection for forgecode agent Forgecode requires both 'name' and 'description' fields in command frontmatter. This commit adds automatic injection of the 'name' field during command generation for forgecode. Changes: - Python (agents.py): Add inject_name: True to forgecode config and implement name injection logic in register_commands - Bash (create-release-packages.sh): Add post-processing step to inject name field into frontmatter after command generation This complements the existing handoffs stripping fix (d83be82) to fully support forgecode command requirements. * test: update test_argument_token_format for forgecode special case Forgecode uses {{parameters}} instead of the standard $ARGUMENTS placeholder. Updated test to check for the correct placeholder format for forgecode agent. - Added special case handling for forgecode in test_argument_token_format - Updated docstring to document forgecode's {{parameters}} format - Test now passes for all 26 agents including forgecode * docs: add forgecode to README documentation Added forgecode agent to all relevant sections: - Added to Supported AI Agents table - Added to --ai option description - Added to specify check command examples - Added initialization example - Added to CLI tools check list in detailed walkthrough Forgecode is now fully documented alongside other supported agents. * fix: show 'forge' binary name in user-facing messages for forgecode Addresses Copilot PR feedback: Users should see the actual executable name 'forge' in status and error messages, not the agent key 'forgecode'. Changes: - Added 'cli_binary' field to forgecode AGENT_CONFIG (set to 'forge') - Updated check_tool() to accept optional display_key parameter - Updated check_tool() to use cli_binary from AGENT_CONFIG when available - Updated check() command to display cli_binary in StepTracker - Updated init() error message to show cli_binary instead of agent key UX improvements: - 'specify check' now shows: '● forge (available/not found)' - 'specify init --ai forgecode' error shows: 'forge not found' (instead of confusing 'forgecode not found') This makes it clear to users that they need to install the 'forge' binary, even though they selected the 'forgecode' agent. * refactor: rename forgecode agent key to forge Aligns with AGENTS.md design principle: "Use the actual CLI tool name as the key, not a shortened version" (AGENTS.md:61-83). The actual CLI executable is 'forge', so the AGENT_CONFIG key should be 'forge' (not 'forgecode'). This follows the same pattern as other agents like cursor-agent and kiro-cli. Changes: - Renamed AGENT_CONFIG key: "forgecode" → "forge" - Removed cli_binary field (no longer needed) - Simplified check_tool() - removed cli_binary lookup logic - Simplified init() and check() - removed display_key mapping - Updated all tests: test_forge_name_field_in_frontmatter - Updated documentation: README.md Code simplification: - Removed 6 lines of workaround code - Removed 1 function parameter (display_key) - Eliminated all special-case logic for forge Note: No backward compatibility needed - forge is a new agent being introduced in this PR. * fix: ensure forge alias commands have correct name in frontmatter When inject_name is enabled (for forge), alias command files must have their own name field in frontmatter, not reuse the primary command's name. This is critical for Forge's command discovery and dispatch system. Changes: - For agents with inject_name, create a deepcopy of frontmatter for each alias and set the name to the alias name - Re-render the command content with the alias-specific frontmatter - Ensures each alias file has the correct name field matching its filename This fixes command discovery issues where forge would try to invoke aliases using the primary command's name. * feat: add forge to PowerShell script and fix test whitespace 1. PowerShell script (create-release-packages.ps1): - Added forge agent support for Windows users - Enables `specify init --ai forge --offline` on Windows - Enhanced Generate-Commands with ExtraStripKey parameter - Added frontmatter stripping for handoffs key - Added $ARGUMENTS replacement for {{parameters}} - Implemented forge case with name field injection - Complete parity with bash script 2. Test file (test_core_pack_scaffold.py): - Removed trailing whitespace from blank lines - Cleaner diffs and no linter warnings Addresses Copilot PR feedback on both issues. * fix: use .NET Regex.Replace for count-limited replacement in PowerShell Addresses Copilot feedback: PowerShell's -replace operator does not support a third argument for replacement count. Using it causes an error or mis-parsing that would break forge package generation on Windows. Changed from: $content -replace '(?m)^---$', "---`nname: $cmdName", 1 To: $regex = [regex]'(?m)^---$' $content = $regex.Replace($content, "---`nname: $cmdName", 1) The .NET Regex.Replace() method properly supports the count parameter, ensuring the name field is injected only after the first frontmatter delimiter (not the closing one). This fix is critical for Windows users running: specify init --ai forge --offline * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: migrate Forge agent to Python integration system - Create ForgeIntegration class with custom processing for {{parameters}}, handoffs stripping, and name injection - Add update-context scripts (bash and PowerShell) for Forge - Register Forge in integration registry - Update AGENTS.md with Forge documentation and special processing requirements section - Add comprehensive test suite (11 tests, all passing) Closes migration from release packaging to Python-based scaffolding for Forge agent. * fix: replace $ARGUMENTS with {{parameters}} in Forge templates - Add replacement of $ARGUMENTS to {{parameters}} after template processing - Use arg_placeholder from config (Copilot's cleaner approach) - Remove unused 'import re' from _apply_forge_transformations() - Enhance tests to verify $ARGUMENTS replacement works correctly - All 11 tests pass Fixes template processing to ensure Forge receives user-supplied parameters correctly. * refactor: make ForgeIntegration extend MarkdownIntegration - Change base class from IntegrationBase to MarkdownIntegration - Eliminates ~30 lines of duplicated validation/setup boilerplate - Aligns with the pattern used by 20+ other markdown agents (Bob, Claude, Windsurf, etc.) - Update AGENTS.md to reflect new inheritance hierarchy - All Forge-specific processing retained ({{parameters}}, handoffs stripping, name injection) - All 535 integration tests pass This addresses reviewer feedback about using the MarkdownIntegration convenience base class. * style: remove trailing whitespace from test file - Strip trailing spaces from blank lines in test_integration_forge.py - Fixes W291 linting warnings - No functional changes * style: remove trailing whitespace from Forge integration - Strip trailing spaces from blank lines in __init__.py - Fixes whitespace on lines 20, 86, 90, 93, 139, 143 - Verified other files in forge/ directory have no trailing whitespace - No functional changes, all tests pass * test: derive expected commands from templates dynamically - Remove hard-coded command count (9) and command set from test_directory_structure - Use forge.list_command_templates() to derive expected commands - Test now auto-syncs when core command templates are added/removed - Prevents test breakage when template set changes - All 11 tests pass * fix: make Forge update-context scripts handle AGENTS.md directly - Add fallback logic to update/create AGENTS.md when shared script doesn't support forge yet - Check if shared dispatcher knows about 'forge' before delegating - If shared script doesn't support forge, handle AGENTS.md updates directly: - Add Forge section to existing AGENTS.md if not present - Create new AGENTS.md with Forge section if file doesn't exist - Both bash and PowerShell scripts implement same logic - Prevents 'Unknown agent type' errors until shared scripts add forge support - Future-compatible: automatically delegates when shared script supports forge Addresses reviewer feedback about update-context scripts failing without forge support. * feat: add Forge support to shared update-agent-context scripts - Add forge case to bash and PowerShell update-agent-context scripts - Add FORGE_FILE variable mapping to AGENTS.md (like opencode/codex/pi) - Add forge to all usage/help text and ValidateSet parameters - Include forge in update_all_existing_agents functions Wrapper script improvements: - Simplify Forge wrapper scripts to unconditionally delegate to shared script - Remove complex fallback logic that created stub AGENTS.md files - Add clear error messages if shared script is missing/not executable - Align with pattern used by other integrations (opencode, bob, etc.) Benefits: - Plan command's {AGENT_SCRIPT} now works for Forge users - No more incomplete/stub context files masking missing support - Cleaner, more maintainable code (-39 lines in wrappers) - Consistent architecture across all integrations Update AGENTS.md to document that Forge integration ensures shared scripts include forge support for context updates. Addresses reviewer feedback about Forge support being incomplete for workflow steps that run {AGENT_SCRIPT}. * fix: resolve unbound variable and duplicate file update issues - Fix undefined FORGE_FILE variable in bash update-agent-context.sh - Add missing FORGE_FILE definition pointing to AGENTS.md - Update comment to include Forge in list of agents sharing AGENTS.md - Prevents crash with 'set -u' when running without explicit agent type - Add deduplication logic to PowerShell update-agent-context.ps1 - Implement Update-IfNew helper to track processed files by real path - Prevents AGENTS.md from being rewritten multiple times - Matches existing deduplication behavior in bash script - Prevent duplicate YAML keys in Forge frontmatter injection - Check for existing 'name:' field before injection in both scripts - PowerShell: Parse frontmatter to detect existing name field - Bash: Enhanced awk script to check frontmatter state - Future-proofs against template changes that add name fields All scripts now have consistent behavior and proper error handling. * fix: import timezone from datetime for rate limit header parsing The _parse_rate_limit_headers() function uses timezone.utc on line 82 but timezone was never imported from datetime. This would raise a NameError the first time GitHub API rate-limit headers are parsed. Import timezone alongside datetime to fix the missing import. * fix: correct variable scope in PowerShell deduplication and update docs - Fix Update-IfNew in PowerShell update-agent-context.ps1 - Changed from $script: scope to Set-Variable -Scope 1 - Properly mutates parent function's local variables - Fixes deduplication tracking for shared AGENTS.md file - Prevents incorrect default Claude file creation - Update create-release-packages.sh documentation - Add missing 'forge' to AGENTS list in header comment - Documentation now matches actual ALL_AGENTS array Without this fix, AGENTS.md would be updated multiple times (once for each agent sharing it: opencode, codex, amp, kiro, bob, pi, forge) and the script would always create a default Claude file even when agent files exist. * fix: resolve missing scaffold_from_core_pack import in tests The test_core_pack_scaffold.py imports scaffold_from_core_pack from specify_cli, but that symbol does not exist in the current codebase. This causes an ImportError when the test module is loaded. Implement a resilient resolver that: - Tries scaffold_from_core_pack first (expected name) - Falls back to alternative names (scaffold_from_release_pack, etc.) - Gracefully skips tests if no compatible entrypoint exists This prevents import-time failures and makes the test future-proof for when the actual scaffolding function is added or restored. * fix: prevent duplicate path prefixes and consolidate shared file updates PowerShell release script: - Add deduplication pass to Rewrite-Paths function - Prevents .specify.specify/ double prefixes in generated commands - Matches bash script behavior with regex '(?:\.specify/){2,}' -> '.specify/' Bash update-agent-context script: - Consolidate AGENTS.md updates to single call - Remove redundant calls for $AMP_FILE, $KIRO_FILE, $BOB_FILE, $FORGE_FILE - Update label to 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge' to reflect all agents - Prevents always-deduped $FORGE_FILE call that never executed Both fixes improve efficiency and correctness while maintaining parity between bash and PowerShell implementations. * refactor: remove unused rate-limit helpers and improve PowerShell scripts - Remove unused _parse_rate_limit_headers() and _format_rate_limit_error() from src/specify_cli/__init__.py (56 lines of dead code) - Add GENRELEASES_DIR override support to PowerShell release script with comprehensive safety checks (parity with bash script) - Remove redundant shared-file update calls from PowerShell agent context script (AMP_FILE, KIRO_FILE, BOB_FILE, FORGE_FILE all resolve to AGENTS.md) - Update test docstring to accurately reflect Forge's {{parameters}} token Changes align PowerShell scripts with bash equivalents and reduce maintenance burden by removing dead code. * fix: add missing 'forge' to PowerShell usage text and fix agent order - Add 'forge' to usage message in Print-Summary (was missing from list) - Reorder ValidateSet to match bash script order (vibe before qodercli) This ensures PowerShell script documentation matches bash script and includes all supported agents consistently. * refactor: remove old architecture files deleted in b1832c9 Remove files that were deleted in b1832c9 (Stage 6 migration) but remained on this branch due to merge conflicts: - Remove .github/workflows/scripts/create-release-packages.{sh,ps1} (replaced by inline release.yml + uv tool install) - Remove tests/test_core_pack_scaffold.py (scaffold system removed, tests no longer relevant) These files existed on the feature branch because they were modified before b1832c9 landed. The merge kept our versions, but they should be deleted to align with the new integration-only architecture. This PR now focuses purely on adding NEW Forge integration support, not restoring old architecture. * refactor: remove unused timezone import from __init__.py Remove unused timezone import that was added in 4a57f79 for rate-limit header parsing but became obsolete when rate-limit helper functions were removed in 59c4212 (and also removed in upstream b1832c9). No functional changes - purely cleanup of unused import. * docs: clarify that handoffs is a Claude Code feature, not Forge's Update docstrings to accurately explain that the 'handoffs' frontmatter key is from Claude Code (for multi-agent collaboration) and is stripped because it causes Forge to hang, not because it's a Forge-specific feature. Changes: - Module docstring: 'Forge-specific collaboration feature' → 'Claude Code feature that causes Forge to hang' - Class docstring: Add '(incompatible with Forge)' clarification - Method docstring: Add '(from Claude Code templates; incompatible with Forge)' context This avoids implying that handoffs belongs to Forge when it actually comes from spec-kit templates designed for Claude Code compatibility. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- AGENTS.md | 44 ++++- README.md | 10 +- scripts/bash/update-agent-context.sh | 19 +- scripts/powershell/update-agent-context.ps1 | 83 ++++++--- src/specify_cli/__init__.py | 48 ++--- src/specify_cli/agents.py | 36 +++- src/specify_cli/integrations/__init__.py | 2 + .../integrations/forge/__init__.py | 155 ++++++++++++++++ .../forge/scripts/update-context.ps1 | 33 ++++ .../forge/scripts/update-context.sh | 38 ++++ tests/integrations/test_integration_forge.py | 170 ++++++++++++++++++ 11 files changed, 569 insertions(+), 69 deletions(-) create mode 100644 src/specify_cli/integrations/forge/__init__.py create mode 100644 src/specify_cli/integrations/forge/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/forge/scripts/update-context.sh create mode 100644 tests/integrations/test_integration_forge.py diff --git a/AGENTS.md b/AGENTS.md index eb3d27065f..c7a06ea59b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | | **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | | **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | +| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) | | **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | | **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) | @@ -333,6 +334,7 @@ Require a command-line tool to be installed: - **Mistral Vibe**: `vibe` CLI - **Pi Coding Agent**: `pi` CLI - **iFlow CLI**: `iflow` CLI +- **Forge**: `forge` CLI ### IDE-Based Agents @@ -351,7 +353,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow +Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge **Standard format:** @@ -419,9 +421,49 @@ Different agents use different argument placeholders: - **Markdown/prompt-based**: `$ARGUMENTS` - **TOML-based**: `{{args}}` +- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) +## Special Processing Requirements + +Some agents require custom processing beyond the standard template transformations: + +### Copilot Integration + +GitHub Copilot has unique requirements: +- Commands use `.agent.md` extension (not `.md`) +- Each command gets a companion `.prompt.md` file in `.github/prompts/` +- Installs `.vscode/settings.json` with prompt file recommendations +- Context file lives at `.github/copilot-instructions.md` + +Implementation: Extends `IntegrationBase` with custom `setup()` method that: +1. Processes templates with `process_template()` +2. Generates companion `.prompt.md` files +3. Merges VS Code settings + +### Forge Integration + +Forge has special frontmatter and argument requirements: +- Uses `{{parameters}}` instead of `$ARGUMENTS` +- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) +- Injects `name` field into frontmatter when missing + +Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: +1. Inherits standard template processing from `MarkdownIntegration` +2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing +3. Applies Forge-specific transformations via `_apply_forge_transformations()` +4. Strips `handoffs` frontmatter key +5. Injects missing `name` fields +6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` (similar to `opencode`/`codex`/`pi`) and lists `forge` in their usage/help text + +### Standard Markdown Agents + +Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`: +- Simple subclass with just `key`, `config`, `registrar_config` set +- Inherits standard processing from `MarkdownIntegration.setup()` +- No custom processing needed + ## Testing New Agent Integration 1. **Build test**: Run package creation script locally diff --git a/README.md b/README.md index f4d571d209..890b1d2d6a 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ Community projects that extend, visualize, or build on Spec Kit: | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | | [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | | [Cursor](https://cursor.sh/) | ✅ | | +| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | | [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | | [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | @@ -314,14 +315,14 @@ The `specify` command supports the following options: | Command | Description | | ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) | +| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | ### `specify init` Arguments & Options | Argument/Option | Type | Description | | ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) | +| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | @@ -376,6 +377,9 @@ specify init my-project --ai codex --ai-skills # Initialize with Antigravity support specify init my-project --ai agy --ai-skills +# Initialize with Forge support +specify init my-project --ai forge + # Initialize with an unsupported agent (generic / bring your own agent) specify init my-project --ai generic --ai-commands-dir .myagent/commands/ @@ -621,7 +625,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 831850f440..da06ed4697 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic # Leave empty to update all existing agent files set -e @@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid +# Amp, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid # updating the same file multiple times. AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" @@ -86,6 +86,7 @@ VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" IFLOW_FILE="$REPO_ROOT/IFLOW.md" +FORGE_FILE="$AGENTS_FILE" # Template file TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" @@ -690,12 +691,15 @@ update_specific_agent() { iflow) update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 ;; + forge) + update_agent_file "$AGENTS_FILE" "Forge" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic" exit 1 ;; esac @@ -739,10 +743,7 @@ update_all_existing_agents() { _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false - _update_if_new "$AMP_FILE" "Amp" || _all_ok=false - _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false - _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false @@ -783,7 +784,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 61df427c7c..342ee5464d 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')] [string]$AgentType ) @@ -67,6 +67,7 @@ $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md' $IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' +$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -415,36 +416,66 @@ function Update-SpecificAgent { 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } + 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false } } } function Update-AllExistingAgents { $found = $false $ok = $true - if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true } - if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true } - if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true } - if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } - if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } - if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } - if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } - if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true } - if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } - if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } - if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } - if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } - if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } - if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } - if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true } - if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true } - if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } - if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } - if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true } - if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true } - if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true } - if (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true } + $updatedPaths = @() + + # Helper function to update only if file exists and hasn't been updated yet + function Update-IfNew { + param( + [Parameter(Mandatory=$true)] + [string]$FilePath, + [Parameter(Mandatory=$true)] + [string]$AgentName + ) + + if (-not (Test-Path $FilePath)) { return $true } + + # Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md) + $realPath = (Get-Item -LiteralPath $FilePath).FullName + + # Check if we've already updated this file + if ($updatedPaths -contains $realPath) { + return $true + } + + # Record the file as seen before attempting the update + # Use parent scope (1) to modify Update-AllExistingAgents' local variables + Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1 + Set-Variable -Name found -Value $true -Scope 1 + + # Perform the update + return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName) + } + + if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } + if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } + if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } + if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } + if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false } + if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } + if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } + if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } + if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false } + if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false } + if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false } + if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false } + if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false } + if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false } + if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false } + if (-not $found) { Write-Info 'No existing agent files found, creating default Claude file...' if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } @@ -459,7 +490,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]' } function Main { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5f0cf0bdfd..fbe1bc033f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -117,10 +117,10 @@ def _build_ai_assistant_help() -> str: BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ -███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ -╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ -███████║██║ ███████╗╚██████╗██║██║ ██║ -╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ +███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ +╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ +███████║██║ ███████╗╚██████╗██║██║ ██║ +╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ """ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" @@ -232,12 +232,12 @@ def get_key(): def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: """ Interactive selection using arrow keys with Rich Live display. - + Args: options: Dict with keys as option keys and values as descriptions prompt_text: Text to show above the options default_key: Default option key to start with - + Returns: Selected option key """ @@ -365,11 +365,11 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False def check_tool(tool: str, tracker: StepTracker = None) -> bool: """Check if a tool is installed. Optionally update tracker. - + Args: tool: Name of the tool to check tracker: Optional StepTracker to update with results - + Returns: True if tool is found, False otherwise """ @@ -385,27 +385,27 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: if tracker: tracker.complete(tool, "available") return True - + if tool == "kiro-cli": # Kiro currently supports both executable names. Prefer kiro-cli and # accept kiro as a compatibility fallback. found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None else: found = shutil.which(tool) is not None - + if tracker: if found: tracker.complete(tool, "available") else: tracker.error(tool, "not found") - + return found def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: path = Path.cwd() - + if not path.is_dir(): return False @@ -423,11 +423,11 @@ def is_git_repo(path: Path = None) -> bool: def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: """Initialize a git repository in the specified path. - + Args: project_path: Path to initialize git repository in quiet: if True suppress console output (tracker handles status) - + Returns: Tuple of (success: bool, error_message: Optional[str]) """ @@ -449,7 +449,7 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option error_msg += f"\nError: {e.stderr.strip()}" elif e.stdout: error_msg += f"\nOutput: {e.stdout.strip()}" - + if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") return False, error_msg @@ -911,7 +911,7 @@ def init( console.print("[yellow]Example:[/yellow] specify init --ai claude --here") console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") raise typer.Exit(1) - + if ai_commands_dir and ai_commands_dir.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") @@ -1023,8 +1023,8 @@ def init( # Create options dict for selection (agent_key: display_name) ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( - ai_choices, - "Choose your AI assistant:", + ai_choices, + "Choose your AI assistant:", "copilot" ) @@ -1262,7 +1262,7 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - + # Show git error details if initialization failed if git_error_message: console.print() @@ -1410,9 +1410,9 @@ def version(): """Display version and system information.""" import platform import importlib.metadata - + show_banner() - + # Get CLI version from package metadata cli_version = "unknown" try: @@ -1428,15 +1428,15 @@ def version(): cli_version = data.get("project", {}).get("version", "unknown") except Exception: pass - + # Fetch latest template release version repo_owner = "github" repo_name = "spec-kit" api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - + template_version = "unknown" release_date = "unknown" - + try: response = client.get( api_url, diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 386fa5df44..4f6714ed8b 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -408,6 +408,12 @@ def register_commands( 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"): + frontmatter["name"] = cmd_name + body = self._convert_argument_placeholder( body, "$ARGUMENTS", agent_config["args"] ) @@ -436,11 +442,30 @@ def register_commands( for alias in cmd_info.get("aliases", []): alias_output_name = self._compute_output_name(agent_name, alias, agent_config) - 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 - ) + + # For agents with inject_name, render with alias-specific frontmatter + if agent_config.get("inject_name"): + alias_frontmatter = deepcopy(frontmatter) + alias_frontmatter["name"] = 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) + 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']}" alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.write_text(alias_output, encoding="utf-8") @@ -540,4 +565,3 @@ def unregister_commands( CommandRegistrar._ensure_configs() except ImportError: pass - diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index bb87cec996..c65013869e 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -55,6 +55,7 @@ def _register_builtins() -> None: from .codebuddy import CodebuddyIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration + from .forge import ForgeIntegration from .gemini import GeminiIntegration from .generic import GenericIntegration from .iflow import IflowIntegration @@ -83,6 +84,7 @@ def _register_builtins() -> None: _register(CodebuddyIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) + _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) _register(IflowIntegration()) diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py new file mode 100644 index 0000000000..e3d5347270 --- /dev/null +++ b/src/specify_cli/integrations/forge/__init__.py @@ -0,0 +1,155 @@ +"""Forge integration — forgecode.dev AI coding agent. + +Forge has several unique behaviors compared to standard markdown agents: +- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing +- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang) +- Injects `name` field into frontmatter when missing +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import MarkdownIntegration +from ..manifest import IntegrationManifest + + +class ForgeIntegration(MarkdownIntegration): + """Integration for Forge (forgecode.dev). + + Extends MarkdownIntegration to add Forge-specific processing: + - Replaces $ARGUMENTS with {{parameters}} + - Strips 'handoffs' frontmatter key (incompatible with Forge) + - Injects 'name' field into frontmatter when missing + """ + + key = "forge" + config = { + "name": "Forge", + "folder": ".forge/", + "commands_subdir": "commands", + "install_url": "https://forgecode.dev/docs/", + "requires_cli": True, + } + registrar_config = { + "dir": ".forge/commands", + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": ["handoffs"], + "inject_name": True, + } + context_file = "AGENTS.md" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Forge commands with custom processing. + + Extends MarkdownIntegration.setup() to inject Forge-specific transformations + after standard template processing. + """ + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "{{parameters}}") + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + # Process template with standard MarkdownIntegration logic + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + + # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are + # converted to {{parameters}} + processed = processed.replace("$ARGUMENTS", arg_placeholder) + + # FORGE-SPECIFIC: Apply frontmatter transformations + processed = self._apply_forge_transformations(processed, src_file.stem) + + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Install integration-specific update-context scripts + created.extend(self.install_scripts(project_root, manifest)) + + return created + + def _apply_forge_transformations(self, content: str, template_name: str) -> str: + """Apply Forge-specific transformations to processed content. + + 1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge) + 2. Inject 'name' field if missing + """ + # Parse frontmatter + lines = content.split('\n') + if not lines or lines[0].strip() != '---': + return content + + # Find end of frontmatter + frontmatter_end = -1 + for i in range(1, len(lines)): + if lines[i].strip() == '---': + frontmatter_end = i + break + + if frontmatter_end == -1: + return content + + frontmatter_lines = lines[1:frontmatter_end] + body_lines = lines[frontmatter_end + 1:] + + # 1. Strip 'handoffs' key + filtered_frontmatter = [] + skip_until_outdent = False + for line in frontmatter_lines: + if skip_until_outdent: + # Skip indented lines under handoffs: + if line and (line[0] == ' ' or line[0] == '\t'): + continue + else: + skip_until_outdent = False + + if line.strip().startswith('handoffs:'): + skip_until_outdent = True + continue + + filtered_frontmatter.append(line) + + # 2. Inject 'name' field if missing + has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter) + if not has_name: + # Use the template name as the command name (e.g., "plan" -> "speckit.plan") + cmd_name = f"speckit.{template_name}" + filtered_frontmatter.insert(0, f'name: {cmd_name}') + + # Reconstruct content + result = ['---'] + filtered_frontmatter + ['---'] + body_lines + return '\n'.join(result) diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 new file mode 100644 index 0000000000..474a9c6d0b --- /dev/null +++ b/src/specify_cli/integrations/forge/scripts/update-context.ps1 @@ -0,0 +1,33 @@ +# update-context.ps1 — Forge integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if (-not (Test-Path $sharedScript)) { + Write-Error "Error: shared agent context updater not found: $sharedScript" + Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1." + exit 1 +} + +& $sharedScript -AgentType forge +exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh new file mode 100755 index 0000000000..2a5c46e1d1 --- /dev/null +++ b/src/specify_cli/integrations/forge/scripts/update-context.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# update-context.sh — Forge integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if [ ! -x "$shared_script" ]; then + echo "Error: shared agent context updater not found or not executable:" >&2 + echo " $shared_script" >&2 + echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2 + exit 1 +fi + +exec "$shared_script" forge diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py new file mode 100644 index 0000000000..10905723fb --- /dev/null +++ b/tests/integrations/test_integration_forge.py @@ -0,0 +1,170 @@ +"""Tests for ForgeIntegration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestForgeIntegration: + def test_forge_key_and_config(self): + forge = get_integration("forge") + assert forge is not None + assert forge.key == "forge" + assert forge.config["folder"] == ".forge/" + assert forge.config["commands_subdir"] == "commands" + assert forge.config["requires_cli"] is True + assert forge.registrar_config["args"] == "{{parameters}}" + assert forge.registrar_config["extension"] == ".md" + assert forge.context_file == "AGENTS.md" + + def test_command_filename_md(self): + forge = get_integration("forge") + assert forge.command_filename("plan") == "speckit.plan.md" + + def test_setup_creates_md_files(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.setup(tmp_path, m) + assert len(created) > 0 + # Separate command files from scripts + command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"] + assert len(command_files) > 0 + for f in command_files: + assert f.name.endswith(".md") + + def test_setup_installs_update_scripts(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.setup(tmp_path, m) + script_files = [f for f in created if "scripts" in f.parts] + assert len(script_files) > 0 + sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh" + ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1" + assert sh_script in created + assert ps_script in created + assert sh_script.exists() + assert ps_script.exists() + + def test_all_created_files_tracked_in_manifest(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"Created file {rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = forge.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.install(tmp_path, m) + m.save() + # Modify a command file (not a script) + command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"] + modified_file = command_files[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = forge.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_directory_structure(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + assert commands_dir.is_dir() + + # Derive expected command names from the Forge command templates so the test + # stays in sync if templates are added/removed. + templates = forge.list_command_templates() + expected_commands = {t.stem for t in templates} + assert len(expected_commands) > 0, "No command templates found" + + # Check generated files match templates + command_files = sorted(commands_dir.glob("speckit.*.md")) + assert len(command_files) == len(expected_commands) + actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files} + assert actual_commands == expected_commands + + def test_templates_are_processed(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # Check standard replacements + assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}" + # Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}} + assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" + # Frontmatter sections should be stripped + assert "\nscripts:\n" not in content + assert "\nagent_scripts:\n" not in content + + def test_forge_specific_transformations(self, tmp_path): + """Test Forge-specific processing: name injection and handoffs stripping.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + + # Check that name field is injected in frontmatter + assert "\nname: " in content, f"{cmd_file.name} missing injected 'name' field" + + # Check that handoffs frontmatter key is stripped + assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key" + + def test_uses_parameters_placeholder(self, tmp_path): + """Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + + # The registrar_config should specify {{parameters}} + assert forge.registrar_config["args"] == "{{parameters}}" + + # Generate files and verify $ARGUMENTS is replaced with {{parameters}} + from specify_cli.integrations.manifest import IntegrationManifest + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + # Check all generated command files + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # $ARGUMENTS should be replaced with {{parameters}} + assert "$ARGUMENTS" not in content, ( + f"{cmd_file.name} still contains $ARGUMENTS - it should be replaced with {{{{parameters}}}}" + ) + # At least some files should have {{parameters}} (those with user input sections) + # We'll check the checklist file specifically as it has a User Input section + + # Verify checklist specifically has {{parameters}} in the User Input section + checklist = commands_dir / "speckit.checklist.md" + if checklist.exists(): + content = checklist.read_text(encoding="utf-8") + assert "{{parameters}}" in content, ( + "checklist should contain {{parameters}} in User Input section" + ) From cb508d7a364413f4dad7cb7d643b516987b67f6b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:42:43 -0500 Subject: [PATCH 180/321] chore: release 0.5.0, begin 0.5.1.dev0 development (#2070) * chore: bump version to 0.5.0 * chore: begin 0.5.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e9ea9e81..2237f7fbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## [0.5.0] - 2026-04-02 + +### Changed + +- Introduces DEVELOPMENT.md (#2069) +- Update cc-sdd reference to cc-spex in Community Friends (#2007) +- chore: release 0.4.5, begin 0.4.6.dev0 development (#2064) + ## [0.4.5] - 2026-04-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 3c476f22c6..4eb8b2f978 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.6.dev0" +version = "0.5.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From d40c9a6428731678d40c3643ee58ffb8ec09c3e6 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:57:03 +0200 Subject: [PATCH 181/321] feat: add spec-kit-fixit extension to community catalog (#2024) * feat: add spec-kit-fixit extension to community catalog * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix catalog format --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 890b1d2d6a..ab14ace731 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ The following community-contributed extensions are available in [`catalog.commun | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | +| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index dce3c06e2a..11100b91f0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -363,6 +363,37 @@ "created_at": "2026-04-01T00:00:00Z", "updated_at": "2026-04-01T00:00:00Z" }, + "fixit": { + "name": "FixIt Extension", + "id": "fixit", + "description": "Spec-aware bug fixing: maps bugs to spec artifacts, proposes a plan, applies minimal changes.", + "author": "ismaelJimenez", + "version": "1.0.0", + "download_url": "https://github.com/speckit-community/spec-kit-fixit/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/speckit-community/spec-kit-fixit", + "homepage": "https://github.com/speckit-community/spec-kit-fixit", + "documentation": "https://github.com/speckit-community/spec-kit-fixit/blob/main/README.md", + "changelog": "https://github.com/speckit-community/spec-kit-fixit/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "debugging", + "fixit", + "spec-alignment", + "post-implementation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-30T00:00:00Z", + "updated_at": "2026-03-30T00:00:00Z" + }, "fleet": { "name": "Fleet Orchestrator", "id": "fleet", From 87c9e1ce7583f4161b5bdebd1282b3c0967dcf9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:17:22 -0500 Subject: [PATCH 182/321] chore(deps): bump actions/configure-pages from 5 to 6 (#2071) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d714b16884..847f564557 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -47,7 +47,7 @@ jobs: docfx docfx.json - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload artifact uses: actions/upload-pages-artifact@v3 From fac8e59c02e1a47f26cef5a9f1f38881fc67356c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:19:42 -0500 Subject: [PATCH 183/321] chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 8.0.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/37802adc94f370d6bfd71619e3f0bf239e1f3b78...cec208311dfd045dd5311c1add060b2062131d57) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f45c5f1071..18b039f02b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - name: Set up Python uses: actions/setup-python@v6 @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From 48b84cc941e1ed7967c8edddc556922615acd446 Mon Sep 17 00:00:00 2001 From: Li-Xian Chen Date: Fri, 3 Apr 2026 22:17:31 +0900 Subject: [PATCH 184/321] Update conduct extension to v1.0.1 (#2078) --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 11100b91f0..65fcb9099b 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-03T12:35:01Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -172,8 +172,8 @@ "id": "conduct", "description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.", "author": "twbrandon7", - "version": "1.0.0", - "download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.1.zip", "repository": "https://github.com/twbrandon7/spec-kit-conduct-ext", "homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext", "documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md", @@ -195,7 +195,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-19T12:08:20Z", - "updated_at": "2026-03-19T12:08:20Z" + "updated_at": "2026-04-03T12:35:01Z" }, "critique": { "name": "Spec Critique Extension", From 10be4848684d0811b8e407fc76f1a6d7e8bb7f6c Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 3 Apr 2026 18:57:51 +0500 Subject: [PATCH 185/321] feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059) * feat: add argument-hint frontmatter to Claude Code commands (#1951) Inject argument-hint into YAML frontmatter for Claude agent only during release package generation. Templates remain agent-agnostic; hints are added on the fly in generate_commands() when agent is "claude". Closes #1951 Co-Authored-By: Claude Sonnet 4.6 * fix: scope argument-hint injection to YAML frontmatter only Addresses Copilot review: the awk/regex matched description: anywhere in the file. Now both bash and PowerShell track frontmatter boundaries (--- delimiters) and only inject argument-hint after the first description: inside the frontmatter block. Co-Authored-By: Claude Sonnet 4.6 * feat: add argument-hint to Claude integration + tests - Override setup() in ClaudeIntegration to inject argument-hint into YAML frontmatter after description: line, scoped to frontmatter only - Add ARGUMENT_HINTS mapping for all 9 commands - Add tests: hint presence, correct values, frontmatter scoping, ordering after description, and body-safety check Addresses maintainer feedback to cover the new integrations system in src/specify_cli/integrations/claude/__init__.py with tests in tests/integrations/test_integration_claude.py Co-Authored-By: Claude Sonnet 4.6 * fix: address Copilot review feedback on Claude integration - Remove unused `import re` - Skip injection if argument-hint already exists in frontmatter - Add found_description assertion to test_hint_appears_after_description - Add test_inject_argument_hint_skips_if_already_present test Co-Authored-By: Claude Sonnet 4.6 * refactor: delegate to super().setup() and post-process for hints - Eliminates setup() duplication by calling super().setup() then post-processing command files to inject argument-hint - Fixes EOL preservation to correctly detect \r\n vs \n - No drift risk if MarkdownIntegration.setup() changes Co-Authored-By: Claude Sonnet 4.6 * fix: use read_bytes/write_bytes for platform-stable EOL handling Address Copilot review: avoid platform newline translation by using read_bytes()/write_bytes() instead of read_text()/write_text() when post-processing SKILL.md files for argument-hint injection. * fix: re-record manifest hash after hint injection, quote hint values - Re-record file hash in manifest after writing argument-hint so check_modified()/uninstall stays in sync - Double-quote argument-hint values to match SKILL.md frontmatter style - Update tests to expect quoted hint values * fix: inject disable-model-invocation into Claude skill frontmatter --------- Co-authored-by: Claude Sonnet 4.6 --- .../integrations/claude/__init__.py | 176 +++++++++++++----- tests/integrations/test_integration_claude.py | 117 ++++++++++++ 2 files changed, 244 insertions(+), 49 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 4825720074..9eb3214614 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -10,6 +10,20 @@ from ..base import SkillsIntegration from ..manifest import IntegrationManifest +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Claude Code. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -30,10 +44,53 @@ class ClaudeIntegration(SkillsIntegration): } context_file = "CLAUDE.md" - def command_filename(self, template_name: str) -> str: - """Claude skills live at .claude/skills//SKILL.md.""" - skill_name = f"speckit-{template_name.replace('.', '-')}" - return f"{skill_name}/SKILL.md" + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content # already present + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + return "".join(out) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: """Render a processed command template as a Claude skill.""" @@ -54,6 +111,38 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: self.key, name, description, source ) + @staticmethod + def _inject_disable_model_invocation(content: str) -> str: + """Insert ``disable-model-invocation: true`` before the closing ``---``.""" + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("disable-model-invocation:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + eol = "\r\n" if line.endswith("\r\n") else "\n" + out.append(f"disable-model-invocation: true{eol}") + injected = True + out.append(line) + return "".join(out) + def setup( self, project_root: Path, @@ -61,49 +150,38 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills into .claude/skills.""" - templates = self.list_command_templates() - if not templates: - return [] - - project_root_resolved = project_root.resolve() - if manifest.project_root != project_root_resolved: - raise ValueError( - f"manifest.project_root ({manifest.project_root}) does not match " - f"project_root ({project_root_resolved})" - ) - - dest = self.skills_dest(project_root).resolve() - try: - dest.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Integration destination {dest} escapes " - f"project root {project_root_resolved}" - ) from exc - dest.mkdir(parents=True, exist_ok=True) - - script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - from specify_cli.agents import CommandRegistrar - registrar = CommandRegistrar() - created: list[Path] = [] - - for src_file in templates: - raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) - frontmatter, body = registrar.parse_frontmatter(processed) - if not isinstance(frontmatter, dict): - frontmatter = {} - - rendered = self._render_skill(src_file.stem, frontmatter, body) - dst_file = self.write_file_and_record( - rendered, - dest / self.command_filename(src_file.stem), - project_root, - manifest, - ) - created.append(dst_file) - - created.extend(self.install_scripts(project_root, manifest)) + """Install Claude skills, then inject argument-hint and disable-model-invocation.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + # Inject disable-model-invocation: true (Claude skills run only when invoked) + updated = self._inject_disable_model_invocation(content) + + # Inject argument-hint if available for this skill + skill_dir_name = path.parent.name # e.g. "speckit-plan" + stem = skill_dir_name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + return created diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 998485469f..fe50eecc70 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -8,6 +8,7 @@ from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.claude import ARGUMENT_HINTS from specify_cli.integrations.manifest import IntegrationManifest @@ -279,3 +280,119 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): metadata = manager.registry.get("claude-skill-command") assert "speckit-research" in metadata.get("registered_skills", []) + + +class TestClaudeArgumentHints: + """Verify that argument-hint frontmatter is injected for Claude skills.""" + + def test_all_skills_have_hints(self, tmp_path): + """Every generated SKILL.md must contain an argument-hint line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "argument-hint:" in content, ( + f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" + ) + + def test_hints_match_expected_values(self, tmp_path): + """Each skill's argument-hint must match the expected text.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + # Extract stem: speckit-plan -> plan + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + expected_hint = ARGUMENT_HINTS.get(stem) + assert expected_hint is not None, ( + f"No expected hint defined for skill '{stem}'" + ) + content = f.read_text(encoding="utf-8") + assert f'argument-hint: "{expected_hint}"' in content, ( + f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" + ) + + def test_hint_is_inside_frontmatter(self, tmp_path): + """argument-hint must appear between the --- delimiters, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" + frontmatter = parts[1] + body = parts[2] + assert "argument-hint:" in frontmatter, ( + f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.parent.name}/SKILL.md: argument-hint leaked into body" + ) + + def test_hint_appears_after_description(self, tmp_path): + """argument-hint must immediately follow the description line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + lines = content.splitlines() + found_description = False + for idx, line in enumerate(lines): + if line.startswith("description:"): + found_description = True + assert idx + 1 < len(lines), ( + f"{f.parent.name}/SKILL.md: description is last line" + ) + assert lines[idx + 1].startswith("argument-hint:"), ( + f"{f.parent.name}/SKILL.md: argument-hint does not follow description" + ) + break + assert found_description, ( + f"{f.parent.name}/SKILL.md: no description: line found in output" + ) + + def test_inject_argument_hint_only_in_frontmatter(self): + """inject_argument_hint must not modify description: lines in the body.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + "---\n" + "\n" + "description: this is body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "Test hint") + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1, ( + f"Expected exactly 1 argument-hint line, found {hint_count}" + ) + + def test_inject_argument_hint_skips_if_already_present(self): + """inject_argument_hint must not duplicate if argument-hint already exists.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + 'argument-hint: "Existing hint"\n' + "---\n" + "\n" + "Body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "New hint") + assert result == content, "Content should be unchanged when hint already exists" + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1 From 8353830f97a8cd4a63933e8123f2ba1c45923ef4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:08:04 -0500 Subject: [PATCH 186/321] fix: add actions:write permission to stale workflow (#2079) The actions/stale@v10 action uses GitHub Actions cache to persist state across runs. Without the actions:write permission, the action can write cache entries but cannot delete them (403 error on cache cleanup). This causes a vicious cycle: once an issue is processed and cached, the action skips it on every future run with 'issue skipped due being processed during the previous run' - so stale issues never reach the closing logic after being marked stale. Adding actions:write allows the action to properly manage its cache lifecycle, enabling stale issues to be closed after the configured 30-day close window. --- .github/workflows/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f09d05f362..076d05336a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: # Allow manual triggering permissions: + actions: write issues: write pull-requests: write From 535ddbe0d25b6f7a8b4eafc9d948edad3a2d30f0 Mon Sep 17 00:00:00 2001 From: Radu Chindris Date: Fri, 3 Apr 2026 17:33:10 +0300 Subject: [PATCH 187/321] fix: add user-invocable: true to skill frontmatter (#2077) Skills were missing this field, causing them to be treated as "managed" instead of user-invocable via /speckit-* commands. --- src/specify_cli/agents.py | 4 +++- tests/integrations/test_integration_claude.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4f6714ed8b..bb25b3fc1a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -275,7 +275,9 @@ def build_skill_frontmatter( }, } if agent_name == "claude": - # Claude skills should only run when explicitly invoked. + # Claude skills should be user-invocable (accessible via /command) + # and only run when explicitly invoked (not auto-triggered by the model). + skill_frontmatter["user-invocable"] = True skill_frontmatter["disable-model-invocation"] = True return skill_frontmatter diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index fe50eecc70..7fd69df176 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -58,6 +58,7 @@ def test_setup_creates_skill_files(self, tmp_path): parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True assert parsed["disable-model-invocation"] is True assert parsed["metadata"]["source"] == "templates/commands/plan.md" @@ -176,7 +177,9 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" assert skill_file.exists() - assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8") + skill_content = skill_file.read_text(encoding="utf-8") + assert "user-invocable: true" in skill_content + assert "disable-model-invocation: true" in skill_content init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") @@ -276,6 +279,7 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): content = skill_file.read_text(encoding="utf-8") assert "preset:claude-skill-command" in content assert "name: speckit-research" in content + assert "user-invocable: true" in content assert "disable-model-invocation: true" in content metadata = manager.registry.get("claude-skill-command") From e1ab4f0486cdeb864217c0517adbc6d2114eba89 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:48:39 -0500 Subject: [PATCH 188/321] Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081) * Remove Template Version and Released from version output Templates are now bundled with the CLI, so showing them as separate artifacts with their own version and release date is no longer accurate. This also removes the GitHub API call that fetched the latest release, making the version command faster and eliminating a network dependency. * Remove unused datetime import * fix: inject user-invocable: true into Claude skill frontmatter The SkillsIntegration.setup() builds frontmatter manually without user-invocable. Add post-processing injection in ClaudeIntegration.setup(), matching the existing pattern for disable-model-invocation. * refactor: address review feedback - Factor _inject_user_invocable and _inject_disable_model_invocation into a shared _inject_frontmatter_flag(key, value) helper - Remove unused httpx, ssl, truststore imports and globals - Remove unused _github_token and _github_auth_headers helpers - Update setup() docstring to mention user-invocable * chore: remove httpx and truststore from dependencies Both are no longer used after removing the GitHub API call from the version command. Removes from PEP 723 script header and pyproject.toml. * fix: match EOL detection style in _inject_frontmatter_flag Handle \r\n, \n, and no-newline cases consistently with inject_argument_hint's pattern. --- pyproject.toml | 2 - src/specify_cli/__init__.py | 51 ------------------- .../integrations/claude/__init__.py | 22 +++++--- 3 files changed, 15 insertions(+), 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4eb8b2f978..02a4cdd7dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,8 @@ dependencies = [ "typer", "click>=8.1", "rich", - "httpx[socks]", "platformdirs", "readchar", - "truststore>=0.10.4", "pyyaml>=6.0", "packaging>=23.0", "pathspec>=0.12.0", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index fbe1bc033f..28831e6cd2 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -6,7 +6,6 @@ # "rich", # "platformdirs", # "readchar", -# "httpx", # "json5", # ] # /// @@ -39,7 +38,6 @@ from typing import Any, Optional, Tuple import typer -import httpx from rich.console import Console from rich.panel import Panel from rich.text import Text @@ -51,21 +49,6 @@ # For cross-platform keyboard input import readchar -import ssl -import truststore -from datetime import datetime - -ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) -client = httpx.Client(verify=ssl_context) - -def _github_token(cli_token: str | None = None) -> str | None: - """Return sanitized GitHub token (cli arg takes precedence) or None.""" - return ((cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip()) or None - -def _github_auth_headers(cli_token: str | None = None) -> dict: - """Return Authorization header dict only when a non-empty token exists.""" - token = _github_token(cli_token) - return {"Authorization": f"Bearer {token}"} if token else {} def _build_agent_config() -> dict[str, dict[str, Any]]: """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" @@ -1429,45 +1412,11 @@ def version(): except Exception: pass - # Fetch latest template release version - repo_owner = "github" - repo_name = "spec-kit" - api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - - template_version = "unknown" - release_date = "unknown" - - try: - response = client.get( - api_url, - timeout=10, - follow_redirects=True, - headers=_github_auth_headers(), - ) - if response.status_code == 200: - release_data = response.json() - template_version = release_data.get("tag_name", "unknown") - # Remove 'v' prefix if present - if template_version.startswith("v"): - template_version = template_version[1:] - release_date = release_data.get("published_at", "unknown") - if release_date != "unknown": - # Format the date nicely - try: - dt = datetime.fromisoformat(release_date.replace('Z', '+00:00')) - release_date = dt.strftime("%Y-%m-%d") - except Exception: - pass - except Exception: - pass - info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") info_table.add_column("Value", style="white") info_table.add_row("CLI Version", cli_version) - info_table.add_row("Template Version", template_version) - info_table.add_row("Released", release_date) info_table.add_row("", "") info_table.add_row("Python", platform.python_version()) info_table.add_row("Platform", platform.system()) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 9eb3214614..31972c4b0e 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -112,8 +112,8 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: ) @staticmethod - def _inject_disable_model_invocation(content: str) -> str: - """Insert ``disable-model-invocation: true`` before the closing ``---``.""" + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Insert ``key: value`` before the closing ``---`` if not already present.""" lines = content.splitlines(keepends=True) # Pre-scan: bail out if already present in frontmatter @@ -125,7 +125,7 @@ def _inject_disable_model_invocation(content: str) -> str: if dash_count == 2: break continue - if dash_count == 1 and stripped.startswith("disable-model-invocation:"): + if dash_count == 1 and stripped.startswith(f"{key}:"): return content # Inject before the closing --- of frontmatter @@ -137,8 +137,13 @@ def _inject_disable_model_invocation(content: str) -> str: if stripped == "---": dash_count += 1 if dash_count == 2 and not injected: - eol = "\r\n" if line.endswith("\r\n") else "\n" - out.append(f"disable-model-invocation: true{eol}") + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") injected = True out.append(line) return "".join(out) @@ -150,7 +155,7 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject argument-hint and disable-model-invocation.""" + """Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint.""" created = super().setup(project_root, manifest, parsed_options, **opts) # Post-process generated skill files @@ -168,8 +173,11 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") + # Inject user-invocable: true (Claude skills are accessible via /command) + updated = self._inject_frontmatter_flag(content, "user-invocable") + # Inject disable-model-invocation: true (Claude skills run only when invoked) - updated = self._inject_disable_model_invocation(content) + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation") # Inject argument-hint if available for this skill skill_dir_name = path.parent.name # e.g. "speckit-plan" From 94ba857b78074024f095b08b45802e17d2bff653 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:35:04 -0500 Subject: [PATCH 189/321] Add `specify integration` subcommand for post-init integration management (#2083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add specify integration subcommand (list, install, uninstall, switch) Implements the `specify integration` subcommand group for managing integrations in existing projects after initial setup: - `specify integration list` — shows available integrations and installed status - `specify integration install ` — installs an integration into existing project - `specify integration uninstall [key]` — hash-safe removal preserving modified files - `specify integration switch ` — uninstalls current, installs target Follows the established `specify ` CLI pattern used by extensions and presets. Shared infrastructure (scripts, templates) is preserved during uninstall and switch operations. Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1cca6c84-3e12-465d-88b8-a646d3504f63 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Address review feedback: extract helper, fix return type annotation - Extract _update_init_options_for_integration() to deduplicate init-options update logic between install and switch commands - Fix _parse_integration_options return type to dict[str, Any] | None Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1cca6c84-3e12-465d-88b8-a646d3504f63 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Address review feedback: validate script type, handle --flag=value, fix metadata cleanup - Add _normalize_script_type() to validate script type against SCRIPT_TYPE_CHOICES - Handle --name=value syntax in _parse_integration_options() - Clear init-options.json keys in no-manifest uninstall early-return path - Clear stale metadata between switch teardown and install phases - Add 5 tests covering the new edge cases * Block --force with different integration, persist script type in init-options - --force on install now rejects overwriting a different integration; users must use 'specify integration switch' instead - _update_init_options_for_integration() now accepts and persists script_type - Fix misleading test docstring for switch metadata test - Add test_force_blocked_with_different_integration * Remove --force from integration install, ensure shared infra on install/switch - Remove --force parameter entirely from integration install; users must uninstall before reinstalling to prevent orphaned files - Auto-install shared infrastructure (.specify/scripts/, .specify/templates/) when missing during install or switch - Add test for shared infra creation on bare project install * Remove redundant installed_key != key check The == key case already returns above, so the != key guard is always true at this point. Simplify to just 'if installed_key:'. * Run shared infra unconditionally, defer metadata removal in switch - Call _install_shared_infra() unconditionally on install and switch since it merges without overwriting existing files - Remove premature metadata cleanup between switch phases; metadata is now only updated after successful Phase 2 install * Add install rollback, graceful manifest errors, clear switch metadata - Attempt teardown rollback on install/switch failure to avoid orphaned files - Catch ValueError/FileNotFoundError on IntegrationManifest.load() in uninstall with user-friendly recovery guidance - Clear metadata immediately after switch teardown so failed Phase 2 doesn't leave stale references to the removed integration * Log rollback failures instead of silently suppressing them * Handle corrupt manifest in switch, distinguish unknown vs missing manifest - Wrap IntegrationManifest.load() in switch with ValueError/FileNotFoundError handling, matching the pattern used in uninstall - Split else branch to report 'unknown integration' vs 'no manifest' separately * Clean up metadata on rollback, broaden init-options match in uninstall - Remove integration.json in install/switch rollback paths so failed installs don't leave stale metadata - Match on both 'integration' and 'ai' keys when clearing init-options.json during uninstall to handle partially-written metadata * Fix recovery guidance for unreadable manifests, fix type annotations - Recovery instructions now guide users through delete manifest → uninstall → reinstall workflow that actually works - Type annotations for optional CLI parameters changed from str to str | None * Allow manifest-only uninstall for unknown/removed integrations - Uninstall no longer requires the integration to be in the registry; falls back to manifest.uninstall() directly when get_integration() returns None - Switch Phase 1 similarly uses manifest-only uninstall for unknown integrations instead of skipping teardown, preventing orphaned files * Fail fast on corrupt integration.json, validate integration options - _read_integration_json() now exits with an actionable error when integration.json exists but is corrupt/unreadable - _parse_integration_options() rejects unknown options, validates flag usage, and requires values for non-flag options * Validate integration.json is a dict, fail fast on missing manifest in switch - _read_integration_json() validates parsed JSON is a dict, not a list/string - Switch fails fast with recovery guidance when manifest is missing instead of silently skipping teardown and risking co-existing integration files --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/specify_cli/__init__.py | 487 ++++++++++++++++ .../test_integration_subcommand.py | 540 ++++++++++++++++++ 2 files changed, 1027 insertions(+) create mode 100644 tests/integrations/test_integration_subcommand.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 28831e6cd2..455beea38e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1486,6 +1486,493 @@ def get_speckit_version() -> str: return "unknown" +# ===== Integration Commands ===== + +integration_app = typer.Typer( + name="integration", + help="Manage AI agent integrations", + add_completion=False, +) +app.add_typer(integration_app, name="integration") + + +INTEGRATION_JSON = ".specify/integration.json" + + +def _read_integration_json(project_root: Path) -> dict[str, Any]: + """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + path = project_root / INTEGRATION_JSON + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + console.print(f"[red]Error:[/red] {path} contains invalid JSON.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + except OSError as exc: + console.print(f"[red]Error:[/red] Could not read {path}.") + console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + if not isinstance(data, dict): + console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + raise typer.Exit(1) + return data + + +def _write_integration_json( + project_root: Path, + integration_key: str, + script_type: str, +) -> None: + """Write ``.specify/integration.json`` for *integration_key*.""" + script_ext = "sh" if script_type == "sh" else "ps1" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps({ + "integration": integration_key, + "version": get_speckit_version(), + "scripts": { + "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", + }, + }, indent=2) + "\n", encoding="utf-8") + + +def _remove_integration_json(project_root: Path) -> None: + """Remove ``.specify/integration.json`` if it exists.""" + path = project_root / INTEGRATION_JSON + if path.exists(): + path.unlink() + + +def _normalize_script_type(script_type: str, source: str) -> str: + """Normalize and validate a script type from CLI/config sources.""" + normalized = script_type.strip().lower() + if normalized in SCRIPT_TYPE_CHOICES: + return normalized + console.print( + f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " + f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." + ) + raise typer.Exit(1) + + +def _resolve_script_type(project_root: Path, script_type: str | None) -> str: + """Resolve the script type from the CLI flag or init-options.json.""" + if script_type: + return _normalize_script_type(script_type, "--script") + opts = load_init_options(project_root) + saved = opts.get("script") + if isinstance(saved, str) and saved.strip(): + return _normalize_script_type(saved, ".specify/init-options.json") + return "ps" if os.name == "nt" else "sh" + + +@integration_app.command("list") +def integration_list(): + """List available integrations and installed status.""" + from .integrations import INTEGRATION_REGISTRY + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + table = Table(title="AI Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) + + if key == installed_key: + status = "[green]installed[/green]" + else: + status = "" + + cli_req = "yes" if requires_cli else "no (IDE)" + table.add_row(key, name, status, cli_req) + + console.print(table) + + if installed_key: + console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") + + +@integration_app.command("install") +def integration_install( + key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), +): + """Install an integration into an existing project.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key and installed_key == key: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + raise typer.Exit(0) + + if installed_key: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") + console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=get_speckit_version() + ) + + # Build parsed options from --integration-options + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, integration.key, selected_script) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration: {e}") + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + + +def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: + """Parse --integration-options string into a dict matching the integration's declared options. + + Returns ``None`` when no options are provided. + """ + import shlex + parsed: dict[str, Any] = {} + tokens = shlex.split(raw_options) + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = ", ".join(sorted(opt.name for opt in declared_options)) + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + name = token.lstrip("-") + value: str | None = None + # Handle --name=value syntax + if "=" in name: + name, value = name.split("=", 1) + opt = declared.get(name) + if not opt: + console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + key = name.replace("-", "_") + if opt.is_flag: + if value is not None: + console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") + raise typer.Exit(1) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 + else: + console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") + raise typer.Exit(1) + return parsed or None + + +def _update_init_options_for_integration( + project_root: Path, + integration: Any, + script_type: str | None = None, +) -> None: + """Update ``init-options.json`` to reflect *integration* as the active one.""" + from .integrations.base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration): + opts["ai_skills"] = True + else: + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), +): + """Uninstall an integration, safely preserving modified files.""" + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if installed_key and installed_key != key: + console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + raise typer.Exit(1) + + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + _remove_integration_json(project_root) + # Clear integration-related keys from init-options.json + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + + removed, skipped = manifest.uninstall(project_root, force=force) + + _remove_integration_json(project_root) + + # Update init-options.json to clear the integration + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = path.relative_to(project_root) if path.is_absolute() else path + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), +): + """Switch from the current integration to a different one.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key == target: + console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + selected_script = _resolve_script_type(project_root, script) + + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except (ValueError, FileNotFoundError) as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + _remove_integration_json(project_root) + opts = load_init_options(project_root) + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) + + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, target_integration.key, selected_script) + _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + # ===== Preset Commands ===== diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py new file mode 100644 index 0000000000..f5322bdf5e --- /dev/null +++ b/tests/integrations/test_integration_subcommand.py @@ -0,0 +1,540 @@ +"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch).""" + +import json +import os + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +def _init_project(tmp_path, integration="copilot"): + """Helper: init a spec-kit project with the given integration.""" + project = tmp_path / "proj" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + return project + + +# ── list ───────────────────────────────────────────────────────────── + + +class TestIntegrationList: + def test_list_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_list_shows_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + def test_list_shows_available_integrations(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Should show multiple integrations + assert "claude" in result.output + assert "gemini" in result.output + + +# ── install ────────────────────────────────────────────────────────── + + +class TestIntegrationInstall: + def test_install_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_install_unknown_integration(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_install_already_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "already installed" in result.output + assert "uninstall" in result.output + + def test_install_different_when_one_exists(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "already installed" in result.output + assert "uninstall" in result.output + + def test_install_into_bare_project(self, tmp_path): + """Install into a project with .specify/ but no integration.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + # integration.json written + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + # Manifest created + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # Claude uses skills directory (not commands) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_bare_project_gets_shared_infra(self, tmp_path): + """Installing into a bare project should create shared scripts and templates.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + # Shared infrastructure should be present + assert (project / ".specify" / "scripts").is_dir() + assert (project / ".specify" / "templates").is_dir() + + +# ── uninstall ──────────────────────────────────────────────────────── + + +class TestIntegrationUninstall: + def test_uninstall_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_uninstall_no_integration(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "No integration" in result.output + + def test_uninstall_removes_files(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Claude uses skills directory + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "uninstalled" in result.output + + # Command files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # Manifest removed + assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # integration.json removed + assert not (project / ".specify" / "integration.json").exists() + + def test_uninstall_preserves_modified_files(self, tmp_path): + """Full lifecycle: install → modify → uninstall → modified file kept.""" + project = _init_project(tmp_path, "claude") + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify a file + plan_file.write_text("# My custom plan command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "preserved" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n" + + def test_uninstall_wrong_key(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not the currently installed" in result.output + + def test_uninstall_preserves_shared_infra(self, tmp_path): + """Shared scripts and templates are not removed by integration uninstall.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_script.exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Shared infrastructure preserved + assert shared_script.exists() + assert (project / ".specify" / "templates").is_dir() + + +# ── switch ─────────────────────────────────────────────────────────── + + +class TestIntegrationSwitch: + def test_switch_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "switch", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_switch_unknown_target(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_switch_same_noop(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "already installed" in result.output + + def test_switch_between_integrations(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Verify claude files exist (claude uses skills) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "Switched to" in result.output + + # Old claude files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # New copilot files created + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + # integration.json updated + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + def test_switch_preserves_shared_infra(self, tmp_path): + """Switching preserves shared scripts, templates, and memory.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_script.exists() + shared_content = shared_script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Shared infra untouched + assert shared_script.exists() + assert shared_script.read_text(encoding="utf-8") == shared_content + + def test_switch_from_nothing(self, tmp_path): + """Switch when no integration is installed should just install the target.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "Switched to" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + +# ── Full lifecycle ─────────────────────────────────────────────────── + + +class TestIntegrationLifecycle: + def test_install_modify_uninstall_preserves_modified(self, tmp_path): + """Full lifecycle: install → modify file → uninstall → verify modified file kept.""" + project = tmp_path / "lifecycle" + project.mkdir() + (project / ".specify").mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(project) + + # Install + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert result.exit_code == 0 + assert "installed successfully" in result.output + + # Claude uses skills directory + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify one file + plan_file.write_text("# user customization\n", encoding="utf-8") + + # Uninstall + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + assert result.exit_code == 0 + assert "preserved" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# user customization\n" + finally: + os.chdir(old_cwd) + + +# ── Edge-case fixes ───────────────────────────────────────────────── + + +class TestScriptTypeValidation: + def test_invalid_script_type_rejected(self, tmp_path): + """--script with an invalid value should fail with a clear error.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "bash", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Invalid script type" in result.output + + def test_valid_script_types_accepted(self, tmp_path): + """Both 'sh' and 'ps' should be accepted.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + +class TestParseIntegrationOptionsEqualsForm: + def test_equals_form_parsed(self): + """--commands-dir=./x should be parsed the same as --commands-dir ./x.""" + from specify_cli import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + result_space = _parse_integration_options(integration, "--commands-dir ./mydir") + result_equals = _parse_integration_options(integration, "--commands-dir=./mydir") + assert result_space is not None + assert result_equals is not None + assert result_space["commands_dir"] == "./mydir" + assert result_equals["commands_dir"] == "./mydir" + + +class TestUninstallNoManifestClearsInitOptions: + def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): + """When no manifest exists, uninstall should still clear init-options.json.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + + # Write integration.json and init-options.json without a manifest + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8") + + opts_json = project / ".specify" / "init-options.json" + opts_json.write_text(json.dumps({ + "integration": "claude", + "ai": "claude", + "ai_skills": True, + "script": "sh", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # init-options.json should have integration keys cleared + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert "integration" not in opts + assert "ai" not in opts + assert "ai_skills" not in opts + # Non-integration keys preserved + assert opts.get("script") == "sh" + + +class TestSwitchClearsMetadataAfterTeardown: + def test_metadata_cleared_between_phases(self, tmp_path): + """After a successful switch, metadata should reference the new integration.""" + project = _init_project(tmp_path, "claude") + + # Verify initial state + int_json = project / ".specify" / "integration.json" + assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude" + + old_cwd = os.getcwd() + try: + os.chdir(project) + # Switch to copilot — should succeed and update metadata + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # integration.json should reference copilot, not claude + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + # init-options.json should reference copilot + opts_json = project / ".specify" / "init-options.json" + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert opts.get("ai") == "copilot" From 5678ca7757d3c0a5b485b3663bd5a1a047d28dab Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 6 Apr 2026 19:51:44 +0700 Subject: [PATCH 190/321] Add security-review v1.1.1 to community extensions catalog (#2073) * Add security-review v1.1.0 to community catalog * Format README and community catalog entries * Set security-review author to DyanGalih * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump security-review to v1.1.1 * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix linting: use asterisk emphasis, fix architecuture typo * Revert "Format README and community catalog entries" This reverts commit 32e7471127c65a63402cd7fb6901f1ab6e4f3b40. * Restore README table to upstream format --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab14ace731..6ba2b7c5d5 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ The following community-contributed extensions are available in [`catalog.commun | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | +| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 65fcb9099b..3c730f284b 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -975,7 +975,7 @@ "repoindex":{ "name": "Repository Index", "id": "repoindex", - "description": "Generate index of your repo for overview, architecuture and module", + "description": "Generate index of your repo for overview, architecture and module", "author": "Yiyu Liu", "version": "1.0.0", "download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip", @@ -1105,6 +1105,38 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-06T00:00:00Z" }, + "security-review": { + "name": "Security Review", + "id": "security-review", + "description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis", + "author": "DyanGalih", + "version": "1.1.1", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip", + "repository": "https://github.com/DyanGalih/spec-kit-security-review", + "homepage": "https://github.com/DyanGalih/spec-kit-security-review", + "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "security", + "devsecops", + "audit", + "owasp", + "compliance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-03T03:24:03Z", + "updated_at": "2026-04-03T04:15:00Z" + }, "ship": { "name": "Ship Release Extension", "id": "ship", From 7dc493e6136c920b4e14e0715070e8703cec3bfc Mon Sep 17 00:00:00 2001 From: "F.D.Castel" Date: Mon, 6 Apr 2026 10:03:31 -0300 Subject: [PATCH 191/321] feat: add "VS Code Ask Questions" preset (#2086) * feat: add "VS Code Ask Questions" preset for enhanced interactive questioning * fix: address PR review feedback from Copilot --- README.md | 1 + presets/catalog.community.json | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ba2b7c5d5..52f31641f1 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ The following community-contributed presets customize how Spec Kit behaves — o |--------|---------|----------|----------|-----| | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 9f7f045b31..3bcd325aec 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-24T00:00:00Z", + "updated_at": "2026-04-04T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -53,6 +53,31 @@ "fun", "experimental" ] + }, + "vscode-ask-questions": { + "name": "VS Code Ask Questions", + "id": "vscode-ask-questions", + "version": "1.0.0", + "description": "Enhances the clarify command to use vscode/askQuestions for batched interactive questioning, reducing API request costs in GitHub Copilot.", + "author": "fdcastel", + "repository": "https://github.com/fdcastel/spec-kit-presets", + "download_url": "https://github.com/fdcastel/spec-kit-presets/releases/download/vscode-ask-questions-v1.0.0/vscode-ask-questions.zip", + "homepage": "https://github.com/fdcastel/spec-kit-presets", + "documentation": "https://github.com/fdcastel/spec-kit-presets/blob/main/vscode-ask-questions/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "vscode", + "askquestions", + "clarify", + "interactive" + ] } } } From d9e63a51f1d8b5570235756faf443c41feac1641 Mon Sep 17 00:00:00 2001 From: Sakit Date: Mon, 6 Apr 2026 06:06:43 -0700 Subject: [PATCH 192/321] Add optimize extension to community catalog (#2088) - Extension ID: optimize - Version: 1.0.0 - Author: sakitA - Description: Audits and optimizes AI governance for context efficiency --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 52f31641f1..fec35f5643 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | +| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3c730f284b..6c52b55b60 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -776,6 +776,38 @@ "created_at": "2026-03-26T00:00:00Z", "updated_at": "2026-03-26T00:00:00Z" }, + "optimize": { + "name": "Optimize Extension", + "id": "optimize", + "description": "Audits and optimizes AI governance for context efficiency", + "author": "sakitA", + "version": "1.0.0", + "download_url": "https://github.com/sakitA/spec-kit-optimize/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/sakitA/spec-kit-optimize", + "homepage": "https://github.com/sakitA/spec-kit-optimize", + "documentation": "https://github.com/sakitA/spec-kit-optimize/blob/main/README.md", + "changelog": "https://github.com/sakitA/spec-kit-optimize/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "constitution", + "optimization", + "token-budget", + "governance", + "audit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z" + }, "plan-review-gate": { "name": "Plan Review Gate", "id": "plan-review-gate", From 4178b6182894c69be2cb7cd1b196f19b723ca88e Mon Sep 17 00:00:00 2001 From: Adam Boczek Date: Mon, 6 Apr 2026 15:09:50 +0200 Subject: [PATCH 193/321] fix(scripts): improve git branch creation error handling (#2089) * fix(scripts): improve git branch creation error handling - Capture git checkout -b stderr for meaningful error reporting - Skip redundant checkout when already on target branch - Surface actual git error messages instead of generic fallback Applies to both bash and PowerShell create-new-feature scripts. * fix(scripts): improve git branch creation error handling - Capture git checkout -b stderr for meaningful error reporting - Skip redundant checkout when already on target branch - Surface actual git error messages instead of generic fallback Applies to both bash and PowerShell create-new-feature scripts. * fix(scripts): use quiet mode for git checkout -b when capturing errors Ensures branch_create_error is empty on success, matching variable semantics. --- scripts/bash/create-new-feature.sh | 18 +++++++++++---- scripts/powershell/create-new-feature.ps1 | 28 ++++++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 36ea537991..f9ba9545df 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -327,12 +327,17 @@ SPEC_FILE="$FEATURE_DIR/spec.md" if [ "$DRY_RUN" != true ]; then if [ "$HAS_GIT" = true ]; then - if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" # Check if branch already exists if git branch --list "$BRANCH_NAME" | grep -q .; then if [ "$ALLOW_EXISTING" = true ]; then - # Switch to the existing branch instead of failing - if ! git checkout "$BRANCH_NAME" 2>/dev/null; then + # If we're already on the branch, continue without another checkout. + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + # Otherwise switch to the existing branch instead of failing. + elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." exit 1 fi @@ -344,7 +349,12 @@ if [ "$DRY_RUN" != true ]; then exit 1 fi else - >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi exit 1 fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2cfa351399..3e7e525b86 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -293,25 +293,33 @@ $specFile = Join-Path $featureDir 'spec.md' if (-not $DryRun) { if ($hasGit) { $branchCreated = $false + $branchCreateError = '' try { - git checkout -q -b $branchName 2>$null | Out-Null + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String if ($LASTEXITCODE -eq 0) { $branchCreated = $true } } catch { - # Exception during git command + $branchCreateError = $_.Exception.Message } if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} # Check if branch already exists $existingBranch = git branch --list $branchName 2>$null if ($existingBranch) { if ($AllowExistingBranch) { - # Switch to the existing branch instead of failing - git checkout -q $branchName 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." - exit 1 + # If we're already on the branch, continue without another checkout. + if ($currentBranch -eq $branchName) { + # Already on the target branch — nothing to do + } else { + # Otherwise switch to the existing branch instead of failing. + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } } } elseif ($Timestamp) { Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." @@ -321,7 +329,11 @@ if (-not $DryRun) { exit 1 } } else { - Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } exit 1 } } From f92e7e80961b89cb953fb254c56ddcd20d4f3cc8 Mon Sep 17 00:00:00 2001 From: alex-zwingli Date: Mon, 6 Apr 2026 06:26:26 -0700 Subject: [PATCH 194/321] fix: accept 4+ digit spec numbers in tests and docs (#2094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test assertions in test_timestamp_branches.py used the regex `\d{3}` (exactly 3 digits) instead of `\d{3,}` (3 or more digits). While the underlying shell scripts already handle spec numbers ≥ 1000 correctly — printf "%03d" and PowerShell '{0:000}' both expand naturally beyond 3 digits, and all detection regexes use {3,} — the overly-strict test assertions would fail with a misleading error if a fixture ever contained 1000+ spec directories. Documentation in README.md, spec-driven.md, and the CLI --branch-numbering help text implied that sequential spec numbers are always 3 digits, which could lead users to believe a hard limit of 999 exists. Changes: - tests/test_timestamp_branches.py: change two \d{3} assertions to \d{3,} - src/specify_cli/__init__.py: clarify help text to show numbers expand past 999 - README.md: update --branch-numbering docs to note numbers expand beyond 3 digits - spec-driven.md: update feature numbering description to include 4-digit example Fixes #2093 Co-authored-by: alex-zwingli Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- spec-driven.md | 2 +- src/specify_cli/__init__.py | 2 +- tests/test_timestamp_branches.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fec35f5643..e8121564e9 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ The `specify` command supports the following options: | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | | `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | -| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | +| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`, …, `1000`, … — expands beyond 3 digits automatically) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | ### Examples diff --git a/spec-driven.md b/spec-driven.md index 70b9789708..090071a3c4 100644 --- a/spec-driven.md +++ b/spec-driven.md @@ -78,7 +78,7 @@ The SDD methodology is significantly enhanced through three powerful commands th This command transforms a simple feature description (the user-prompt) into a complete, structured specification with automatic repository management: -1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003) +1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003, …, 1000 — expands beyond 3 digits automatically) 2. **Branch Creation**: Generates a semantic branch name from your description and creates it automatically 3. **Template-Based Generation**: Copies and customizes the feature specification template with your requirements 4. **Directory Structure**: Creates the proper `specs/[branch-name]/` structure for all related documents diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 455beea38e..1c62a4a3be 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -840,7 +840,7 @@ def init( ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), - branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), + branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index edc93fb39e..2c13853119 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -134,7 +134,7 @@ def test_sequential_default_with_existing_specs(self, git_repo: Path): if line.startswith("BRANCH_NAME:"): branch = line.split(":", 1)[1].strip() assert branch is not None - assert re.match(r"^\d{3}-new-feat$", branch), f"unexpected branch: {branch}" + assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}" def test_sequential_ignores_timestamp_dirs(self, git_repo: Path): """Sequential numbering skips timestamp dirs when computing next number.""" @@ -289,7 +289,7 @@ def test_e2e_sequential(self, git_repo: Path): capture_output=True, text=True, ).stdout.strip() - assert re.match(r"^\d{3}-seq-feat$", branch), f"branch: {branch}" + assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}" assert (git_repo / "specs" / branch).is_dir() val = source_and_call(f'check_feature_branch "{branch}" "true"') assert val.returncode == 0 From 9c0be46006ec4ac7ccb24dc2880732d1e6100429 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Mon, 6 Apr 2026 06:28:41 -0700 Subject: [PATCH 195/321] Add Confluence extension (#2028) * Add Confluence extension * Updated latest available version to v.1.1.0 --------- Co-authored-by: Aaron Sun --- README.md | 1 + extensions/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/README.md b/README.md index e8121564e9..667134a93c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The following community-contributed extensions are available in [`catalog.commun | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | +| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6c52b55b60..3dd17ec112 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -227,6 +227,34 @@ "created_at": "2026-04-01T00:00:00Z", "updated_at": "2026-04-01T00:00:00Z" }, + "confluence": { + "name": "Confluence Extension", + "id": "confluence", + "description": "Create, read, and update Confluence docs for your project", + "author": "aaronrsun", + "version": "1.1.0", + "download_url": "https://github.com/aaronrsun/spec-kit-confluence/archive/refs/tags/v1.1.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-confluence", + "homepage": "https://github.com/aaronrsun/spec-kit-confluence", + "documentation": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "confluence" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-29T00:00:00Z", + "updated_at": "2026-03-29T00:00:00Z" + }, "docguard": { "name": "DocGuard — CDD Enforcement", "id": "docguard", From 8b099585c762108511bbebb814a16b0271b6813f Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Mon, 6 Apr 2026 21:36:05 +0800 Subject: [PATCH 196/321] [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096) * fix: correct toml integration frontmatter handling * refactor: reuse frontmatter split in toml integration * fix: preserve toml integration string semantics * docs: align toml integration renderer docstring --- src/specify_cli/integrations/base.py | 123 ++++++++++++------ .../test_integration_base_toml.py | 77 ++++++++++- 2 files changed, 154 insertions(+), 46 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index dac5063f5c..0722b9a91e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -532,23 +532,83 @@ def command_filename(self, template_name: str) -> str: def _extract_description(content: str) -> str: """Extract the ``description`` value from YAML frontmatter. - Scans lines between the first pair of ``---`` delimiters for a - top-level ``description:`` key. Returns the value (with - surrounding quotes stripped) or an empty string if not found. + Parses the YAML frontmatter so block scalar descriptions (``|`` + and ``>``) keep their YAML semantics instead of being treated as + raw text. """ - in_frontmatter = False - for line in content.splitlines(): - stripped = line.rstrip("\n\r") - if stripped == "---": - if not in_frontmatter: - in_frontmatter = True - continue - break # second --- - if in_frontmatter and stripped.startswith("description:"): - _, _, value = stripped.partition(":") - return value.strip().strip('"').strip("'") + import yaml + + frontmatter_text, _ = TomlIntegration._split_frontmatter(content) + if not frontmatter_text: + return "" + try: + frontmatter = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return "" + + if not isinstance(frontmatter, dict): + return "" + + description = frontmatter.get("description", "") + if isinstance(description, str): + return description return "" + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining content. + + Returns ``("", content)`` when no complete frontmatter block is + present. The body is preserved exactly as written so prompt text + keeps its intended formatting. + """ + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _render_toml_string(value: str) -> str: + """Render *value* as a TOML string literal. + + Uses a basic string for single-line values, multiline basic + strings for values containing newlines, and falls back to a + literal string or escaped basic string when delimiters appear in + the content. + """ + if "\n" not in value and "\r" not in value: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + escaped = value.replace("\\", "\\\\") + if '"""' not in escaped: + return '"""\n' + escaped + '"""' + if "'''" not in value: + return "'''\n" + value + "'''" + + return '"' + ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + '"' + @staticmethod def _render_toml(description: str, body: str) -> str: """Render a TOML command file from description and body. @@ -558,39 +618,19 @@ def _render_toml(description: str, body: str) -> str: to multiline literal strings (``'''``) if the body contains ``\"\"\"``, then to an escaped basic string as a last resort. - The body is rstrip'd so the closing delimiter appears on the line - immediately after the last content line — matching the release - script's ``echo "$body"; echo '\"\"\"'`` pattern. + The body is ``rstrip("\\n")``'d before rendering, so the TOML + value preserves content without forcing a trailing newline. As a + result, multiline delimiters appear on their own line only when + the rendered value itself ends with a newline. """ toml_lines: list[str] = [] if description: - desc = description.replace('"', '\\"') - toml_lines.append(f'description = "{desc}"') + toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}") toml_lines.append("") body = body.rstrip("\n") - - # Escape backslashes for basic multiline strings. - escaped = body.replace("\\", "\\\\") - - if '"""' not in escaped: - toml_lines.append('prompt = """') - toml_lines.append(escaped) - toml_lines.append('"""') - elif "'''" not in body: - toml_lines.append("prompt = '''") - toml_lines.append(body) - toml_lines.append("'''") - else: - escaped_body = ( - body.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) - toml_lines.append(f'prompt = "{escaped_body}"') + toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(body)}") return "\n".join(toml_lines) + "\n" @@ -630,7 +670,8 @@ def setup( raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template(raw, self.key, script_type, arg_placeholder) - toml_content = self._render_toml(description, processed) + _, body = self._split_frontmatter(processed) + toml_content = self._render_toml(description, body) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( toml_content, dest / dst_name, project_root, manifest diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 8b0935290a..2582a9a855 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -9,6 +9,9 @@ """ import os +import tomllib + +import pytest from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import TomlIntegration @@ -132,13 +135,77 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) assert has_args, "No TOML command file contains {{args}} placeholder" + @pytest.mark.parametrize( + ("frontmatter", "expected"), + [ + ( + "---\ndescription: |\n First line\n Second line\n---\nBody\n", + "First line\nSecond line\n", + ), + ( + "---\ndescription: >\n First line\n Second line\n---\nBody\n", + "First line Second line\n", + ), + ( + "---\ndescription: |-\n First line\n Second line\n---\nBody\n", + "First line\nSecond line", + ), + ( + "---\ndescription: >-\n First line\n Second line\n---\nBody\n", + "First line Second line", + ), + ], + ) + def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected): + assert TomlIntegration._extract_description(frontmatter) == expected + + def test_split_frontmatter_ignores_indented_delimiters(self): + content = ( + "---\n" + "description: |\n" + " line one\n" + " ---\n" + " line two\n" + "---\n" + "Body\n" + ) + + frontmatter, body = TomlIntegration._split_frontmatter(content) + + assert "line two" in frontmatter + assert body == "Body\n" + + def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Summary line one\n" + "scripts:\n" + " sh: scripts/bash/example.sh\n" + "---\n" + "Body line one\n" + "Body line two\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + generated = cmd_files[0].read_text(encoding="utf-8") + parsed = tomllib.loads(generated) + + assert parsed["description"] == "Summary line one" + assert parsed["prompt"] == "Body line one\nBody line two" + assert "description:" not in parsed["prompt"] + assert "scripts:" not in parsed["prompt"] + assert "---" not in parsed["prompt"] + def test_toml_is_valid(self, tmp_path): """Every generated TOML file must parse without errors.""" - try: - import tomllib - except ModuleNotFoundError: - import tomli as tomllib # type: ignore[no-redef] - i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) created = i.setup(tmp_path, m) From 7f08f31286e02c1f75e4a0053bee5df504746b12 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Mon, 6 Apr 2026 21:39:01 +0800 Subject: [PATCH 197/321] [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097) * fix: preserve multiline descriptions in legacy toml renderer * refactor: reuse toml escape helper for prompt fallback --- src/specify_cli/agents.py | 26 ++++++++++++++++---------- tests/test_extensions.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index bb25b3fc1a..4b869283cc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -191,8 +191,9 @@ def render_toml_command( toml_lines = [] if "description" in frontmatter: - desc = frontmatter["description"].replace('"', '\\"') - toml_lines.append(f'description = "{desc}"') + toml_lines.append( + f'description = {self._render_basic_toml_string(frontmatter["description"])}' + ) toml_lines.append("") toml_lines.append(f"# Source: {source_id}") @@ -209,17 +210,22 @@ def render_toml_command( toml_lines.append(body) toml_lines.append("'''") else: - escaped_body = ( - body.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) - toml_lines.append(f'prompt = "{escaped_body}"') + 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_skill_command( self, agent_name: str, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 350b368eac..6ebb39789a 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -13,6 +13,7 @@ import json import tempfile import shutil +import tomllib from pathlib import Path from datetime import datetime, timezone @@ -1014,6 +1015,21 @@ def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self): assert "\\n" in output assert "\\\"\\\"\\\"" in output + def test_render_toml_command_preserves_multiline_description(self): + """Multiline descriptions should render as parseable TOML with preserved semantics.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "first line\nsecond line\n"}, + "body", + "extension:test-ext", + ) + + parsed = tomllib.loads(output) + + assert parsed["description"] == "first line\nsecond line\n" + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory From 55ff1484756b09b6eb05d14bca3624aef074b1ac Mon Sep 17 00:00:00 2001 From: Maxim Stupakov Date: Tue, 7 Apr 2026 00:03:29 +0600 Subject: [PATCH 198/321] Add canon extension and canon-core preset. (#2022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add canon extension and canon-core preset. * fix: correct branch and link references for spec-kit-canon catalog entries - Fix documentation/changelog URLs using `main` → `master` branch in extension and preset catalogs - Fix preset display link label from `spec-kit-canon-core` → `spec-kit-canon` in README * chore: refresh community catalog timestamps * chore: refresh canon extension command count * chore: point canon catalog entries to repo root instead of subdirectories --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- README.md | 2 ++ extensions/catalog.community.json | 37 ++++++++++++++++++++++++++++++- presets/catalog.community.json | 26 +++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 667134a93c..45f6bb91bc 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ The following community-contributed extensions are available in [`catalog.commun | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | @@ -241,6 +242,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Preset | Purpose | Provides | Requires | URL | |--------|---------|----------|----------|-----| | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3dd17ec112..3f2f8001c1 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-03T12:35:01Z", + "updated_at": "2026-04-06T06:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -106,6 +106,41 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "canon": { + "name": "Canon", + "id": "canon", + "description": "Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation.", + "author": "Maxim Stupakov", + "version": "0.1.0", + "download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-v0.1.0.zip", + "repository": "https://github.com/maximiliamus/spec-kit-canon", + "homepage": "https://github.com/maximiliamus/spec-kit-canon", + "documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md", + "changelog": "https://github.com/maximiliamus/spec-kit-canon/blob/master/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3" + }, + "provides": { + "commands": 16, + "hooks": 0 + }, + "tags": [ + "process", + "baseline", + "canon", + "drift", + "spec-first", + "code-first", + "spec-drift", + "vibecoding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-29T00:00:00Z", + "updated_at": "2026-03-29T00:00:00Z" + }, "checkpoint": { "name": "Checkpoint Extension", "id": "checkpoint", diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 3bcd325aec..53616c2d71 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-04T00:00:00Z", + "updated_at": "2026-04-06T06:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -29,6 +29,30 @@ "aide" ] }, + "canon-core": { + "name": "Canon Core", + "id": "canon-core", + "version": "0.1.0", + "description": "Adapts original Spec Kit workflow to work together with Canon extension.", + "author": "Maxim Stupakov", + "download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-core-v0.1.0.zip", + "repository": "https://github.com/maximiliamus/spec-kit-canon", + "homepage": "https://github.com/maximiliamus/spec-kit-canon", + "documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3" + }, + "provides": { + "templates": 2, + "commands": 8 + }, + "tags": [ + "baseline", + "canon", + "spec-first" + ] + }, "pirate": { "name": "Pirate Speak (Full)", "id": "pirate", From 473a441720dfab9ce0caeba83fbe2ee12632da1b Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Tue, 7 Apr 2026 13:16:44 +0100 Subject: [PATCH 199/321] Update V-Model Extension Pack to v0.5.0 (#2108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version: 0.4.0 → 0.5.0 - download_url: v0.4.0 tag → v0.5.0 tag - commands: 9 → 14 - updated_at: 2026-04-06 --- extensions/catalog.community.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3f2f8001c1..8dc4574931 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1067,7 +1067,7 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, - "repoindex":{ + "repoindex": { "name": "Repository Index", "id": "repoindex", "description": "Generate index of your repo for overview, architecture and module", @@ -1082,7 +1082,7 @@ "requires": { "speckit_version": ">=0.1.0", "tools": [ - { + { "name": "no need", "version": ">=1.0.0", "required": false @@ -1436,8 +1436,8 @@ "id": "v-model", "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", "author": "leocamello", - "version": "0.4.0", - "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.4.0.zip", + "version": "0.5.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip", "repository": "https://github.com/leocamello/spec-kit-v-model", "homepage": "https://github.com/leocamello/spec-kit-v-model", "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", @@ -1447,7 +1447,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 9, + "commands": 14, "hooks": 1 }, "tags": [ @@ -1461,7 +1461,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-02-22T00:00:00Z" + "updated_at": "2026-04-06T00:00:00Z" }, "verify": { "name": "Verify Extension", @@ -1526,6 +1526,5 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" } - } } From aad6f68ae5f9f12e1448246417b02be9332c4ea2 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Tue, 7 Apr 2026 05:18:45 -0700 Subject: [PATCH 200/321] Upgraded confluence extension to v.1.1.1 (#2109) Co-authored-by: Aaron Sun --- extensions/catalog.community.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8dc4574931..da4fc75da3 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -267,8 +267,8 @@ "id": "confluence", "description": "Create, read, and update Confluence docs for your project", "author": "aaronrsun", - "version": "1.1.0", - "download_url": "https://github.com/aaronrsun/spec-kit-confluence/archive/refs/tags/v1.1.0.zip", + "version": "1.1.1", + "download_url": "https://github.com/aaronrsun/spec-kit-confluence/archive/refs/tags/v1.1.1.zip", "repository": "https://github.com/aaronrsun/spec-kit-confluence", "homepage": "https://github.com/aaronrsun/spec-kit-confluence", "documentation": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/README.md", From 1a9e4d1d8da99e46fcbc0b70ecfd99b4e4a05e76 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:39:35 -0500 Subject: [PATCH 201/321] =?UTF-8?q?feat:=20Git=20extension=20stage=201=20?= =?UTF-8?q?=E2=80=94=20bundled=20`extensions/git`=20with=20hooks=20on=20al?= =?UTF-8?q?l=20core=20commands=20(#1941)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add git extension with hooks on all core commands - Create extensions/git/ with 5 commands: initialize, feature, validate, remote, commit - 18 hooks covering before/after for all 9 core commands - Scripts: create-new-feature, initialize-repo, auto-commit, git-common (bash + powershell) - Configurable: branch_numbering, init_commit_message, per-command auto-commit with custom messages - Add hooks to analyze, checklist, clarify, constitution, taskstoissues command templates - Allow hooks-only extensions (no commands required) - Bundle extension in wheel via pyproject.toml force-include - Resolve bundled extensions locally before catalog lookup - Remove planned-but-unimplemented before/after_commit hook refs - Update extension docs (API ref, dev guide, user guide) - 37 new tests covering manifest, install, all scripts (bash+pwsh), config reading, graceful degradation Stage 1: opt-in via 'specify extension add git'. No auto-install, no changes to specify.md or core git init code. Refs: #841, #1382, #1066, #1791, #1191 * fix: set git identity env vars in extension tests for CI runners * fix: address PR review comments - Fix commands property KeyError for hooks-only extensions - Fix has_git() operator precedence in git-common.sh - Align default commit message to '[Spec Kit] Initial commit' across config-template, extension.yml defaults, and both init scripts - Update README to reflect all 5 commands and 18 hooks * fix: address second round of PR review comments - Add type validation for provides.commands (must be list) and hooks (must be dict) in manifest _validate() - Tighten malformed timestamp detection in git-common.sh to catch 7-digit dates without trailing slug (e.g. 2026031-143022) - Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts - Fix initialize command docs: surface errors on git failures, only skip when git is not installed - Fix commit command docs: 'skips with a warning' not 'silently' - Add tests for commands:null and hooks:list rejection * fix: address third round of PR review comments - Remove scripts frontmatter from command files (CommandRegistrar rewrites ../../scripts/ to .specify/scripts/ which points at core scripts, not extension scripts) - Update speckit.git.commit command to derive event name from hook context rather than using a static example - Clarify that hook argument passthrough works via AI agent context (the agent carries conversation state including user's original feature description) * fix: address fourth round of PR review comments - Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension to prevent path traversal (security fix) - Move defaults under config.defaults in extension.yml to match ConfigManager._get_extension_defaults() schema - Ship git-config.yml in extension directory so it's copied during install (provides.config template isn't materialized by ExtensionManager) - Condition handling in hook templates: intentionally matches existing pattern from specify/plan/tasks/implement templates (not a new issue) * fix: add --allow-empty to git commit in initialize-repo scripts Ensures git init succeeds even on empty repos where nothing has been staged yet. * fix: resolve display names to bundled extensions before catalog download When 'specify extension add "Git Branching Workflow"' is used with a display name instead of the ID, the catalog resolver now runs first to map the name to an ID, then checks bundled extensions again with the resolved ID before falling back to network download. Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the existing pattern in specify/plan/tasks/implement templates (pre-existing, not introduced by this PR). * fix: handle before_/after_ prefixes in auto-commit message derivation - Strip both before_ and after_ prefixes when deriving command name (fixes misleading 'Auto-commit after before_plan' messages) - Include phase (before/after) in default commit messages - Clarify README config example is an override, not default behavior * fix: use portable grep -qw for word boundary in create-new-feature.sh BSD grep (macOS) doesn't support \b as a word boundary. Replace with grep -qw which is POSIX-portable. * fix: validate hook values, numeric --number, and PS warning routing - Validate each hook value is a dict with a 'command' field during manifest _validate() (prevents crash at install time) - Validate --number is a non-negative integer in bash create-new-feature (clear error instead of cryptic shell arithmetic failure) - Route PowerShell no-git warning to stderr in JSON mode so stdout stays valid JSON --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- extensions/EXTENSION-API-REFERENCE.md | 14 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 17 +- extensions/EXTENSION-USER-GUIDE.md | 6 +- extensions/catalog.json | 17 +- extensions/git/README.md | 100 +++ extensions/git/commands/speckit.git.commit.md | 48 ++ .../git/commands/speckit.git.feature.md | 66 ++ .../git/commands/speckit.git.initialize.md | 49 ++ extensions/git/commands/speckit.git.remote.md | 45 ++ .../git/commands/speckit.git.validate.md | 49 ++ extensions/git/config-template.yml | 62 ++ extensions/git/extension.yml | 140 ++++ extensions/git/git-config.yml | 62 ++ extensions/git/scripts/bash/auto-commit.sh | 140 ++++ .../git/scripts/bash/create-new-feature.sh | 451 +++++++++++++ extensions/git/scripts/bash/git-common.sh | 41 ++ .../git/scripts/bash/initialize-repo.sh | 54 ++ .../git/scripts/powershell/auto-commit.ps1 | 149 +++++ .../scripts/powershell/create-new-feature.ps1 | 397 ++++++++++++ .../git/scripts/powershell/git-common.ps1 | 50 ++ .../scripts/powershell/initialize-repo.ps1 | 69 ++ pyproject.toml | 2 + src/specify_cli/__init__.py | 108 +++- src/specify_cli/extensions.py | 39 +- templates/commands/analyze.md | 65 ++ templates/commands/checklist.md | 66 ++ templates/commands/clarify.md | 66 ++ templates/commands/constitution.md | 66 ++ templates/commands/taskstoissues.md | 66 ++ tests/extensions/__init__.py | 1 + tests/extensions/git/__init__.py | 1 + tests/extensions/git/test_git_extension.py | 601 ++++++++++++++++++ tests/test_extensions.py | 55 +- 33 files changed, 3108 insertions(+), 54 deletions(-) create mode 100644 extensions/git/README.md create mode 100644 extensions/git/commands/speckit.git.commit.md create mode 100644 extensions/git/commands/speckit.git.feature.md create mode 100644 extensions/git/commands/speckit.git.initialize.md create mode 100644 extensions/git/commands/speckit.git.remote.md create mode 100644 extensions/git/commands/speckit.git.validate.md create mode 100644 extensions/git/config-template.yml create mode 100644 extensions/git/extension.yml create mode 100644 extensions/git/git-config.yml create mode 100755 extensions/git/scripts/bash/auto-commit.sh create mode 100755 extensions/git/scripts/bash/create-new-feature.sh create mode 100755 extensions/git/scripts/bash/git-common.sh create mode 100755 extensions/git/scripts/bash/initialize-repo.sh create mode 100644 extensions/git/scripts/powershell/auto-commit.ps1 create mode 100644 extensions/git/scripts/powershell/create-new-feature.ps1 create mode 100644 extensions/git/scripts/powershell/git-common.ps1 create mode 100644 extensions/git/scripts/powershell/initialize-repo.ps1 create mode 100644 tests/extensions/__init__.py create mode 100644 tests/extensions/git/__init__.py create mode 100644 tests/extensions/git/test_git_extension.py diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 721624ab81..ce0ff1775c 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -108,7 +108,7 @@ defaults: # Optional, default configuration values #### `hooks` - **Type**: object -- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`) +- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`) - **Description**: Hooks that execute at lifecycle events - **Events**: Defined by core spec-kit commands @@ -559,8 +559,16 @@ Standard events (defined by core): - `after_tasks` - After task generation - `before_implement` - Before implementation - `after_implement` - After implementation -- `before_commit` - Before git commit *(planned - not yet wired into core templates)* -- `after_commit` - After git commit *(planned - not yet wired into core templates)* +- `before_analyze` - Before cross-artifact analysis +- `after_analyze` - After cross-artifact analysis +- `before_checklist` - Before checklist generation +- `after_checklist` - After checklist generation +- `before_clarify` - Before spec clarification +- `after_clarify` - After spec clarification +- `before_constitution` - Before constitution update +- `after_constitution` - After constitution update +- `before_taskstoissues` - Before tasks-to-issues conversion +- `after_taskstoissues` - After tasks-to-issues conversion ### Hook Configuration diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 4eb7626d8f..dfc1125228 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -177,9 +177,9 @@ Compatibility requirements. What the extension provides. -**Required sub-fields**: +**Optional sub-fields**: -- `commands`: Array of command objects (must have at least one) +- `commands`: Array of command objects (at least one command or hook is required) **Command object**: @@ -196,12 +196,19 @@ Integration hooks for automatic execution. Available hook points: -- `after_tasks`: After `/speckit.tasks` completes -- `after_implement`: After `/speckit.implement` completes (future) +- `before_specify` / `after_specify`: Before/after specification generation +- `before_plan` / `after_plan`: Before/after implementation planning +- `before_tasks` / `after_tasks`: Before/after task generation +- `before_implement` / `after_implement`: Before/after implementation +- `before_analyze` / `after_analyze`: Before/after cross-artifact analysis +- `before_checklist` / `after_checklist`: Before/after checklist generation +- `before_clarify` / `after_clarify`: Before/after spec clarification +- `before_constitution` / `after_constitution`: Before/after constitution update +- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion Hook object: -- `command`: Command to execute (must be in `provides.commands`) +- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command) - `optional`: If true, prompt user before executing - `prompt`: Prompt text for optional hooks - `description`: Hook description diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 190e263af2..595985d955 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -403,8 +403,10 @@ settings: # Hook configuration # Available events: before_specify, after_specify, before_plan, after_plan, -# before_tasks, after_tasks, before_implement, after_implement -# Planned (not yet wired into core templates): before_commit, after_commit +# before_tasks, after_tasks, before_implement, after_implement, +# before_analyze, after_analyze, before_checklist, after_checklist, +# before_clarify, after_clarify, before_constitution, after_constitution, +# before_taskstoissues, after_taskstoissues hooks: after_tasks: - extension: jira diff --git a/extensions/catalog.json b/extensions/catalog.json index f06cfe5744..a039883ba2 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,8 +1,23 @@ { "schema_version": "1.0", - "updated_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-04-06T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { + "git": { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip", + "tags": [ + "git", + "branching", + "workflow", + "core" + ] + }, "selftest": { "name": "Spec Kit Self-Test Utility", "id": "selftest", diff --git a/extensions/git/README.md b/extensions/git/README.md new file mode 100644 index 0000000000..31ba75c30f --- /dev/null +++ b/extensions/git/README.md @@ -0,0 +1,100 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/extensions/git/commands/speckit.git.commit.md b/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 0000000000..e606f911df --- /dev/null +++ b/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 0000000000..13a7d0784d --- /dev/null +++ b/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,66 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create a new feature branch for the given specification. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation (spec directory will still be created) + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain BRANCH_NAME and SPEC_FILE paths + +If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: +- **Bash**: `scripts/bash/create-new-feature.sh` +- **PowerShell**: `scripts/powershell/create-new-feature.ps1` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- The script will still create the spec directory under `specs/` +- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` +- The workflow continues normally without branch creation + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `SPEC_FILE`: Path to the created spec file +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/commands/speckit.git.initialize.md b/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 0000000000..4451ee6b77 --- /dev/null +++ b/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,49 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `✓ Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/extensions/git/commands/speckit.git.remote.md b/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 0000000000..712a3e8b8c --- /dev/null +++ b/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 0000000000..dd84618cb8 --- /dev/null +++ b/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml new file mode 100644 index 0000000000..13c1977ea1 --- /dev/null +++ b/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 0000000000..49c32fe634 --- /dev/null +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Changes committed ${_phase} ${_command_name}" >&2 diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000000..dfae29df73 --- /dev/null +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + if type resolve_template >/dev/null 2>&1; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + else + TEMPLATE="" + fi + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_spec=$(json_escape "$SPEC_FILE") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_spec="$SPEC_FILE" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 0000000000..882a385e28 --- /dev/null +++ b/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) + if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 + fi + + # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + return 0 + fi + + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 +} diff --git a/extensions/git/scripts/bash/initialize-repo.sh b/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 0000000000..296e363b94 --- /dev/null +++ b/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Git repository initialized" >&2 diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 0000000000..e9777ff9be --- /dev/null +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,149 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "not a repo" } +} catch { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file — auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE +$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE +$untracked = git ls-files --others --exclude-standard 2>$null + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "✓ Changes committed $phase $commandName" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000000..75a4e69814 --- /dev/null +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,397 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + exit 0 +} + +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + $hasGit = Test-HasGit -RepoRoot $repoRoot +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' +if (-not $DryRun) { + New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +} + +function Get-BranchName { + param([string]$Description) + + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +} else { + $branchSuffix = Get-BranchName -Description $featureDesc +} + +if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 +} + +if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" +} else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + + if (-not (Test-Path -PathType Leaf $specFile)) { + if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) { + $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot + } else { + $template = $null + } + if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force + } else { + New-Item -ItemType File -Path $specFile -Force | Out-Null + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + SPEC_FILE = $specFile + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "SPEC_FILE: $specFile" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 0000000000..8a9c4fd6cc --- /dev/null +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,50 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 — contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + # Reject malformed timestamps (7-digit date or no trailing slug) + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or + ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + if ($hasMalformedTimestamp) { + Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + return $false + } + + # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + $isTimestamp = $Branch -match '^\d{8}-\d{6}-' + + if ($isSequential -or $isTimestamp) { + return $true + } + + Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + return $false +} diff --git a/extensions/git/scripts/powershell/initialize-repo.ps1 b/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 0000000000..324240a3e7 --- /dev/null +++ b/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "✓ Git repository initialized" diff --git a/pyproject.toml b/pyproject.toml index 02a4cdd7dc..9e46b0a14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ packages = ["src/specify_cli"] "templates/commands" = "specify_cli/core_pack/commands" "scripts/bash" = "specify_cli/core_pack/scripts/bash" "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" +# Bundled extensions (installable via `specify extension add `) +"extensions/git" = "specify_cli/core_pack/extensions/git" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1c62a4a3be..146d8e6e12 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -604,6 +604,31 @@ def _locate_core_pack() -> Path | None: return None +def _locate_bundled_extension(extension_id: str) -> Path | None: + """Return the path to a bundled extension, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``extensions//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', extension_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + + return None + + def _install_shared_infra( project_path: Path, script_type: str, @@ -3024,45 +3049,58 @@ def extension_add( zip_path.unlink() else: - # Install from catalog - catalog = ExtensionCatalog(project_root) + # Try bundled extensions first (shipped with spec-kit) + bundled_path = _locate_bundled_extension(extension) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + else: + # Install from catalog (also resolves display names to IDs) + catalog = ExtensionCatalog(project_root) - # Check if extension exists in catalog (supports both ID and display name) - ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") - raise typer.Exit(1) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nSearch available extensions:") - console.print(" specify extension search") - raise typer.Exit(1) + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) - # Enforce install_allowed policy - if not ext_info.get("_install_allowed", True): - catalog_name = ext_info.get("_catalog_name", "community") - console.print( - f"[red]Error:[/red] '{extension}' is available in the " - f"'{catalog_name}' catalog but installation is not allowed from that catalog." - ) - console.print( - f"\nTo enable installation, add '{extension}' to an approved catalog " - f"(install_allowed: true) in .specify/extension-catalogs.yml." - ) - raise typer.Exit(1) + # If catalog resolved a display name to an ID, check bundled again + resolved_id = ext_info['id'] + if resolved_id != extension: + bundled_path = _locate_bundled_extension(resolved_id) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + + if bundled_path is None: + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + console.print( + f"[red]Error:[/red] '{extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{extension}' to an approved catalog " + f"(install_allowed: true) in .specify/extension-catalogs.yml." + ) + raise typer.Exit(1) - # Download extension ZIP (use resolved ID, not original argument which may be display name) - extension_id = ext_info['id'] - console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension_id) + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] + console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") + zip_path = catalog.download_extension(extension_id) - try: - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + try: + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() console.print("\n[green]✓[/green] Extension installed successfully!") console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3420a7651b..6d7b7c1199 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -183,11 +183,40 @@ def _validate(self): # Validate provides section provides = self.data["provides"] - if "commands" not in provides or not provides["commands"]: - raise ValidationError("Extension must provide at least one command") + commands = provides.get("commands", []) + hooks = self.data.get("hooks") - # Validate commands - for cmd in provides["commands"]: + if "commands" in provides and not isinstance(commands, list): + raise ValidationError( + "Invalid provides.commands: expected a list" + ) + if "hooks" in self.data and not isinstance(hooks, dict): + raise ValidationError( + "Invalid hooks: expected a mapping" + ) + + has_commands = bool(commands) + has_hooks = bool(hooks) + + if not has_commands and not has_hooks: + raise ValidationError( + "Extension must provide at least one command or hook" + ) + + # Validate hook values (if present) + if hooks: + for hook_name, hook_config in hooks.items(): + if not isinstance(hook_config, dict): + raise ValidationError( + f"Invalid hook '{hook_name}': expected a mapping" + ) + if not hook_config.get("command"): + raise ValidationError( + f"Hook '{hook_name}' missing required 'command' field" + ) + + # Validate commands (if present) + for cmd in commands: if "name" not in cmd or "file" not in cmd: raise ValidationError("Command missing 'name' or 'file'") @@ -226,7 +255,7 @@ def requires_speckit_version(self) -> str: @property def commands(self) -> List[Dict[str, Any]]: """Get list of provided commands.""" - return self.data["provides"]["commands"] + return self.data.get("provides", {}).get("commands", []) @property def hooks(self) -> Dict[str, Any]: diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index b3174338d9..43e6d225b1 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -13,6 +13,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before analysis)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_analyze` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Goal. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Goal Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`. @@ -165,6 +199,37 @@ At end of report, output a concise Next Actions block: Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) +### 9. Check for extension hooks + +After reporting, check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_analyze` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Operating Principles ### Context Efficiency diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index a79131a204..533046566b 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -34,6 +34,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before checklist generation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_checklist` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Execution Steps. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Execution Steps 1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list. @@ -296,3 +330,35 @@ Sample items: - Correct: Validation of requirement quality - Wrong: "Does it do X?" - Correct: "Is X clearly specified?" + +## Post-Execution Checks + +**Check for extension hooks (after checklist generation)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_checklist` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 26efb5aedb..d6d6bbe910 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -17,6 +17,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before clarification)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_clarify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. @@ -182,3 +216,35 @@ Behavior rules: - If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale. Context for prioritization: {ARGS} + +## Post-Execution Checks + +**Check for extension hooks (after clarification)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_clarify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 63d4f662ae..29ae9a09e2 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -14,6 +14,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before constitution update)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_constitution` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts. @@ -82,3 +116,35 @@ If the user supplies partial updates (e.g., only one principle revision), still If critical info missing (e.g., ratification date truly unknown), insert `TODO(): explanation` and include in the Sync Impact Report under deferred items. Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file. + +## Post-Execution Checks + +**Check for extension hooks (after constitution update)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_constitution` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index d6aa3bbf55..77db7be130 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -14,6 +14,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before tasks-to-issues conversion)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). @@ -31,3 +65,35 @@ git config --get remote.origin.url > [!CAUTION] > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL + +## Post-Execution Checks + +**Check for extension hooks (after tasks-to-issues conversion)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py new file mode 100644 index 0000000000..97d2f6978b --- /dev/null +++ b/tests/extensions/__init__.py @@ -0,0 +1 @@ +"""Extensions test package.""" diff --git a/tests/extensions/git/__init__.py b/tests/extensions/git/__init__.py new file mode 100644 index 0000000000..bec5daeccc --- /dev/null +++ b/tests/extensions/git/__init__.py @@ -0,0 +1 @@ +"""Tests for the bundled git extension.""" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py new file mode 100644 index 0000000000..721bd999f2 --- /dev/null +++ b/tests/extensions/git/test_git_extension.py @@ -0,0 +1,601 @@ +""" +Tests for the bundled git extension (extensions/git/). + +Validates: +- extension.yml manifest +- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh) +- PowerShell scripts (where pwsh is available) +- Config reading from git-config.yml +- Extension install via ExtensionManager +""" + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +EXT_DIR = PROJECT_ROOT / "extensions" / "git" +EXT_BASH = EXT_DIR / "scripts" / "bash" +EXT_PS = EXT_DIR / "scripts" / "powershell" +CORE_COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +CORE_COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _init_git(path: Path) -> None: + """Initialize a git repo with a dummy commit.""" + subprocess.run(["git", "init", "-q"], cwd=path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "seed", "-q"], + cwd=path, + check=True, + ) + + +def _setup_project(tmp_path: Path, *, git: bool = True) -> Path: + """Create a project directory with core scripts and .specify.""" + # Core scripts (needed by extension scripts that source common.sh) + bash_dir = tmp_path / "scripts" / "bash" + bash_dir.mkdir(parents=True) + shutil.copy(CORE_COMMON_SH, bash_dir / "common.sh") + + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + shutil.copy(CORE_COMMON_PS, ps_dir / "common.ps1") + + # .specify structure + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Extension scripts (as if installed) + ext_bash = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_bash.mkdir(parents=True) + for f in EXT_BASH.iterdir(): + dest = ext_bash / f.name + shutil.copy(f, dest) + dest.chmod(0o755) + + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + for f in EXT_PS.iterdir(): + shutil.copy(f, ext_ps / f.name) + + # Copy extension.yml + shutil.copy(EXT_DIR / "extension.yml", tmp_path / ".specify" / "extensions" / "git" / "extension.yml") + + if git: + _init_git(tmp_path) + + return tmp_path + + +def _write_config(project: Path, content: str) -> Path: + """Write git-config.yml into the extension config directory.""" + config_path = project / ".specify" / "extensions" / "git" / "git-config.yml" + config_path.write_text(content, encoding="utf-8") + return config_path + + +# Git identity env vars for CI runners without global git config +_GIT_ENV = { + "GIT_AUTHOR_NAME": "Test User", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test User", + "GIT_COMMITTER_EMAIL": "test@example.com", +} + + +def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: + """Run an extension bash script.""" + script = cwd / ".specify" / "extensions" / "git" / "scripts" / "bash" / script_name + env = {**os.environ, **_GIT_ENV, **(env_extra or {})} + return subprocess.run( + ["bash", str(script), *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run an extension PowerShell script.""" + script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name + env = {**os.environ, **_GIT_ENV} + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +# ── Manifest Tests ─────────────────────────────────────────────────────────── + + +class TestGitExtensionManifest: + def test_manifest_validates(self): + """extension.yml passes manifest validation.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + assert m.id == "git" + assert m.version == "1.0.0" + + def test_manifest_commands(self): + """Manifest declares expected commands.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + names = [c["name"] for c in m.commands] + assert "speckit.git.feature" in names + assert "speckit.git.validate" in names + assert "speckit.git.remote" in names + assert "speckit.git.initialize" in names + assert "speckit.git.commit" in names + + def test_manifest_hooks(self): + """Manifest declares expected hooks.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + assert "before_constitution" in m.hooks + assert "before_specify" in m.hooks + assert "after_specify" in m.hooks + assert "after_implement" in m.hooks + assert m.hooks["before_constitution"]["command"] == "speckit.git.initialize" + assert m.hooks["before_specify"]["command"] == "speckit.git.feature" + + def test_manifest_command_files_exist(self): + """All command files referenced in the manifest exist.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + for cmd in m.commands: + cmd_path = EXT_DIR / cmd["file"] + assert cmd_path.is_file(), f"Missing command file: {cmd['file']}" + + +# ── Install Tests ──────────────────────────────────────────────────────────── + + +class TestGitExtensionInstall: + def test_install_from_directory(self, tmp_path: Path): + """Extension installs via ExtensionManager.install_from_directory.""" + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manifest = manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) + assert manifest.id == "git" + assert manager.registry.is_installed("git") + + def test_install_copies_scripts(self, tmp_path: Path): + """Extension install copies script files.""" + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) + + ext_installed = tmp_path / ".specify" / "extensions" / "git" + assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file() + assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file() + + def test_bundled_extension_locator(self): + """_locate_bundled_extension finds the git extension.""" + from specify_cli import _locate_bundled_extension + + path = _locate_bundled_extension("git") + assert path is not None + assert (path / "extension.yml").is_file() + + +# ── initialize-repo.sh Tests ───────────────────────────────────────────────── + + +class TestInitializeRepoBash: + def test_initializes_git_repo(self, tmp_path: Path): + """initialize-repo.sh creates a git repo with initial commit.""" + project = _setup_project(tmp_path, git=False) + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0, result.stderr + + # Verify git repo exists + assert (project / ".git").exists() + + # Verify at least one commit exists + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert log.returncode == 0 + + def test_skips_if_already_git_repo(self, tmp_path: Path): + """initialize-repo.sh skips if already a git repo.""" + project = _setup_project(tmp_path, git=True) + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0 + assert "already initialized" in result.stderr.lower() + + def test_custom_commit_message(self, tmp_path: Path): + """initialize-repo.sh reads custom commit message from config.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, 'init_commit_message: "Custom init message"\n') + + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "Custom init message" in log.stdout + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestInitializeRepoPowerShell: + def test_initializes_git_repo(self, tmp_path: Path): + """initialize-repo.ps1 creates a git repo with initial commit.""" + project = _setup_project(tmp_path, git=False) + result = _run_pwsh("initialize-repo.ps1", project) + assert result.returncode == 0, result.stderr + assert (project / ".git").exists() + + def test_skips_if_already_git_repo(self, tmp_path: Path): + """initialize-repo.ps1 skips if already a git repo.""" + project = _setup_project(tmp_path, git=True) + result = _run_pwsh("initialize-repo.ps1", project) + assert result.returncode == 0 + + +# ── create-new-feature.sh Tests ────────────────────────────────────────────── + + +class TestCreateFeatureBash: + def test_creates_branch_sequential(self, tmp_path: Path): + """Extension create-new-feature.sh creates sequential branch.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "user-auth", "Add user authentication", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-user-auth" + assert "SPEC_FILE" in data + assert data["FEATURE_NUM"] == "001" + + def test_creates_branch_timestamp(self, tmp_path: Path): + """Extension create-new-feature.sh creates timestamp branch.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--timestamp", "--short-name", "feat", "Feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + + def test_creates_spec_dir(self, tmp_path: Path): + """create-new-feature.sh creates specs directory and spec.md.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "test-feat", "Test feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + spec_file = Path(data["SPEC_FILE"]) + assert spec_file.exists(), f"spec.md not created at {spec_file}" + + def test_increments_from_existing_specs(self, tmp_path: Path): + """Sequential numbering increments past existing spec directories.""" + project = _setup_project(tmp_path) + (project / "specs" / "001-first").mkdir(parents=True) + (project / "specs" / "002-second").mkdir(parents=True) + + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "third", "Third feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["FEATURE_NUM"] == "003" + + def test_no_git_graceful_degradation(self, tmp_path: Path): + """create-new-feature.sh works without git (creates spec dir only).""" + project = _setup_project(tmp_path, git=False) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "no-git", "No git feature", + ) + assert result.returncode == 0, result.stderr + assert "Warning" in result.stderr + data = json.loads(result.stdout) + spec_file = Path(data["SPEC_FILE"]) + assert spec_file.exists() + + def test_dry_run(self, tmp_path: Path): + """--dry-run computes branch name without creating anything.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--dry-run", "--short-name", "dry", "Dry run test", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data.get("DRY_RUN") is True + assert not (project / "specs" / data["BRANCH_NAME"]).exists() + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestCreateFeaturePowerShell: + def test_creates_branch_sequential(self, tmp_path: Path): + """Extension create-new-feature.ps1 creates sequential branch.""" + project = _setup_project(tmp_path) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-ShortName", "user-auth", "Add user authentication", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-user-auth" + + def test_creates_branch_timestamp(self, tmp_path: Path): + """Extension create-new-feature.ps1 creates timestamp branch.""" + project = _setup_project(tmp_path) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-Timestamp", "-ShortName", "feat", "Feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + + def test_no_git_graceful_degradation(self, tmp_path: Path): + """create-new-feature.ps1 works without git.""" + project = _setup_project(tmp_path, git=False) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-ShortName", "no-git", "No git feature", + ) + assert result.returncode == 0, result.stderr + # pwsh may prefix warnings to stdout; find the JSON line + json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] + assert json_line, f"No JSON in output: {result.stdout}" + data = json.loads(json_line[-1]) + assert Path(data["SPEC_FILE"]).exists() + + +# ── auto-commit.sh Tests ───────────────────────────────────────────────────── + + +class TestAutoCommitBash: + def test_disabled_by_default(self, tmp_path: Path): + """auto-commit.sh exits silently when config is all false.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: false\n") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + # Should not have created any new commits + log = subprocess.run( + ["git", "log", "--oneline"], + cwd=project, capture_output=True, text=True, + ) + assert log.stdout.strip().count("\n") == 0 # only the seed commit + + def test_enabled_per_command(self, tmp_path: Path): + """auto-commit.sh commits when per-command key is enabled.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "test commit after specify"\n' + )) + # Create a file to commit + (project / "specs" / "001-test" / "spec.md").parent.mkdir(parents=True) + (project / "specs" / "001-test" / "spec.md").write_text("test spec") + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "test commit after specify" in log.stdout + + def test_custom_message(self, tmp_path: Path): + """auto-commit.sh uses the per-command message.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + ' message: "[Project] Plan complete"\n' + )) + (project / "new-file.txt").write_text("content") + + result = _run_bash("auto-commit.sh", project, "after_plan") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "[Project] Plan complete" in log.stdout + + def test_default_true_with_no_event_key(self, tmp_path: Path): + """auto-commit.sh uses default: true when event key is absent.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: true\n") + (project / "new-file.txt").write_text("content") + + result = _run_bash("auto-commit.sh", project, "after_tasks") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "Auto-commit after tasks" in log.stdout + + def test_no_changes_skips(self, tmp_path: Path): + """auto-commit.sh skips when there are no changes.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "should not appear"\n' + )) + # Commit all existing files so nothing is dirty + subprocess.run(["git", "add", "."], cwd=project, check=True) + subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True) + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "No changes" in result.stderr + + def test_no_config_file_skips(self, tmp_path: Path): + """auto-commit.sh exits silently when no config file exists.""" + project = _setup_project(tmp_path) + # Remove config if it was copied + config = project / ".specify" / "extensions" / "git" / "git-config.yml" + config.unlink(missing_ok=True) + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + + def test_no_git_repo_skips(self, tmp_path: Path): + """auto-commit.sh skips when not in a git repo.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, "auto_commit:\n default: true\n") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "not a Git repository" in result.stderr.lower() or "Warning" in result.stderr + + def test_requires_event_name_argument(self, tmp_path: Path): + """auto-commit.sh fails without event name argument.""" + project = _setup_project(tmp_path) + result = _run_bash("auto-commit.sh", project) + assert result.returncode != 0 + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestAutoCommitPowerShell: + def test_disabled_by_default(self, tmp_path: Path): + """auto-commit.ps1 exits silently when config is all false.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: false\n") + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + + def test_enabled_per_command(self, tmp_path: Path): + """auto-commit.ps1 commits when per-command key is enabled.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "ps commit"\n' + )) + (project / "specs" / "001-test").mkdir(parents=True) + (project / "specs" / "001-test" / "spec.md").write_text("test") + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "ps commit" in log.stdout + + +# ── git-common.sh Tests ────────────────────────────────────────────────────── + + +class TestGitCommonBash: + def test_has_git_true(self, tmp_path: Path): + """has_git returns 0 in a git repo.""" + project = _setup_project(tmp_path, git=True) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && has_git "{project}"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_has_git_false(self, tmp_path: Path): + """has_git returns non-zero outside a git repo.""" + project = _setup_project(tmp_path, git=False) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && has_git "{project}"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_sequential(self, tmp_path: Path): + """check_feature_branch accepts sequential branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_timestamp(self, tmp_path: Path): + """check_feature_branch accepts timestamp branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "20260319-143022-feat" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_main(self, tmp_path: Path): + """check_feature_branch rejects non-feature branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "main" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): + """check_feature_branch rejects malformed timestamps (7-digit date).""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "2026031-143022-feat" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6ebb39789a..9d4df6a9a1 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -255,17 +255,66 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) - def test_no_commands(self, temp_dir, valid_manifest_data): - """Test manifest with no commands provided.""" + def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): + """Test manifest with no commands and no hooks provided.""" import yaml valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data.pop("hooks", None) manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) - with pytest.raises(ValidationError, match="must provide at least one command"): + with pytest.raises(ValidationError, match="must provide at least one command or hook"): + ExtensionManifest(manifest_path) + + def test_hooks_only_extension(self, temp_dir, valid_manifest_data): + """Test manifest with hooks but no commands is valid.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data["hooks"] = { + "after_specify": { + "command": "speckit.test-ext.notify", + "optional": True, + "prompt": "Run notification?", + } + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert manifest.id == valid_manifest_data["extension"]["id"] + assert len(manifest.commands) == 0 + assert len(manifest.hooks) == 1 + + def test_commands_null_rejected(self, temp_dir, valid_manifest_data): + """Test manifest with commands: null is rejected.""" + import yaml + + valid_manifest_data["provides"]["commands"] = None + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid provides.commands"): + ExtensionManifest(manifest_path) + + def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): + """Test manifest with hooks as a list is rejected.""" + import yaml + + valid_manifest_data["hooks"] = ["not", "a", "dict"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid hooks"): ExtensionManifest(manifest_path) def test_manifest_hash(self, extension_dir): From 6536bc410265406d0bd480fbd1073d060d23bab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8A=A0=E5=BA=B7=E5=AE=81?= Date: Tue, 7 Apr 2026 21:41:11 +0800 Subject: [PATCH 202/321] fix speckit issue for trae (#2112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修改trea文件结构错误问题 * 修改trea文件结构错误问题 * 修复trae agent 文件结构错误问题 * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix trae's test case files * Update src/specify_cli/integrations/trae/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: jiakangning Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/bash/update-agent-context.sh | 2 +- scripts/powershell/update-agent-context.ps1 | 2 +- src/specify_cli/__init__.py | 7 ++-- src/specify_cli/integrations/trae/__init__.py | 33 +++++++++++++++---- .../trae/scripts/update-context.ps1 | 2 +- .../trae/scripts/update-context.sh | 2 +- src/specify_cli/presets.py | 2 +- tests/integrations/test_integration_trae.py | 10 +++--- tests/test_agent_config_consistency.py | 4 +-- 9 files changed, 42 insertions(+), 22 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index da06ed4697..b0ef4b422a 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -84,7 +84,7 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" BOB_FILE="$AGENTS_FILE" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" -TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" +TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md" IFLOW_FILE="$REPO_ROOT/IFLOW.md" FORGE_FILE="$AGENTS_FILE" diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 342ee5464d..12caa306da 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -65,7 +65,7 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' -$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md' +$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' $IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' $FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 146d8e6e12..95ab2028c1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1312,7 +1312,7 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy) should show skill invocation syntax. + # Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) @@ -1320,7 +1320,8 @@ def init( claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode + trae_skill_mode = selected_ai == "trae" + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1332,7 +1333,7 @@ def init( usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: - if codex_skill_mode or agy_skill_mode: + if codex_skill_mode or agy_skill_mode or trae_skill_mode: return f"$speckit-{name}" if claude_skill_mode: return f"/speckit-{name}" diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 7037eecb8c..343a7527f8 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -1,21 +1,40 @@ -"""Trae IDE integration.""" +"""Trae IDE integration. — skills-based agent. -from ..base import MarkdownIntegration +Trae IDE uses ``.trae/skills/speckit-/SKILL.md`` layout. +In the Specify CLI Trae integration, explicit command support was deprecated +since v0.5.1; ``--skills`` defaults to ``True``. +""" +from __future__ import annotations +from ..base import IntegrationOption, SkillsIntegration + + +class TraeIntegration(SkillsIntegration): + """Integration for Trae IDE.""" -class TraeIntegration(MarkdownIntegration): key = "trae" config = { "name": "Trae", "folder": ".trae/", - "commands_subdir": "rules", + "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".trae/rules", + "dir": ".trae/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } - context_file = ".trae/rules/AGENTS.md" + context_file = ".trae/rules/project_rules.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for trae since v0.5.1)", + ), + ] diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 index f72d96318e..ae9a3d1cd0 100644 --- a/src/specify_cli/integrations/trae/scripts/update-context.ps1 +++ b/src/specify_cli/integrations/trae/scripts/update-context.ps1 @@ -1,4 +1,4 @@ -# update-context.ps1 — Trae integration: create/update .trae/rules/AGENTS.md +# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md # # Thin wrapper that delegates to the shared update-agent-context script. # Activated in Stage 7 when the shared script uses integration.json dispatch. diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh index b868a7c983..32e5c16b29 100755 --- a/src/specify_cli/integrations/trae/scripts/update-context.sh +++ b/src/specify_cli/integrations/trae/scripts/update-context.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# update-context.sh — Trae integration: create/update .trae/rules/AGENTS.md +# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md # # Thin wrapper that delegates to the shared update-agent-context script. # Activated in Stage 7 when the shared script uses integration.json dispatch. diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 0c8bba1757..137d1d22a8 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -717,7 +717,7 @@ def _register_skills( ai_skills_enabled = bool(init_opts.get("ai_skills")) registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) - # Native skill agents (e.g. codex/kimi/agy) materialize brand-new + # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new # preset skills in _register_commands() because their detected agent # directory is already the skills directory. This flag is only for # command-backed agents that also mirror commands into skills. diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py index 307c3481db..74b8b41c3f 100644 --- a/tests/integrations/test_integration_trae.py +++ b/tests/integrations/test_integration_trae.py @@ -1,11 +1,11 @@ """Tests for TraeIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +from .test_integration_base_skills import SkillsIntegrationTests -class TestTraeIntegration(MarkdownIntegrationTests): +class TestTraeIntegration(SkillsIntegrationTests): KEY = "trae" FOLDER = ".trae/" - COMMANDS_SUBDIR = "rules" - REGISTRAR_DIR = ".trae/rules" - CONTEXT_FILE = ".trae/rules/AGENTS.md" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".trae/skills" + CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 8e293baa17..35d8c02f7e 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -139,7 +139,7 @@ def test_trae_in_agent_config(self): """AGENT_CONFIG should include trae with correct folder and commands_subdir.""" assert "trae" in AGENT_CONFIG assert AGENT_CONFIG["trae"]["folder"] == ".trae/" - assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules" + assert AGENT_CONFIG["trae"]["commands_subdir"] == "skills" assert AGENT_CONFIG["trae"]["requires_cli"] is False assert AGENT_CONFIG["trae"]["install_url"] is None @@ -151,7 +151,7 @@ def test_trae_in_extension_registrar(self): trae_cfg = cfg["trae"] assert trae_cfg["format"] == "markdown" assert trae_cfg["args"] == "$ARGUMENTS" - assert trae_cfg["extension"] == ".md" + assert trae_cfg["extension"] == "/SKILL.md" def test_trae_in_agent_context_scripts(self): """Agent context scripts should support trae agent type.""" From 40fb276023dc75898a9b5548cd03867c80e385c5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:58:43 -0500 Subject: [PATCH 203/321] fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115) * fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) _render_toml_string placed the closing `"""` inline with content, so a body ending with `"` produced `""""` (four consecutive quotes). While technically valid TOML 1.0, this breaks stricter parsers such as Gemini CLI v0.27.2. Insert a newline before the closing delimiter when the body ends with a quote character. Same treatment for the single-quote (`'''`) fallback. Adds both a positive test (body ending with `"` must not produce `""""`) and a negative test (safe bodies keep the inline delimiter). * fix: use line-ending backslash instead of newline for TOML closing delimiters Address PR review feedback: - Replace sep=newline with TOML line-ending backslash so the parsed value does not gain a trailing newline when body ends with a quote. - For literal string (''') fallback, skip to escaped basic string when value ends with single quote instead of inserting a newline. - Make test body multiline so it exercises the """ rendering path, and assert no trailing newline in parsed value. * test: cover escaped basic-string fallback when body has triple-quotes and ends with single-quote Addresses review feedback from PR #2115: adds test for the branch where the body contains '"""' and ends with "'", which forces _render_toml_string() through the escaped basic-string fallback instead of the '''...''' literal-string path (since '''' would produce the same ambiguous-closing-delimiter problem). --- src/specify_cli/integrations/base.py | 4 +- .../test_integration_base_toml.py | 83 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 0722b9a91e..1b09347dcd 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -597,8 +597,10 @@ def _render_toml_string(value: str) -> str: escaped = value.replace("\\", "\\\\") if '"""' not in escaped: + if escaped.endswith('"'): + return '"""\n' + escaped + '\\\n"""' return '"""\n' + escaped + '"""' - if "'''" not in value: + if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" return '"' + ( diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 2582a9a855..fcded1834e 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -204,6 +204,89 @@ def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] + def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): + """Multiline body ending with `"` must not produce `""""` (#2113).""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + "Check the following:\n" + '- Correct: "Is X clearly specified?"\n', + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + assert '""""' not in raw, "closing delimiter must not merge with body quote" + assert '"""\n' in raw, "body must use multiline basic string" + parsed = tomllib.loads(raw) + assert parsed["prompt"].endswith('specified?"') + assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + + def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch): + """Body containing `\"\"\"` and ending with `'` falls back to escaped basic string.""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + 'Use """triple""" quotes\n' + "and end with 'single'\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + assert "''''" not in raw, "literal string must not produce ambiguous closing quotes" + parsed = tomllib.loads(raw) + assert parsed["prompt"].endswith("'single'") + assert '"""triple"""' in parsed["prompt"] + assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + + def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): + """Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline).""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + "Line one\n" + "Plain body content\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + parsed = tomllib.loads(raw) + assert parsed["prompt"] == "Line one\nPlain body content" + assert raw.rstrip().endswith('content"""'), \ + "closing delimiter should be inline when body does not end with a quote" + def test_toml_is_valid(self, tmp_path): """Every generated TOML file must parse without errors.""" i = get_integration(self.KEY) From 375b2fdb1ddc5a8c2c40ff095c1d8ed7862a686f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 7 Apr 2026 23:44:24 +0500 Subject: [PATCH 204/321] Add toc-navigation community preset to catalog and README (#2080) * feat: add Table of Contents to generated markdown documents (#1970) * fix: address Copilot review - clarify TOC placement wording * fix: include TOC sections in structure templates * fix: include TOC in structure templates and fix tasks TOC placement wording * fix: correct TOC anchors to match headings with mandatory suffix * fix: include all ##-level headings in tasks-template TOC * fix: add missing TOC entries in tasks-template, remove leading blank line in * fix: move TOC after metadata block and include all ## headings in tasks-template * fix: use plain text for dynamic phase entries in tasks-template TOC * fix: remove hardcoded anchor links from template TOCs, use plain text exemplars * fix: remove HTML comments from template TOCs * fix: add missing Parallel Example heading to tasks-template TOC * revert: remove all core template changes, pivot to preset approach * feat: deliver TOC navigation as a preset (closes #1970) Pivots from core template changes to a preset approach per reviewer request. Adds presets/toc-navigation/ with 3 template overrides and 3 command overrides that add Table of Contents sections to generated spec.md, plan.md, and tasks.md documents. Addresses all 8 impact concerns from review: - Templates use anchor links (not plain text) matching command instructions - All 12 tasks-template headings accounted for (dynamic phases as plain text) - spec-template anchors include -mandatory suffix - TOC placed after Note paragraph in plan-template - Self-reference exclusion explicit in all commands - Clarify stale TOC instruction in specify command - Implement misparse warning in tasks command Co-Authored-By: Claude Sonnet 4.6 * feat: publish toc-navigation preset to community catalog (#1970) Move preset to standalone repository per maintainer guidance: https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation - Remove presets/toc-navigation/ from core repo - Add toc-navigation entry to catalog.community.json Co-Authored-By: Claude Sonnet 4.6 * Add toc-navigation preset to main README community presets table Adds Table of Contents Navigation entry (alphabetically between Pirate Speak and VS Code Ask Questions) to the community presets table in README.md as requested by maintainer. --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + presets/catalog.community.json | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/README.md b/README.md index 45f6bb91bc..be83ee9684 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 53616c2d71..73a022cfd1 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -78,6 +78,30 @@ "experimental" ] }, + "toc-navigation": { + "name": "Table of Contents Navigation", + "id": "toc-navigation", + "version": "1.0.0", + "description": "Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents", + "author": "Quratulain-bilal", + "repository": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 3, + "commands": 3 + }, + "tags": [ + "navigation", + "toc", + "documentation" + ] + }, "vscode-ask-questions": { "name": "VS Code Ask Questions", "id": "vscode-ask-questions", From 1c25b5af3b1e75847adcade17be79d53fdce95eb Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Apr 2026 01:47:06 +0500 Subject: [PATCH 205/321] Add explicit-task-dependencies community preset to catalog and README (#2091) Registers the explicit-task-dependencies preset in the community catalog and README. The preset adds (depends on T###) dependency declarations and an Execution Wave DAG to tasks.md. Preset repository: https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies Related to #1934 --- README.md | 1 + presets/catalog.community.json | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index be83ee9684..711abb341e 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ The following community-contributed presets customize how Spec Kit behaves — o |--------|---------|----------|----------|-----| | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 73a022cfd1..625bc9ed50 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -53,6 +53,31 @@ "spec-first" ] }, + "explicit-task-dependencies": { + "name": "Explicit Task Dependencies", + "id": "explicit-task-dependencies", + "version": "1.0.0", + "description": "Adds explicit (depends on T###) dependency declarations and an Execution Wave DAG to tasks.md for dependency-resolved parallel scheduling", + "author": "Quratulain-bilal", + "repository": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 1, + "commands": 1 + }, + "tags": [ + "dependencies", + "parallel", + "scheduling", + "wave-dag" + ] + }, "pirate": { "name": "Pirate Speak (Full)", "id": "pirate", From feb839103d7a46d4622e6b33ba11ceb56fdb1ab6 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Apr 2026 20:27:39 +0500 Subject: [PATCH 206/321] Add Spec Refine community extension to catalog and README (#2118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Spec Refine community extension to catalog and README Adds the spec-kit-refine extension (4 commands, 2 hooks) that enables iterative specification refinement — update specs in-place, propagate changes to plan and tasks, diff impact, and track sync status. Addresses community request in issue #1191 (101+ upvotes). Co-Authored-By: Claude Sonnet 4.6 * Fix alphabetical ordering of S-entries in Community Extensions table Reorders Ship Release, Spec Critique, Spec Refine, Spec Sync, Staff Review, and Superpowers Bridge into correct alphabetical order per publishing guide. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 5 +++-- extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 711abb341e..e996583429 100644 --- a/README.md +++ b/README.md @@ -221,11 +221,12 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | -| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | -| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | +| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | +| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index da4fc75da3..6935624ee7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-04-08T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1067,6 +1067,38 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "refine": { + "name": "Spec Refine", + "id": "refine", + "description": "Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-refine/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-refine", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-refine", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "refine", + "iterate", + "propagation", + "workflow", + "specifications" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "repoindex": { "name": "Repository Index", "id": "repoindex", From 4d58ee945c7c5b12ea6df7b3508c19cab754f2c6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:35:06 -0500 Subject: [PATCH 207/321] Added March 2026 newsletter (#2124) * Added March 2026 newsletter * Use ASCII hyphen in newsletter title for consistency --- newsletters/2026-March.md | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 newsletters/2026-March.md diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md new file mode 100644 index 0000000000..1d42443dcd --- /dev/null +++ b/newsletters/2026-March.md @@ -0,0 +1,86 @@ +# Spec Kit - March 2026 Newsletter + +This edition covers Spec Kit activity in March 2026. Versions v0.2.0 through v0.4.3 shipped during the month — nine releases — introducing major capabilities including simultaneous multi-catalog extension support, a pluggable preset system, air-gapped offline deployment, and automatic skill registration for extensions. Seven new AI coding assistants were integrated, bringing total platform support past 22. Community activity included over twenty new extensions, independent walkthroughs and blog posts, and a wave of industry coverage debating whether "vibe coding" is dead. A category summary is in the table below, followed by details. + +| **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Versions **v0.2.0** through **v0.4.3** shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added (Tabnine CLI, Kimi Code, Mistral Vibe, Junie, iFlow, Trae, Pi). The repo grew from ~71k to **72,700 stars** by March 20. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev covered SDD in practice. Over 20 community extensions reached the catalog. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module on SDD with Spec Kit was available. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) | ByteIota reported AWS pushing SDD as the new standard; multiple independent articles declared "vibe coding" dead. Augment Code published a detailed Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) | + +*** + +## Spec Kit Project Updates + +### Three Major Versions and Six Patches + +**v0.2.0** (released March 10) was the month's opening milestone. Its headline feature was **simultaneous multi-catalog support** (PR #1720), enabling users to activate both the core and community extension catalogs at the same time — a prerequisite for the modular ecosystem that would flourish throughout the rest of the month. The release bundled the first new agent integrations of March — **Tabnine CLI** (#1503) and **Kimi Code CLI** (#1790) — along with four community extensions: **Understanding** (#1778), **Ralph** (#1780), **Review** (#1775), and **Fleet Orchestrator** (#1771). Tooling improvements included `.extensionignore` support for excluding files during extension installation (#1781) and **Codex extension command registration** (#1767). Patch **v0.2.1** followed to fix broken quickstart links (#1759/#1797), add catalog CLI help documentation (#1793/#1794), and use quiet checkout to suppress git exceptions (#1792). The February 2026 newsletter was also committed as part of v0.2.1 (#1812). [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.3.0** (mid-March) delivered one of the most anticipated features: a **pluggable preset system** with catalog, resolver, and skills propagation (#1787). Presets let teams override Spec Kit's default templates and commands with their own conventions — a mechanism that Thulasi Rajasekaran described on LinkedIn as "the layer that makes AI-assisted development governable". The system supports priority-based stacking: an organization can layer an enterprise-standards preset beneath a team-style preset, with lower priority numbers winning conflicts. Version 0.3.0 also added a **/selftest.extension** core extension for testing other extensions against the framework (#1758), **RFC-aligned catalog integration** quality-of-life improvements (#1776), and hardened bash scripts against shell injection (#1809). On the agent side, v0.3.0 added **Mistral Vibe CLI** (#1725), migrated **Qwen Code CLI** from TOML to Markdown format (#1589/#1730), and deprecated explicit command support for the Antigravity (agy) agent (#1798/#1808). Several new community extensions arrived, including **DocGuard CDD** (#1838), **Archive & Reconcile** (#1844), **specify-status** (#1837), and **specify-doctor** (#1828). [\[github.com\]](https://github.com/github/spec-kit/releases) [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +Patches came rapidly. **v0.3.1** wired **before/after hook events** into the specify and plan templates (#1886), added **JSONC deep-merge** support for `settings.json` (#1874), added the **Trae IDE** agent (#1817), and introduced priority-based resolution for both extensions and presets (#1855). A greenfield **Spring Boot pirate-speak preset** demo was published in the README (#1878), and a **Go/React brownfield walkthrough** using GitHub Copilot CLI was added to community walkthroughs (#1868). **v0.3.2** added four more new agent integrations — **JetBrains Junie** (#1831), **iFlow CLI** (#1875), and **Pi Coding Agent** (#1853) — plus migrated Codex/agy init to a native skills workflow (#1906). It also shipped a **preset submission template** (#1910) and an **Extension Comparison Guide** (#1897) to help the growing community navigate overlapping extensions. Additional community extensions added in this cycle included **verify-tasks** (#1871), **conduct** (#1908), **cognitive-squad** (#1870, updated to Triadic Model), **speckit-utils** (#1896), **spec-kit-iterate** (#1887), and **spec-kit-learn** (#1883). DocGuard received three version updates in quick succession (v0.9.8, v0.9.10, v0.9.11). [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.4.0** (late March) introduced the month's headline usability feature: **auto-registration of extension skills** (#1840), so that any installed extension's commands are automatically exposed as agent skills without extra configuration. It also delivered **air-gapped/offline deployment** by embedding the core template pack directly in the CLI wheel (#1803), enabling Spec Kit to function in restricted environments with no internet access. A **timestamp-based branch naming** option was added for `specify init` (#1911) to better support parallel feature development. The YAML I/O layer was fixed to use `allow_unicode=True` and `encoding="utf-8"` (#1936), and the stale-issue GitHub Action was increased to 250 operations per run (#1922). New community extensions in this cycle included **Checkpoint** (#1947). [\[github.com\]](https://github.com/github/spec-kit/releases) + +Three rapid patches closed the month. **v0.4.1** fixed a missing **Assumptions section** in the spec template (#1939) and prioritized the `.specify` directory over the parent git root for **repo root detection** (#1933). **v0.4.2** was the month's most documentation-heavy release: it added **AIDE, Extensify, and Presetify** to the community catalog (#1961), moved the **community extensions table into the main README** for discoverability (#1959), added a **community presets** section (#1960), consolidated **Community Friends** sections (#1958), and formally recognized the **Spec Kit Assistant VS Code extension** (#1944). It also shipped a manual testing guide for slash command validation (#1955) and renamed "NFR" references to "success criteria" in the analyze and clarify commands (#1935). **v0.4.3** wrapped up the month by unifying Kimi/Codex skill naming and migrating legacy dotted directory names (#1971), and replacing the null-conditional operator in PowerShell scripts to restore **PowerShell 5.1 compatibility** (#1975). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Bug Fixes and Security Hardening + +Security and stability received substantial attention. The most significant fix was **shell injection hardening** of bash scripts (#1809), which addressed a class of potential injection vulnerabilities where unsanitized values from git branch names or environment variables could be passed through to shell commands. The v0.3.1 release followed up with additional bash escape and compatibility improvements (#1869). **Branch numbering** was overhauled: the old per-short-name detection scheme caused conflicts, so Spec Kit switched to **global branch numbering** (#1757) for consistent sequencing across feature branches. A **quiet git checkout** fix suppressed exceptions during branching (#1792), and a **git fetch stdout leak** was suppressed in multi-remote environments (#1876). **JSON control characters** were encoded as `\uXXXX` instead of being silently stripped (#1872). Explicit **PowerShell positional binding** was added to `create-new-feature` parameters (#1885), and the Codex native skills fallback was refreshed with legacy prompt suppression (#1930). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension Catalog at Scale + +By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article on March 20, titled *"The Feature That Turns Spec Kit Into a Platform: Extensions & Presets,"* provided a thorough analysis of what makes this ecosystem significant. Rajasekaran highlighted several standout extensions: **Conduct**, which orchestrates SDD phases by delegating to sub-agents to solve "context pollution" (where a single agent accumulates so many tokens that quality degrades); **Verify Tasks**, which scans task lists for "phantom completions" — tasks marked done with no real code behind them; **Understanding**, which runs 31 deterministic quality metrics against specifications based on IEEE/ISO standards ("like a linter for English"); and the **Jira and Azure DevOps integrations**, which auto-create work items from specs and tasks. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +Rajasekaran argued that the real significance of presets is not the mechanism itself — which is "almost comically simple" (a stack of Markdown files with a priority order) — but what it enables: the same machinery that turned "User Stories" into "Crew Tales" in the pirate-speak demo could turn them into compliance requirements with traceability IDs, add mandatory threat-model sections to every plan, or enforce TDD by requiring test tasks before implementation tasks. The pirate-speak preset, which Rajasekaran described in detail, was built by the Spec Kit maintainer using Spring Boot 4 and produced a fully functional application where every artifact — headings, paragraphs, status updates — was rendered in pirate prose. Organizations can curate which extensions are available to developers by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +## Community & Content + +### Developer Walkthroughs and Blog Posts + +March produced a wave of independent content as developers explored SDD in practice. + +**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14 (12-minute read). The post documents building an Instagram-style photo mural feature on a personal blog using the full Spec Kit workflow — from constitution setup through specification, clarification, planning, task breakdown, and implementation. Valverde contrasts the structured approach with previous ad-hoc prompting: while directly prompting Claude worked for small changes, for anything complex Valverde *"kept running into the same problems: scope creep mid-session, ambiguous requirements only noticed after implementation, and no artifact left behind to explain why a decision was made"*. The walkthrough includes actual Claude command syntax, expected file structures, and practical advice. Valverde recommends being *"overly specific in the initial prompt"* and to *"immediately review `spec.md`"* and add missing requirements before proceeding to planning. The clarify step is highlighted as particularly valuable — it surfaces guided questions with recommended options and identifies *"decisions that materially change complexity"*. On the broader philosophy, Valverde frames SDD as essential for production work: *"engineers still need to guarantee code quality, meet security requirements, keep documentation up to date, and keep all the stakeholders in sync with the project status and health"*. Valverde also published a shorter companion piece on March 8 titled *"The Shift from Vibe Coding to Spec-Driven Development,"* describing the broader industry trend. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) + +**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach to Spec Kit's thoroughness. Perez praises spec-driven development in principle (*"planning before you code catches bad assumptions early, keeps scope honest, and gives the AI enough context to write code you'd actually ship"*) but argues the standard seven-step workflow carries too much ceremony for smaller tasks: *"When you're adding a component or fixing a bug, that's a lot of overhead for a Tuesday afternoon"*. Perez's solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review. The rationale: coding rules belong in `CLAUDE.md` once (not re-checked per feature), clarification can happen iteratively rather than as a formal upfront step, and review can be added back when warranted. The custom workflow is wired into the **SpecKit Companion** VS Code extension via a `speckit.customWorkflows` configuration in `.vscode/settings.json`, giving users a visual sidebar where each phase appears as a clickable step. Perez walks through a real feature implementation — adding a home page and navigation bar — showing each phase in action with screenshots of the SpecKit Companion panel. The article highlights an important tradeoff: **full rigor vs. lightweight adoption**. Not every project needs all seven steps, and Spec Kit's extensible design accommodates both extremes. Perez also presented this workflow live at an **Angular Community Meetup** on March 25 titled *"Create Your Own AI Workflow"*. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) + +**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17, the result of two weeks of research across YouTube, Telegram, Medium, Augment Code, ThoughtWorks Radar, and GitHub repositories. The catalog organizes **20+ frameworks in 6 categories** and highlights three standouts. **BMAD-METHOD** (\~41,000 stars) simulates an entire agile team from AI roles — Analyst, PM, Architect, Scrum Master — and produces a full PRD with architecture and dev stories; Golubev calls it *"perfect for a team of 3-5 people"* but notes a high barrier to entry. **QuintCode + FPF** is notable for its ADI Cycle in 5 phases (hypothesize, verify logic, test, audit, decide), which preserves decision rationale — *"three months later you won't remember the reasoning."* In one case study, FPF + ChatGPT Pro produced a 52-page spec and 280 feature files in two evenings, and QuintCode chose Docker Swarm over Kubernetes through reasoned analysis. **cc-sdd** (\~2,880 stars) provides Kiro-style SDD commands for 8 tools (Claude Code, Cursor, Gemini CLI, Codex CLI, and 4 more), with an enforced workflow that *"won't let you skip planning"*. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +Golubev also presents a **three-level SDD maturity model**: *Spec-First* (spec per task, discarded after implementation), *Spec-Anchored* (spec as a living document, changes start from it), and *Spec-as-Source* (spec is the only artifact, code is compiled output). At the most aggressive end, **Tessl** ($125M raised) is building toward spec-as-source: *"code becomes a byproduct of specification"*. Golubev's conclusion: *"SDD is not a fad and not waterfall in markdown… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice"*. The article references the **METR study (July 2025)** finding that developers using AI were **19% slower** on real-world tasks, attributing the problem to *"debugging loops from unstructured prompts"* — a finding that has become a recurring justification for the SDD movement. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +### Community Tools and Documentation Improvements + +The **Spec Kit Assistant VS Code extension**, a third-party tool providing a graphical interface for running Spec Kit commands, was formally recognized as a "Community Friend" and added to the project README (#1944, #1956). The README underwent significant reorganization during the month: the team consolidated **Community Friends** sections (#1958), moved the **community extensions table** into the main README for discoverability (#1959), added a **community presets** section (#1960), an **AIDE extension demo** (#1943), and updated the publishing guide with Category and Effect columns to help extension authors categorize submissions (#1913). An **Extension Comparison Guide** was also published (#1897) and a manual testing guide for slash command validation (#1955). The team added multiple technology-specific walkthroughs: a **Java brownfield walkthrough** (#1820), a **Go/React brownfield dashboard walkthrough** (#1868), and the **Spring Boot pirate-speak preset** demo (#1878), supplementing the walkthroughs for .NET CLI, Spring Boot + React, and ASP.NET CMS that were committed in late February/early March. [\[github.com\]](https://github.com/github/spec-kit/releases) + +A notable community project appeared on GitHub: **speckit-pipeline** by iandeherdt, described as *"a pipeline on top of spec-kit to automate the design and build process with an evaluation step"*. This tool installs specialized Claude Code agents for a **design loop** (designer + design-critic agents iterating in a real browser) and a **build loop** (developer + evaluator agents verifying implementations against acceptance criteria). Both loops produce structured feedback and iterate up to 5 cycles per sprint until the work passes a quality gate. While small (4 commits, 0 stars as of retrieval), it represents the kind of higher-order automation that the community is building atop Spec Kit's foundation. The official Spec Kit repository has an open issue (#1966) requesting a built-in pipeline command for automated end-to-end workflow execution, suggesting this pattern may eventually be incorporated into the core. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) + +A public **Microsoft Learn** training module titled *"Implement Spec-Driven Development using the GitHub Spec Kit"* was also available during March — a 3-hour, 13-unit, intermediate-level course covering practical SDD workflows with Spec Kit, providing an onboarding path for enterprise developers encountering SDD for the first time. + +## SDD Ecosystem & Industry Trends + +### The "Vibe Coding Is Dead" Narrative + +On March 20, *ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"*, reporting that AWS developers were pushing SDD as the new standard for AI-assisted coding. The article cited **over 100,000 developers** adopting SDD approaches in the first five days of tool previews, AWS demonstrating a two-week feature completed in **two days** using Kiro IDE with structured specs, and World Economic Forum research indicating **65% of developers** expect their role to change around spec-first workflows in 2026. Spec Kit received a direct recommendation as a free, open-source option supporting **22+ AI platforms**. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +The article gave equal space to critics. *Marmelab* called SDD *"the exact mistakes Agile was designed to solve."* A controlled test by *Isoform* found SDD took **33 minutes** to produce **689 lines of code** versus **8 minutes with iterative prompting**, with no measured quality improvement. The emerging consensus favored **hybrid approaches** — a Red Hat developer recommendation captured the middle ground: *"Use the vibes to explore. Use specifications to build."* Reported success cases included Google's migrations (**50% time reduction**), Airbnb migrating **3,500 test files** at a **15× speedup**, and API-first teams achieving **75% cycle time reduction**. But solo developers consistently reported overhead exceeding benefit. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +The ByteIota piece was not isolated. Within a two-week window, independent articles appeared from **Shimon Ifrah** (*"Vibe Coding Is Dead"*), **Raul Proenza** at Cox Automotive (*"Welcome to Agentic Engineering"*), **CGI** (*"From vibe coding to intent engineering"*), and **Vishal Mysore** on Medium (*"A Map of 30+ Agentic Coding Frameworks"*). ByteIota also raised an underappreciated concern: if specifications replace coding as the primary activity, **how do junior developers build the judgment needed to write good specs or review AI-generated code?** No proven onboarding model exists yet for a spec-first world. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +### Competitive Landscape and Comparisons + +The SDD tool ecosystem expanded rapidly. Beyond the frameworks catalogued by Golubev, March saw deeper public analysis of how tools compare. + +On March 31, **Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* The analysis framed Spec Kit as an agent-agnostic framework producing **plain Markdown files identical across agents**, while Intent (Augment Code's own product) offers **"living specs"** that auto-update as agents complete work. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent's is **deeper native integration** with automated drift detection. The comparison surfaced the **drift problem** as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions (Retrospective, Verify, Sync) address this post-facto, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) + +The broader competitive landscape continued to evolve. **OpenSpec** (Fission AI) remained at ~29,300 stars, **BMAD-METHOD** grew to ~41,000 stars, and **Tessl** continued in private beta pursuing spec-as-source. AWS's **Kiro** delivered the two-day implementation result cited by ByteIota. While Spec Kit leads in GitHub popularity and agent breadth, alternatives are differentiating on orchestration depth (Intent, BMAD), enforced planning discipline (cc-sdd), decision audit trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** -- supporting longer-lived specifications that evolve across multiple iterations. The Augment Code comparison and community commentary highlighted "spec drift" as a key concern. The Archive & Reconcile extension (#1844) is a community step; a core solution is expected to be a focus area. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) [\[github.com\]](https://github.com/github/spec-kit/releases) +- **CI/CD integration** -- incorporating Spec Kit verification into pull request workflows and failing builds when specs are out of alignment. The Jira and Azure DevOps extensions (#1764, #1734) are a first step. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **End-to-end workflow automation** -- an open issue (#1966) proposes a built-in pipeline command. The community-built **speckit-pipeline** by iandeherdt already demonstrates multi-agent loops with browser verification. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) +- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) +- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) + + From 4deb90f4f5790f88953db7ff2f40b6b96d570386 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:03:29 -0500 Subject: [PATCH 208/321] fix: restore alias compatibility for community extensions (#2110) (#2125) Relax alias validation in _collect_manifest_command_names() to only enforce the 3-part speckit.{ext}.{cmd} pattern on primary command names. Aliases retain type and duplicate checking but are otherwise free-form, restoring pre-#1994 behavior. This unblocks community extensions (e.g. spec-kit-verify) that use 2-part aliases like 'speckit.verify'. Fixes #2110 --- src/specify_cli/extensions.py | 38 +++++++++++++++++++---------------- tests/test_extensions.py | 8 ++++---- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6d7b7c1199..da1a5f4472 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -523,10 +523,11 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st """Collect command and alias names declared by a manifest. Performs install-time validation for extension-specific constraints: - - commands and aliases must use the canonical `speckit.{extension}.{command}` shape - - commands and aliases must use this extension's namespace + - primary commands must use the canonical `speckit.{extension}.{command}` shape + - primary commands must use this extension's namespace - command namespaces must not shadow core commands - duplicate command/alias names inside one manifest are rejected + - aliases are validated for type and uniqueness only (no pattern enforcement) Args: manifest: Parsed extension manifest @@ -563,23 +564,26 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st f"{kind.capitalize()} for command '{primary_name}' must be a string" ) - match = EXTENSION_COMMAND_NAME_PATTERN.match(name) - if match is None: - raise ValidationError( - f"Invalid {kind} '{name}': " - "must follow pattern 'speckit.{extension}.{command}'" - ) + # Enforce canonical pattern only for primary command names; + # aliases are free-form to preserve community extension compat. + if kind == "command": + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) + if match is None: + raise ValidationError( + f"Invalid {kind} '{name}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) - namespace = match.group(1) - if namespace != manifest.id: - raise ValidationError( - f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" - ) + namespace = match.group(1) + if namespace != manifest.id: + raise ValidationError( + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" + ) - if namespace in CORE_COMMAND_NAMES: - raise ValidationError( - f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" - ) + if namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) if name in declared_names: raise ValidationError( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9d4df6a9a1..c5aed03dcf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -686,8 +686,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): - """Install should reject legacy short aliases that can shadow core commands.""" + def test_install_accepts_short_alias(self, temp_dir, project_dir): + """Install should accept legacy short aliases for community extension compat.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -718,8 +718,8 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + # Should not raise — short aliases are allowed + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" From ac6714de31bab4fbe974942613bbc6c7daaf7390 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:21:50 -0500 Subject: [PATCH 209/321] docs: lighten March 2026 newsletter for readability (#2127) - Remove PR/issue number references throughout - Shorten summary table cells - Break version wall-of-text into shorter per-version paragraphs - Trim blog post summaries to key insights - Condense community tools and industry coverage sections - Merge competitive landscape subsections --- newsletters/2026-March.md | 54 +++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md index 1d42443dcd..d97ca3960f 100644 --- a/newsletters/2026-March.md +++ b/newsletters/2026-March.md @@ -1,36 +1,36 @@ # Spec Kit - March 2026 Newsletter -This edition covers Spec Kit activity in March 2026. Versions v0.2.0 through v0.4.3 shipped during the month — nine releases — introducing major capabilities including simultaneous multi-catalog extension support, a pluggable preset system, air-gapped offline deployment, and automatic skill registration for extensions. Seven new AI coding assistants were integrated, bringing total platform support past 22. Community activity included over twenty new extensions, independent walkthroughs and blog posts, and a wave of industry coverage debating whether "vibe coding" is dead. A category summary is in the table below, followed by details. +This edition covers Spec Kit activity in March 2026. Nine releases shipped (v0.2.0 through v0.4.3), introducing a pluggable preset system, air-gapped deployment, automatic skill registration, and seven new AI agent integrations. The community extension catalog grew past 20 entries, independent walkthroughs and blog posts proliferated, and industry coverage debated whether "vibe coding" is dead. A summary is in the table below, followed by details. | **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** | | --- | --- | --- | -| Versions **v0.2.0** through **v0.4.3** shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added (Tabnine CLI, Kimi Code, Mistral Vibe, Junie, iFlow, Trae, Pi). The repo grew from ~71k to **72,700 stars** by March 20. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev covered SDD in practice. Over 20 community extensions reached the catalog. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module on SDD with Spec Kit was available. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) | ByteIota reported AWS pushing SDD as the new standard; multiple independent articles declared "vibe coding" dead. Augment Code published a detailed Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) | +| Nine releases shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added. The repo grew from ~71k to **82,616 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev. Over 20 community extensions. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module became available. | ByteIota reported AWS pushing SDD as the new standard. Augment Code published a Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. | *** ## Spec Kit Project Updates -### Three Major Versions and Six Patches +### Releases Overview -**v0.2.0** (released March 10) was the month's opening milestone. Its headline feature was **simultaneous multi-catalog support** (PR #1720), enabling users to activate both the core and community extension catalogs at the same time — a prerequisite for the modular ecosystem that would flourish throughout the rest of the month. The release bundled the first new agent integrations of March — **Tabnine CLI** (#1503) and **Kimi Code CLI** (#1790) — along with four community extensions: **Understanding** (#1778), **Ralph** (#1780), **Review** (#1775), and **Fleet Orchestrator** (#1771). Tooling improvements included `.extensionignore` support for excluding files during extension installation (#1781) and **Codex extension command registration** (#1767). Patch **v0.2.1** followed to fix broken quickstart links (#1759/#1797), add catalog CLI help documentation (#1793/#1794), and use quiet checkout to suppress git exceptions (#1792). The February 2026 newsletter was also committed as part of v0.2.1 (#1812). [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.2.0** (March 10) opened the month with **simultaneous multi-catalog support**, enabling both core and community extension catalogs at the same time. It added **Tabnine CLI** and **Kimi Code CLI** agents, four community extensions (Understanding, Ralph, Review, Fleet Orchestrator), and `.extensionignore` support. Patch **v0.2.1** fixed broken quickstart links and added catalog CLI help. [\[github.com\]](https://github.com/github/spec-kit/releases) -**v0.3.0** (mid-March) delivered one of the most anticipated features: a **pluggable preset system** with catalog, resolver, and skills propagation (#1787). Presets let teams override Spec Kit's default templates and commands with their own conventions — a mechanism that Thulasi Rajasekaran described on LinkedIn as "the layer that makes AI-assisted development governable". The system supports priority-based stacking: an organization can layer an enterprise-standards preset beneath a team-style preset, with lower priority numbers winning conflicts. Version 0.3.0 also added a **/selftest.extension** core extension for testing other extensions against the framework (#1758), **RFC-aligned catalog integration** quality-of-life improvements (#1776), and hardened bash scripts against shell injection (#1809). On the agent side, v0.3.0 added **Mistral Vibe CLI** (#1725), migrated **Qwen Code CLI** from TOML to Markdown format (#1589/#1730), and deprecated explicit command support for the Antigravity (agy) agent (#1798/#1808). Several new community extensions arrived, including **DocGuard CDD** (#1838), **Archive & Reconcile** (#1844), **specify-status** (#1837), and **specify-doctor** (#1828). [\[github.com\]](https://github.com/github/spec-kit/releases) [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +**v0.3.0** (mid-March) delivered the **pluggable preset system** with catalog, resolver, and skills propagation. Presets let teams override default templates with their own conventions, using priority-based stacking. The release also added a **/selftest.extension** for testing extensions, **Mistral Vibe CLI**, migrated **Qwen Code CLI** from TOML to Markdown, and hardened bash scripts against shell injection. New community extensions included DocGuard CDD, Archive & Reconcile, specify-status, and specify-doctor. [\[github.com\]](https://github.com/github/spec-kit/releases) -Patches came rapidly. **v0.3.1** wired **before/after hook events** into the specify and plan templates (#1886), added **JSONC deep-merge** support for `settings.json` (#1874), added the **Trae IDE** agent (#1817), and introduced priority-based resolution for both extensions and presets (#1855). A greenfield **Spring Boot pirate-speak preset** demo was published in the README (#1878), and a **Go/React brownfield walkthrough** using GitHub Copilot CLI was added to community walkthroughs (#1868). **v0.3.2** added four more new agent integrations — **JetBrains Junie** (#1831), **iFlow CLI** (#1875), and **Pi Coding Agent** (#1853) — plus migrated Codex/agy init to a native skills workflow (#1906). It also shipped a **preset submission template** (#1910) and an **Extension Comparison Guide** (#1897) to help the growing community navigate overlapping extensions. Additional community extensions added in this cycle included **verify-tasks** (#1871), **conduct** (#1908), **cognitive-squad** (#1870, updated to Triadic Model), **speckit-utils** (#1896), **spec-kit-iterate** (#1887), and **spec-kit-learn** (#1883). DocGuard received three version updates in quick succession (v0.9.8, v0.9.10, v0.9.11). [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.3.1** added before/after hook events, JSONC deep-merge for `settings.json`, and the **Trae IDE** agent. **v0.3.2** added **Junie**, **iFlow CLI**, and **Pi Coding Agent**, plus a preset submission template and an Extension Comparison Guide. Community extensions continued arriving: verify-tasks, conduct, cognitive-squad, speckit-utils, spec-kit-iterate, and spec-kit-learn. [\[github.com\]](https://github.com/github/spec-kit/releases) -**v0.4.0** (late March) introduced the month's headline usability feature: **auto-registration of extension skills** (#1840), so that any installed extension's commands are automatically exposed as agent skills without extra configuration. It also delivered **air-gapped/offline deployment** by embedding the core template pack directly in the CLI wheel (#1803), enabling Spec Kit to function in restricted environments with no internet access. A **timestamp-based branch naming** option was added for `specify init` (#1911) to better support parallel feature development. The YAML I/O layer was fixed to use `allow_unicode=True` and `encoding="utf-8"` (#1936), and the stale-issue GitHub Action was increased to 250 operations per run (#1922). New community extensions in this cycle included **Checkpoint** (#1947). [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.4.0** (late March) introduced **auto-registration of extension skills** — installed extensions' commands are now automatically exposed as agent skills. It also delivered **air-gapped/offline deployment** by embedding core templates in the CLI wheel and added timestamp-based branch naming. [\[github.com\]](https://github.com/github/spec-kit/releases) -Three rapid patches closed the month. **v0.4.1** fixed a missing **Assumptions section** in the spec template (#1939) and prioritized the `.specify` directory over the parent git root for **repo root detection** (#1933). **v0.4.2** was the month's most documentation-heavy release: it added **AIDE, Extensify, and Presetify** to the community catalog (#1961), moved the **community extensions table into the main README** for discoverability (#1959), added a **community presets** section (#1960), consolidated **Community Friends** sections (#1958), and formally recognized the **Spec Kit Assistant VS Code extension** (#1944). It also shipped a manual testing guide for slash command validation (#1955) and renamed "NFR" references to "success criteria" in the analyze and clarify commands (#1935). **v0.4.3** wrapped up the month by unifying Kimi/Codex skill naming and migrating legacy dotted directory names (#1971), and replacing the null-conditional operator in PowerShell scripts to restore **PowerShell 5.1 compatibility** (#1975). [\[github.com\]](https://github.com/github/spec-kit/releases) +Three patches closed the month. **v0.4.1** fixed a missing Assumptions section in the spec template and improved repo root detection. **v0.4.2** added AIDE, Extensify, and Presetify to the community catalog, moved the community extensions table into the main README, and recognized the **Spec Kit Assistant VS Code extension** as a Community Friend. **v0.4.3** unified skill naming conventions and restored **PowerShell 5.1 compatibility**. [\[github.com\]](https://github.com/github/spec-kit/releases) ### Bug Fixes and Security Hardening -Security and stability received substantial attention. The most significant fix was **shell injection hardening** of bash scripts (#1809), which addressed a class of potential injection vulnerabilities where unsanitized values from git branch names or environment variables could be passed through to shell commands. The v0.3.1 release followed up with additional bash escape and compatibility improvements (#1869). **Branch numbering** was overhauled: the old per-short-name detection scheme caused conflicts, so Spec Kit switched to **global branch numbering** (#1757) for consistent sequencing across feature branches. A **quiet git checkout** fix suppressed exceptions during branching (#1792), and a **git fetch stdout leak** was suppressed in multi-remote environments (#1876). **JSON control characters** were encoded as `\uXXXX` instead of being silently stripped (#1872). Explicit **PowerShell positional binding** was added to `create-new-feature` parameters (#1885), and the Codex native skills fallback was refreshed with legacy prompt suppression (#1930). [\[github.com\]](https://github.com/github/spec-kit/releases) +The most significant fix was **shell injection hardening** of bash scripts, addressing potential vulnerabilities from unsanitized git branch names and environment variables. Other fixes included switching to **global branch numbering** for consistent sequencing, suppressing git checkout exceptions and fetch stdout leaks, properly encoding JSON control characters, and adding explicit PowerShell positional binding. [\[github.com\]](https://github.com/github/spec-kit/releases) -### The Extension Catalog at Scale +### The Extension Ecosystem -By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article on March 20, titled *"The Feature That Turns Spec Kit Into a Platform: Extensions & Presets,"* provided a thorough analysis of what makes this ecosystem significant. Rajasekaran highlighted several standout extensions: **Conduct**, which orchestrates SDD phases by delegating to sub-agents to solve "context pollution" (where a single agent accumulates so many tokens that quality degrades); **Verify Tasks**, which scans task lists for "phantom completions" — tasks marked done with no real code behind them; **Understanding**, which runs 31 deterministic quality metrics against specifications based on IEEE/ISO standards ("like a linter for English"); and the **Jira and Azure DevOps integrations**, which auto-create work items from specs and tasks. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article *"The Feature That Turns Spec Kit Into a Platform"* highlighted standouts: **Conduct** (orchestrates SDD phases via sub-agents to avoid context pollution), **Verify Tasks** (catches "phantom completions" — tasks marked done with no real code), **Understanding** (31 quality metrics against specs based on IEEE/ISO standards), and the **Jira and Azure DevOps integrations**. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) -Rajasekaran argued that the real significance of presets is not the mechanism itself — which is "almost comically simple" (a stack of Markdown files with a priority order) — but what it enables: the same machinery that turned "User Stories" into "Crew Tales" in the pirate-speak demo could turn them into compliance requirements with traceability IDs, add mandatory threat-model sections to every plan, or enforce TDD by requiring test tasks before implementation tasks. The pirate-speak preset, which Rajasekaran described in detail, was built by the Spec Kit maintainer using Spring Boot 4 and produced a fully functional application where every artifact — headings, paragraphs, status updates — was rendered in pirate prose. Organizations can curate which extensions are available to developers by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +Rajasekaran argued the real significance of presets is what they enable: the same machinery that turned "User Stories" into pirate-speak "Crew Tales" could enforce compliance requirements, add mandatory threat-model sections, or require test tasks before implementation tasks. Organizations can curate available extensions by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) ## Community & Content @@ -38,39 +38,33 @@ Rajasekaran argued that the real significance of presets is not the mechanism it March produced a wave of independent content as developers explored SDD in practice. -**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14 (12-minute read). The post documents building an Instagram-style photo mural feature on a personal blog using the full Spec Kit workflow — from constitution setup through specification, clarification, planning, task breakdown, and implementation. Valverde contrasts the structured approach with previous ad-hoc prompting: while directly prompting Claude worked for small changes, for anything complex Valverde *"kept running into the same problems: scope creep mid-session, ambiguous requirements only noticed after implementation, and no artifact left behind to explain why a decision was made"*. The walkthrough includes actual Claude command syntax, expected file structures, and practical advice. Valverde recommends being *"overly specific in the initial prompt"* and to *"immediately review `spec.md`"* and add missing requirements before proceeding to planning. The clarify step is highlighted as particularly valuable — it surfaces guided questions with recommended options and identifies *"decisions that materially change complexity"*. On the broader philosophy, Valverde frames SDD as essential for production work: *"engineers still need to guarantee code quality, meet security requirements, keep documentation up to date, and keep all the stakeholders in sync with the project status and health"*. Valverde also published a shorter companion piece on March 8 titled *"The Shift from Vibe Coding to Spec-Driven Development,"* describing the broader industry trend. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) +**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14. He documents building an Instagram-style photo mural feature using the full Spec Kit workflow, contrasting it with previous ad-hoc prompting: while directly prompting Claude worked for small changes, complex work led to scope creep, ambiguous requirements discovered too late, and no artifacts left behind. Valverde recommends being specific in the initial prompt, reviewing `spec.md` immediately, and highlights the clarify step as particularly valuable. A shorter companion piece, *"The Shift from Vibe Coding to Spec-Driven Development,"* appeared on March 8. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) -**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach to Spec Kit's thoroughness. Perez praises spec-driven development in principle (*"planning before you code catches bad assumptions early, keeps scope honest, and gives the AI enough context to write code you'd actually ship"*) but argues the standard seven-step workflow carries too much ceremony for smaller tasks: *"When you're adding a component or fixing a bug, that's a lot of overhead for a Tuesday afternoon"*. Perez's solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review. The rationale: coding rules belong in `CLAUDE.md` once (not re-checked per feature), clarification can happen iteratively rather than as a formal upfront step, and review can be added back when warranted. The custom workflow is wired into the **SpecKit Companion** VS Code extension via a `speckit.customWorkflows` configuration in `.vscode/settings.json`, giving users a visual sidebar where each phase appears as a clickable step. Perez walks through a real feature implementation — adding a home page and navigation bar — showing each phase in action with screenshots of the SpecKit Companion panel. The article highlights an important tradeoff: **full rigor vs. lightweight adoption**. Not every project needs all seven steps, and Spec Kit's extensible design accommodates both extremes. Perez also presented this workflow live at an **Angular Community Meetup** on March 25 titled *"Create Your Own AI Workflow"*. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) +**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach. He praises SDD in principle but argues the full seven-step workflow carries too much ceremony for smaller tasks. His solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review, wired into the **SpecKit Companion** VS Code extension. The article highlights an important tradeoff: full rigor vs. lightweight adoption. Perez also presented this workflow at an **Angular Community Meetup** on March 25. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) -**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17, the result of two weeks of research across YouTube, Telegram, Medium, Augment Code, ThoughtWorks Radar, and GitHub repositories. The catalog organizes **20+ frameworks in 6 categories** and highlights three standouts. **BMAD-METHOD** (\~41,000 stars) simulates an entire agile team from AI roles — Analyst, PM, Architect, Scrum Master — and produces a full PRD with architecture and dev stories; Golubev calls it *"perfect for a team of 3-5 people"* but notes a high barrier to entry. **QuintCode + FPF** is notable for its ADI Cycle in 5 phases (hypothesize, verify logic, test, audit, decide), which preserves decision rationale — *"three months later you won't remember the reasoning."* In one case study, FPF + ChatGPT Pro produced a 52-page spec and 280 feature files in two evenings, and QuintCode chose Docker Swarm over Kubernetes through reasoned analysis. **cc-sdd** (\~2,880 stars) provides Kiro-style SDD commands for 8 tools (Claude Code, Cursor, Gemini CLI, Codex CLI, and 4 more), with an enforced workflow that *"won't let you skip planning"*. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17. The catalog organizes **20+ frameworks in 6 categories**, highlighting **BMAD-METHOD** (~41k stars, simulates an agile team from AI roles), **QuintCode + FPF** (preserves decision rationale via a 5-phase ADI Cycle), and **cc-sdd** (~2.9k stars, enforced SDD workflow for 8 tools). Golubev presents a three-level maturity model: *Spec-First* (spec per task, discarded after), *Spec-Anchored* (living document), and *Spec-as-Source* (spec is the only artifact). His conclusion: "SDD is not a fad… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice." [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) -Golubev also presents a **three-level SDD maturity model**: *Spec-First* (spec per task, discarded after implementation), *Spec-Anchored* (spec as a living document, changes start from it), and *Spec-as-Source* (spec is the only artifact, code is compiled output). At the most aggressive end, **Tessl** ($125M raised) is building toward spec-as-source: *"code becomes a byproduct of specification"*. Golubev's conclusion: *"SDD is not a fad and not waterfall in markdown… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice"*. The article references the **METR study (July 2025)** finding that developers using AI were **19% slower** on real-world tasks, attributing the problem to *"debugging loops from unstructured prompts"* — a finding that has become a recurring justification for the SDD movement. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +### Community Tools and Documentation -### Community Tools and Documentation Improvements +The **Spec Kit Assistant VS Code extension** was formally recognized as a Community Friend and added to the README. The README was reorganized: community extensions table moved into the main page for discoverability, a community presets section was added, and the publishing guide gained Category and Effect columns. New walkthroughs included Java brownfield, Go/React brownfield dashboard, and the Spring Boot pirate-speak preset demo. [\[github.com\]](https://github.com/github/spec-kit/releases) -The **Spec Kit Assistant VS Code extension**, a third-party tool providing a graphical interface for running Spec Kit commands, was formally recognized as a "Community Friend" and added to the project README (#1944, #1956). The README underwent significant reorganization during the month: the team consolidated **Community Friends** sections (#1958), moved the **community extensions table** into the main README for discoverability (#1959), added a **community presets** section (#1960), an **AIDE extension demo** (#1943), and updated the publishing guide with Category and Effect columns to help extension authors categorize submissions (#1913). An **Extension Comparison Guide** was also published (#1897) and a manual testing guide for slash command validation (#1955). The team added multiple technology-specific walkthroughs: a **Java brownfield walkthrough** (#1820), a **Go/React brownfield dashboard walkthrough** (#1868), and the **Spring Boot pirate-speak preset** demo (#1878), supplementing the walkthroughs for .NET CLI, Spring Boot + React, and ASP.NET CMS that were committed in late February/early March. [\[github.com\]](https://github.com/github/spec-kit/releases) +A notable community project appeared: **speckit-pipeline** by iandeherdt — a pipeline atop Spec Kit with a **design loop** (designer + critic agents iterating in a browser) and a **build loop** (developer + evaluator agents verifying against acceptance criteria). An open issue (#1966) requests a built-in pipeline command, suggesting this pattern may eventually reach core. -A notable community project appeared on GitHub: **speckit-pipeline** by iandeherdt, described as *"a pipeline on top of spec-kit to automate the design and build process with an evaluation step"*. This tool installs specialized Claude Code agents for a **design loop** (designer + design-critic agents iterating in a real browser) and a **build loop** (developer + evaluator agents verifying implementations against acceptance criteria). Both loops produce structured feedback and iterate up to 5 cycles per sprint until the work passes a quality gate. While small (4 commits, 0 stars as of retrieval), it represents the kind of higher-order automation that the community is building atop Spec Kit's foundation. The official Spec Kit repository has an open issue (#1966) requesting a built-in pipeline command for automated end-to-end workflow execution, suggesting this pattern may eventually be incorporated into the core. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) - -A public **Microsoft Learn** training module titled *"Implement Spec-Driven Development using the GitHub Spec Kit"* was also available during March — a 3-hour, 13-unit, intermediate-level course covering practical SDD workflows with Spec Kit, providing an onboarding path for enterprise developers encountering SDD for the first time. +A public **Microsoft Learn** training module, *"Implement Spec-Driven Development using the GitHub Spec Kit"* (3 hours, 13 units), provided an onboarding path for enterprise developers. ## SDD Ecosystem & Industry Trends ### The "Vibe Coding Is Dead" Narrative -On March 20, *ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"*, reporting that AWS developers were pushing SDD as the new standard for AI-assisted coding. The article cited **over 100,000 developers** adopting SDD approaches in the first five days of tool previews, AWS demonstrating a two-week feature completed in **two days** using Kiro IDE with structured specs, and World Economic Forum research indicating **65% of developers** expect their role to change around spec-first workflows in 2026. Spec Kit received a direct recommendation as a free, open-source option supporting **22+ AI platforms**. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) - -The article gave equal space to critics. *Marmelab* called SDD *"the exact mistakes Agile was designed to solve."* A controlled test by *Isoform* found SDD took **33 minutes** to produce **689 lines of code** versus **8 minutes with iterative prompting**, with no measured quality improvement. The emerging consensus favored **hybrid approaches** — a Red Hat developer recommendation captured the middle ground: *"Use the vibes to explore. Use specifications to build."* Reported success cases included Google's migrations (**50% time reduction**), Airbnb migrating **3,500 test files** at a **15× speedup**, and API-first teams achieving **75% cycle time reduction**. But solo developers consistently reported overhead exceeding benefit. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) - -The ByteIota piece was not isolated. Within a two-week window, independent articles appeared from **Shimon Ifrah** (*"Vibe Coding Is Dead"*), **Raul Proenza** at Cox Automotive (*"Welcome to Agentic Engineering"*), **CGI** (*"From vibe coding to intent engineering"*), and **Vishal Mysore** on Medium (*"A Map of 30+ Agentic Coding Frameworks"*). ByteIota also raised an underappreciated concern: if specifications replace coding as the primary activity, **how do junior developers build the judgment needed to write good specs or review AI-generated code?** No proven onboarding model exists yet for a spec-first world. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) +*ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"* on March 20, reporting AWS pushing SDD as the new standard. Key claims: over 100,000 developers adopting SDD approaches in early tool previews, AWS demonstrating a two-week feature completed in two days using Kiro IDE, and WEF research indicating 65% of developers expect their role to shift toward spec-first workflows in 2026. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) -### Competitive Landscape and Comparisons +Critics got equal space. *Marmelab* called SDD "the exact mistakes Agile was designed to solve." An *Isoform* controlled test found SDD took 33 minutes for 689 lines vs. 8 minutes with iterative prompting, with no measured quality improvement. The emerging consensus favored hybrids — a Red Hat developer captured it: "Use the vibes to explore. Use specifications to build." Other independent articles appeared from Shimon Ifrah, Raul Proenza (Cox Automotive), CGI, and Vishal Mysore. ByteIota also raised an underappreciated concern: if specs replace coding, how do juniors build the judgment to write good specs or review AI-generated code? [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) -The SDD tool ecosystem expanded rapidly. Beyond the frameworks catalogued by Golubev, March saw deeper public analysis of how tools compare. +### Competitive Landscape -On March 31, **Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* The analysis framed Spec Kit as an agent-agnostic framework producing **plain Markdown files identical across agents**, while Intent (Augment Code's own product) offers **"living specs"** that auto-update as agents complete work. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent's is **deeper native integration** with automated drift detection. The comparison surfaced the **drift problem** as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions (Retrospective, Verify, Sync) address this post-facto, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) +**Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* on March 31. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent offers **living specs** with automated drift detection. The comparison surfaced spec drift as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions address this, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) -The broader competitive landscape continued to evolve. **OpenSpec** (Fission AI) remained at ~29,300 stars, **BMAD-METHOD** grew to ~41,000 stars, and **Tessl** continued in private beta pursuing spec-as-source. AWS's **Kiro** delivered the two-day implementation result cited by ByteIota. While Spec Kit leads in GitHub popularity and agent breadth, alternatives are differentiating on orchestration depth (Intent, BMAD), enforced planning discipline (cc-sdd), decision audit trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +The broader landscape continued evolving. OpenSpec held ~29.3k stars, BMAD-METHOD grew to ~41k, and Tessl continued in private beta. While Spec Kit leads in GitHub popularity and agent breadth, alternatives differentiate on orchestration depth (Intent, BMAD), enforced discipline (cc-sdd), decision trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) ## Roadmap From 3028a00b6e7b6ba568e49dcb816655894b5a633a Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Apr 2026 22:59:41 +0500 Subject: [PATCH 210/321] Add Branch Convention community extension to catalog and README (#2128) Adds the spec-kit-branch-convention extension (3 commands, 1 hook) that enables configurable branch and folder naming with built-in presets for GitFlow, ticket-based, date-based, and custom patterns. Addresses community request in issue #407 (39+ upvotes). Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index e996583429..cb3891d98a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ The following community-contributed extensions are available in [`catalog.commun | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6935624ee7..ee944cde59 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -106,6 +106,38 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "branch-convention": { + "name": "Branch Convention", + "id": "branch-convention", + "description": "Configurable branch and folder naming conventions for /specify with presets and custom patterns.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "branch", + "naming", + "convention", + "gitflow", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "canon": { "name": "Canon", "id": "canon", From 838bd0fedc5820e01c23c3ac663a0ba9335fcb04 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Wed, 8 Apr 2026 20:41:37 +0200 Subject: [PATCH 211/321] fix(git): surface checkout errors for existing branches (#2122) --- .../git/scripts/bash/create-new-feature.sh | 5 +- .../scripts/powershell/create-new-feature.ps1 | 8 ++- scripts/bash/create-new-feature.sh | 5 +- scripts/powershell/create-new-feature.ps1 | 8 ++- tests/test_timestamp_branches.py | 63 +++++++++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index dfae29df73..c83e8c613f 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -366,8 +366,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$ALLOW_EXISTING" = true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi exit 1 fi elif [ "$USE_TIMESTAMP" = true ]; then diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 75a4e69814..ded6eaa72f 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -327,9 +327,13 @@ if (-not $DryRun) { if ($currentBranch -eq $branchName) { # Already on the target branch } else { - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } exit 1 } } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f9ba9545df..1879647026 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -337,8 +337,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : # Otherwise switch to the existing branch instead of failing. - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi exit 1 fi elif [ "$USE_TIMESTAMP" = true ]; then diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 3e7e525b86..2f23283fc4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -315,9 +315,13 @@ if (-not $DryRun) { # Already on the target branch — nothing to do } else { # Otherwise switch to the existing branch instead of failing. - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } exit 1 } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2c13853119..605ae48965 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -15,6 +15,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" +EXT_CREATE_FEATURE = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +) +EXT_CREATE_FEATURE_PS = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +) COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" @@ -428,6 +434,43 @@ def test_allow_existing_no_git(self, no_git_dir: Path): ) assert result.returncode == 0, result.stderr + def test_allow_existing_surfaces_checkout_error(self, git_repo: Path): + """Checkout failures on an existing branch should include Git's stderr.""" + shared_file = git_repo / "shared.txt" + shared_file.write_text("base\n") + subprocess.run( + ["git", "add", "shared.txt"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "commit", "-m", "add shared file", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-b", "010-checkout-failure"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("branch version\n") + subprocess.run( + ["git", "commit", "-am", "branch change", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("uncommitted main change\n") + + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "checkout-failure", + "--number", "10", "Checkout failure", + ) + + assert result.returncode != 0, "checkout should fail with conflicting local changes" + assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr + assert "would be overwritten by checkout" in result.stderr + assert "shared.txt" in result.stderr + class TestAllowExistingBranchPowerShell: def test_powershell_supports_allow_existing_branch_flag(self): @@ -437,6 +480,26 @@ def test_powershell_supports_allow_existing_branch_flag(self): # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + def test_powershell_surfaces_checkout_errors(self): + """Static guard: PS script preserves checkout stderr on existing-branch failures.""" + contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + + +class TestGitExtensionParity: + def test_bash_extension_surfaces_checkout_errors(self): + """Static guard: git extension bash script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE.read_text(encoding="utf-8") + assert 'switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1)' in contents + assert "Failed to switch to existing branch '$BRANCH_NAME'" in contents + + def test_powershell_extension_surfaces_checkout_errors(self): + """Static guard: git extension PowerShell script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + # ── Dry-Run Tests ──────────────────────────────────────────────────────────── From 2972dec85c6370ef47275d3b4f072ecb31f7b36c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:48:36 -0500 Subject: [PATCH 212/321] =?UTF-8?q?feat:=20Git=20extension=20stage=202=20?= =?UTF-8?q?=E2=80=94=20GIT=5FBRANCH=5FNAME=20override,=20--force=20for=20e?= =?UTF-8?q?xisting=20dirs,=20auto-install=20tests=20(#1940)=20(#2117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) - Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1 for exact branch naming (bypasses all prefix/suffix generation) - Fix --force flag for 'specify init ' into existing directories - Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip, commands registered) - Add TestFeatureDirectoryResolution tests (env var, feature.json, priority, branch fallback) - Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md * fix: remove unused Tuple import (ruff F401) * fix: address Copilot review feedback (#2117) - Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic numeric prefix in both bash and PowerShell - Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte truncation logic works correctly - Add 244-byte length check for GIT_BRANCH_NAME in PowerShell - Use existing_items for non-empty dir warning with --force - Skip git extension install if already installed (idempotent --force) - Wrap PowerShell feature.json parsing in try/catch for malformed JSON - Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir' - Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template * fix: address second round of Copilot review feedback (#2117) - Guard shutil.rmtree on init failure: skip cleanup when --force merged into a pre-existing directory (prevents data loss) - Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation - Fix malformed numbered list in specify.md (restore missing step 1) - Add claude_skills.exists() assert before iterdir() in test * fix: use UTF-8 byte count for 244-byte branch name limit (#2117) - Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR} - PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead of .Length (UTF-16 code units) * fix: address third round of review feedback (#2117) - Update --dry-run help text in bash and PowerShell (branch name only) - Fix specify.md JSON example: use concrete path, not literal variable - Add TestForceExistingDirectory tests (merge + error without --force) - Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json) * fix: normalize relative paths and fix Test-HasGit compat (#2117) - Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json relative paths to absolute under repo root - PowerShell common.ps1: same normalization using IsPathRooted + Join-Path - PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot for compatibility with core common.ps1 (no param) and git-common.ps1 (optional param with default) * test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117) - TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix, timestamp prefix, overlong rejection, dry-run) - TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential prefix, timestamp prefix, overlong rejection) - Tests use extension scripts (not core) via new ext_git_repo and ext_ps_git_repo fixtures * fix: restore git init during specify init + review fixes (#2117) - Restore is_git_repo() and init_git_repo() functions removed in stage 2 - specify init now runs git init AND installs git extension (not just extension install alone) - Add is_dir() guard for non-here path to prevent uncontrolled error when target exists but is a file - Add python3 JSON fallback in common.sh for multi-line feature.json (grep pipeline fails on pretty-printed JSON without jq) * fix: use init_git_repo error_msg in failure output (#2117) * fix: ensure_executable_scripts also covers .specify/extensions/ (#2117) Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh) may lack execute bits after install. Scan both .specify/scripts/ and .specify/extensions/ for permission fixing. * fix: move chmod after extension install + sanitize error_msg (#2117) - ensure_executable_scripts() now runs after git extension install so extension .sh files get execute bits in the same init run - Sanitize init_git_repo error_msg to single line (replace newlines, truncate to 120 chars) to prevent garbled StepTracker output * fix: use tracker.error for git init/extension failures (#2117) Git init failure and extension install failure were reported as tracker.complete (showing green) even on error. Now track a git_has_error flag and call tracker.error when any step fails, so the UI correctly reflects the failure state. * fix: sanitize ext_err in git step tracker for consistent rendering (#2117) --- .../git/commands/speckit.git.feature.md | 25 +- .../git/scripts/bash/create-new-feature.sh | 123 ++++--- .../scripts/powershell/create-new-feature.ps1 | 100 +++--- scripts/bash/common.sh | 30 +- scripts/powershell/common.ps1 | 31 +- src/specify_cli/__init__.py | 190 ++++++----- templates/commands/specify.md | 74 ++-- tests/extensions/git/test_git_extension.py | 22 +- tests/integrations/test_cli.py | 141 ++++++++ tests/test_timestamp_branches.py | 318 ++++++++++++++++++ 10 files changed, 801 insertions(+), 253 deletions(-) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 13a7d0784d..1a9c5e35da 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create a new feature branch for the given specification. +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. ## User Input @@ -14,10 +14,17 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + ## Prerequisites - Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` -- If Git is not available, warn the user and skip branch creation (spec directory will still be created) +- If Git is not available, warn the user and skip branch creation ## Branch Numbering Mode @@ -45,22 +52,16 @@ Run the appropriate script based on your platform: - Do NOT pass `--number` — the script determines the correct next number automatically - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature -- The JSON output will contain BRANCH_NAME and SPEC_FILE paths - -If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: -- **Bash**: `scripts/bash/create-new-feature.sh` -- **PowerShell**: `scripts/powershell/create-new-feature.ps1` +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` ## Graceful Degradation If Git is not installed or the current directory is not a Git repository: -- The script will still create the spec directory under `specs/` -- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` -- The workflow continues normally without branch creation +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them ## Output The script outputs JSON with: -- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) -- `SPEC_FILE`: Path to the created spec file +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) - `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index c83e8c613f..286aaf7634 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -64,17 +64,21 @@ while [ $i -le $# ]; do echo "" echo "Options:" echo " --json Output in JSON format" - echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --dry-run Compute branch name without creating the branch" echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" exit 0 ;; *) @@ -258,9 +262,6 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -if [ "$DRY_RUN" != true ]; then - mkdir -p "$SPECS_DIR" -fi # Function to generate branch name with stop word filtering generate_branch_name() { @@ -301,45 +302,67 @@ generate_branch_name() { fi } -# Generate branch name -if [ -n "$SHORT_NAME" ]; then - BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi else - BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") -fi + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi -# Warn if --number and --timestamp are both specified -if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then - >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" - BRANCH_NUMBER="" -fi + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi -# Determine branch prefix -if [ "$USE_TIMESTAMP" = true ]; then - FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" -else - if [ -z "$BRANCH_NUMBER" ]; then - if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) - elif [ "$DRY_RUN" = true ]; then - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) - elif [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") - else - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi fi - fi - FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi fi # GitHub enforces a 244-byte limit on branch names MAX_BRANCH_LENGTH=244 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) @@ -354,9 +377,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -SPEC_FILE="$FEATURE_DIR/spec.md" - if [ "$DRY_RUN" != true ]; then if [ "$HAS_GIT" = true ]; then branch_create_error="" @@ -394,22 +414,6 @@ if [ "$DRY_RUN" != true ]; then >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi - mkdir -p "$FEATURE_DIR" - - if [ ! -f "$SPEC_FILE" ]; then - if type resolve_template >/dev/null 2>&1; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - else - TEMPLATE="" - fi - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" - fi - fi - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi @@ -418,35 +422,30 @@ if $JSON_MODE; then if [ "$DRY_RUN" = true ]; then jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' else jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' fi else if type json_escape >/dev/null 2>&1; then _je_branch=$(json_escape "$BRANCH_NAME") - _je_spec=$(json_escape "$SPEC_FILE") _je_num=$(json_escape "$FEATURE_NUM") else _je_branch="$BRANCH_NAME" - _je_spec="$SPEC_FILE" _je_num="$FEATURE_NUM" fi if [ "$DRY_RUN" = true ]; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index ded6eaa72f..b579f05160 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -23,12 +23,16 @@ if ($Help) { Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" - Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" + Write-Host " -DryRun Compute branch name without creating the branch" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" exit 0 } @@ -203,7 +207,9 @@ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { # Check if git is available if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { - $hasGit = Test-HasGit -RepoRoot $repoRoot + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit } else { try { git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null @@ -216,9 +222,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -if (-not $DryRun) { - New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -} function Get-BranchName { param([string]$Description) @@ -255,35 +258,54 @@ function Get-BranchName { } } -if ($ShortName) { - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } } else { - $branchSuffix = Get-BranchName -Description $featureDesc -} + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } -if ($Timestamp -and $Number -ne 0) { - Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" - $Number = 0 -} + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } -if ($Timestamp) { - $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" -} else { - if ($Number -eq 0) { - if ($DryRun -and $hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch - } elseif ($DryRun) { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } elseif ($hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir - } else { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } } - } - $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } } $maxBranchLength = 244 @@ -302,9 +324,6 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -$featureDir = Join-Path $specsDir $branchName -$specFile = Join-Path $featureDir 'spec.md' - if (-not $DryRun) { if ($hasGit) { $branchCreated = $false @@ -361,28 +380,12 @@ if (-not $DryRun) { } } - New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - - if (-not (Test-Path -PathType Leaf $specFile)) { - if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - } else { - $template = $null - } - if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force - } else { - New-Item -ItemType File -Path $specFile -Force | Out-Null - } - } - $env:SPECIFY_FEATURE = $branchName } if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName - SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } @@ -392,7 +395,6 @@ if ($Json) { $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" - Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5e45e8708c..04af7d794f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -194,9 +194,35 @@ get_feature_paths() { has_git_repo="true" fi - # Use prefix-based lookup to support multiple branches per spec + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) local feature_dir - if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + local _fd + if command -v jq >/dev/null 2>&1; then + _fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null) + elif command -v python3 >/dev/null 2>&1; then + # Fallback: use Python to parse JSON so pretty-printed/multi-line files work + _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null) + else + # Last resort: single-line grep fallback (won't work on multi-line JSON) + _fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/') + fi + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then echo "ERROR: Failed to resolve feature directory" >&2 return 1 fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 8c8c801ee3..35ed884f0f 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -160,7 +160,36 @@ function Get-FeaturePathsEnv { $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) + $featureJson = Join-Path $repoRoot '.specify/feature.json' + if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } elseif (Test-Path $featureJson) { + try { + $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } catch { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } [PSCustomObject]@{ REPO_ROOT = $repoRoot diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 95ab2028c1..11b6e0eda5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -35,7 +35,7 @@ import stat import yaml from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Optional import typer from rich.console import Console @@ -384,6 +384,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: return found + def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: @@ -393,7 +394,6 @@ def is_git_repo(path: Path = None) -> bool: return False try: - # Use git command to check if inside a work tree subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], check=True, @@ -404,16 +404,9 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False -def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: - """Initialize a git repository in the specified path. - - Args: - project_path: Path to initialize git repository in - quiet: if True suppress console output (tracker handles status) - Returns: - Tuple of (success: bool, error_message: Optional[str]) - """ +def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: + """Initialize a git repository in the specified path.""" try: original_cwd = Path.cwd() os.chdir(project_path) @@ -425,20 +418,19 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option if not quiet: console.print("[green]✓[/green] Git repository initialized") return True, None - except subprocess.CalledProcessError as e: error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" if e.stderr: error_msg += f"\nError: {e.stderr.strip()}" elif e.stdout: error_msg += f"\nOutput: {e.stdout.strip()}" - if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") return False, error_msg finally: os.chdir(original_cwd) + def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: """Handle merging or copying of .vscode/settings.json files. @@ -708,41 +700,45 @@ def _install_shared_infra( def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" + """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": return # Windows: skip silently - scripts_root = project_path / ".specify" / "scripts" - if not scripts_root.is_dir(): - return + scan_roots = [ + project_path / ".specify" / "scripts", + project_path / ".specify" / "extensions", + ] failures: list[str] = [] updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue + for scripts_root in scan_roots: + if not scripts_root.is_dir(): + continue + for script in scripts_root.rglob("*.sh"): try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") + if script.is_symlink() or not script.is_file(): + continue + try: + with script.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + continue + st = script.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"{script.relative_to(project_path)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") @@ -993,9 +989,11 @@ def init( console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") raise typer.Exit(1) + dir_existed_before = False if here: project_name = Path.cwd().name project_path = Path.cwd() + dir_existed_before = True existing_items = list(project_path.iterdir()) if existing_items: @@ -1010,17 +1008,29 @@ def init( raise typer.Exit(0) else: project_path = Path(project_name).resolve() + dir_existed_before = project_path.exists() if project_path.exists(): - error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" - "Please choose a different project name or remove the existing directory.", - title="[red]Directory Conflict[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) + if not project_path.is_dir(): + console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + raise typer.Exit(1) + existing_items = list(project_path.iterdir()) + if force: + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + else: + error_panel = Panel( + f"Directory '[cyan]{project_name}[/cyan]' already exists\n" + "Please choose a different project name or remove the existing directory.\n" + "Use [bold]--force[/bold] to merge into the existing directory.", + title="[red]Directory Conflict[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) if ai_assistant: if ai_assistant not in AGENT_CONFIG: @@ -1123,14 +1133,11 @@ def init( for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ("git", "Initialize git repository"), + ("git", "Install git extension"), ("final", "Finalize"), ]: tracker.add(key, label) - # Track git error message outside Live context so it persists - git_error_message = None - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: @@ -1177,26 +1184,62 @@ def init( _install_shared_infra(project_path, selected_script, tracker=tracker) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - ensure_executable_scripts(project_path, tracker=tracker) - ensure_constitution_from_template(project_path, tracker=tracker) if not no_git: tracker.start("git") + git_messages = [] + git_has_error = False + # Step 1: Initialize git repo if needed if is_git_repo(project_path): - tracker.complete("git", "existing repo detected") + git_messages.append("existing repo detected") elif should_init_git: success, error_msg = init_git_repo(project_path, quiet=True) if success: - tracker.complete("git", "initialized") + git_messages.append("initialized") + else: + git_has_error = True + # Sanitize multi-line error_msg to single line for tracker + if error_msg: + sanitized = error_msg.replace('\n', ' ').strip() + git_messages.append(f"init failed: {sanitized[:120]}") + else: + git_messages.append("init failed") + else: + git_messages.append("git not available") + # Step 2: Install bundled git extension + try: + from .extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") + if bundled_path: + manager = ExtensionManager(project_path) + if manager.registry.is_installed("git"): + git_messages.append("extension already installed") + else: + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + git_messages.append("extension installed") else: - tracker.error("git", "init failed") - git_error_message = error_msg + git_has_error = True + git_messages.append("bundled extension not found") + except Exception as ext_err: + git_has_error = True + sanitized_ext = str(ext_err).replace('\n', ' ').strip() + git_messages.append( + f"extension install failed: {sanitized_ext[:120]}" + ) + summary = "; ".join(git_messages) + if git_has_error: + tracker.error("git", summary) else: - tracker.skip("git", "git not available") + tracker.complete("git", summary) else: tracker.skip("git", "--no-git flag") + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. @@ -1262,7 +1305,7 @@ def init( _label_width = max(len(k) for k, _ in _env_pairs) env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) - if not here and project_path.exists(): + if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) finally: @@ -1271,23 +1314,6 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - # Show git error details if initialization failed - if git_error_message: - console.print() - git_error_panel = Panel( - f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n" - f"{git_error_message}\n\n" - f"[dim]You can initialize git manually later with:[/dim]\n" - f"[cyan]cd {project_path if not here else '.'}[/cyan]\n" - f"[cyan]git init[/cyan]\n" - f"[cyan]git add .[/cyan]\n" - f"[cyan]git commit -m \"Initial commit\"[/cyan]", - title="[red]Git Initialization Failed[/red]", - border_style="red", - padding=(1, 2) - ) - console.print(git_error_panel) - # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index a81b8f12f1..15c75ec396 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -8,9 +8,6 @@ handoffs: agent: speckit.clarify prompt: Clarify specification requirements send: true -scripts: - sh: scripts/bash/create-new-feature.sh "{ARGS}" - ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" --- ## User Input @@ -61,7 +58,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is* Given that feature description, do this: -1. **Generate a concise short name** (2-4 words) for the branch: +1. **Generate a concise short name** (2-4 words) for the feature: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") @@ -73,30 +70,47 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: +2. **Branch creation** (optional, via hook): - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. - - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - - If `"sequential"` or absent, do not add any extra flag (default behavior) + If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name. - - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` - - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` + If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation). - **IMPORTANT**: - - Do NOT pass `--number` — the script determines the correct next number automatically - - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - - You must only ever run this script once per feature - - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths - - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") +3. **Create the spec feature directory**: + + Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. + + **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: + 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is + 2. Otherwise, auto-generate it under `specs/`: + - Check `.specify/init-options.json` for `branch_numbering` + - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) + - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) + - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` -3. Load `templates/spec-template.md` to understand required sections. + **Create the directory and spec file**: + - `mkdir -p SPECIFY_FEATURE_DIRECTORY` + - Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point + - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md` + - Persist the resolved path to `.specify/feature.json`: + ```json + { + "feature_directory": "" + } + ``` + Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. + This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. + + **IMPORTANT**: + - You must only create one feature per `/speckit.specify` invocation + - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice + - The spec directory and file are always created by this command, never by the hook -4. Follow this execution flow: +4. Load `templates/spec-template.md` to understand required sections. - 1. Parse user description from Input +5. Follow this execution flow: + 1. Parse user description from arguments If empty: ERROR "No feature description provided" 2. Extract key concepts from description Identify: actors, actions, data, constraints @@ -120,11 +134,11 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: - a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: + a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: ```markdown # Specification Quality Checklist: [FEATURE NAME] @@ -214,9 +228,13 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +8. **Report completion** to the user with: + - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path + - `SPEC_FILE` — the spec file path + - Checklist results summary + - Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`) -8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. +9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_specify` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. @@ -245,7 +263,7 @@ Given that feature description, do this: ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. +**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command. ## Quick Guidelines diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 721bd999f2..098caf53b7 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -280,7 +280,6 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" - assert "SPEC_FILE" in data assert data["FEATURE_NUM"] == "001" def test_creates_branch_timestamp(self, tmp_path: Path): @@ -294,18 +293,6 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) - def test_creates_spec_dir(self, tmp_path: Path): - """create-new-feature.sh creates specs directory and spec.md.""" - project = _setup_project(tmp_path) - result = _run_bash( - "create-new-feature.sh", project, - "--json", "--short-name", "test-feat", "Test feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists(), f"spec.md not created at {spec_file}" - def test_increments_from_existing_specs(self, tmp_path: Path): """Sequential numbering increments past existing spec directories.""" project = _setup_project(tmp_path) @@ -321,7 +308,7 @@ def test_increments_from_existing_specs(self, tmp_path: Path): assert data["FEATURE_NUM"] == "003" def test_no_git_graceful_degradation(self, tmp_path: Path): - """create-new-feature.sh works without git (creates spec dir only).""" + """create-new-feature.sh works without git (outputs branch name, skips branch creation).""" project = _setup_project(tmp_path, git=False) result = _run_bash( "create-new-feature.sh", project, @@ -330,8 +317,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): assert result.returncode == 0, result.stderr assert "Warning" in result.stderr data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data def test_dry_run(self, tmp_path: Path): """--dry-run computes branch name without creating anything.""" @@ -382,7 +369,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] assert json_line, f"No JSON in output: {result.stdout}" data = json.loads(json_line[-1]) - assert Path(data["SPEC_FILE"]).exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data # ── auto-commit.sh Tests ───────────────────────────────────────────────────── diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 945ce6ac62..1e23e35a7d 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,6 +3,8 @@ import json import os +import yaml + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): @@ -147,3 +149,142 @@ def test_shared_infra_skips_existing_files(self, tmp_path): # Other shared files should still be installed assert (scripts_dir / "setup-plan.sh").exists() assert (templates_dir / "plan-template.md").exists() + + +class TestForceExistingDirectory: + """Tests for --force merging into an existing named directory.""" + + def test_force_merges_into_existing_dir(self, tmp_path): + """specify init --force succeeds when the directory already exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + # Place a pre-existing file to verify it survives the merge + marker = target / "user-file.txt" + marker.write_text("keep me", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", "--force", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 0, f"init --force failed: {result.output}" + + # Pre-existing file should survive + assert marker.read_text(encoding="utf-8") == "keep me" + + # Spec Kit files should be installed + assert (target / ".specify" / "init-options.json").exists() + assert (target / ".specify" / "templates" / "spec-template.md").exists() + + def test_without_force_errors_on_existing_dir(self, tmp_path): + """specify init without --force errors when directory exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 1 + assert "already exists" in result.output + + +class TestGitExtensionAutoInstall: + """Tests for auto-installation of the git extension during specify init.""" + + def test_git_extension_auto_installed(self, tmp_path): + """Without --no-git, the git extension is installed during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-auto" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Check that the tracker didn't report a git error + assert "install failed" not in result.output, f"git extension install failed: {result.output}" + + # Git extension files should be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert ext_dir.exists(), "git extension directory not installed" + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists() + assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists() + + # Hooks should be registered + extensions_yml = project / ".specify" / "extensions.yml" + assert extensions_yml.exists(), "extensions.yml not created" + hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8")) + assert "hooks" in hooks_data + assert "before_specify" in hooks_data["hooks"] + assert "before_constitution" in hooks_data["hooks"] + + def test_no_git_skips_extension(self, tmp_path): + """With --no-git, the git extension is NOT installed.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "no-git" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension should NOT be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert not ext_dir.exists(), "git extension should not be installed with --no-git" + + def test_git_extension_commands_registered(self, tmp_path): + """Git extension commands are registered with the agent during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-cmds" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension commands should be registered with the agent + claude_skills = project / ".claude" / "skills" + assert claude_skills.exists(), "Claude skills directory was not created" + git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] + assert len(git_skills) > 0, "no git extension commands registered" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 605ae48965..2161d2893c 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -4,6 +4,7 @@ Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`. """ +import json import os import re import shutil @@ -22,6 +23,8 @@ PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" ) COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" @pytest.fixture @@ -47,6 +50,62 @@ def git_repo(tmp_path: Path) -> Path: return tmp_path +@pytest.fixture +def ext_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests).""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Extension script needs common.sh at .specify/scripts/bash/ + specify_scripts = tmp_path / ".specify" / "scripts" / "bash" + specify_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, specify_scripts / "common.sh") + # Also install core scripts for compatibility + core_scripts = tmp_path / "scripts" / "bash" + core_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, core_scripts / "common.sh") + # Copy extension script + ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_dir.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") + # Also copy git-common.sh if it exists + git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + if git_common.exists(): + shutil.copy(git_common, ext_dir / "git-common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + +@pytest.fixture +def ext_ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell extension scripts.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Install core PS scripts + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + # Also install at .specify/scripts/powershell/ for extension resolution + specify_ps = tmp_path / ".specify" / "scripts" / "powershell" + specify_ps.mkdir(parents=True) + shutil.copy(common_ps, specify_ps / "common.ps1") + # Copy extension script + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") + git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + if git_common_ps.exists(): + shutil.copy(git_common_ps, ext_ps / "git-common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + @pytest.fixture def no_git_dir(tmp_path: Path) -> Path: """Create a temp directory without git, but with scripts.""" @@ -837,3 +896,262 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + +# ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── + + +class TestGitBranchNameOverrideBash: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" + + def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): + script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + cmd = ["bash", str(script), "--json", *extra_args, "ignored"] + return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}) + + def test_exact_name_no_prefix(self, ext_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "my-exact-branch" + assert data["FEATURE_NUM"] == "my-exact-branch" + + def test_sequential_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix (digits before dash).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_timestamp_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-my-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error.""" + long_name = "a" * 245 + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + def test_dry_run_with_override(self, ext_git_repo: Path): + """GIT_BRANCH_NAME works with --dry-run (no branch created).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "dry-run-override" + assert data.get("DRY_RUN") is True + branches = subprocess.run( + ["git", "branch", "--list", "dry-run-override"], + cwd=ext_git_repo, capture_output=True, text=True, + ) + assert "dry-run-override" not in branches.stdout + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") +class TestGitBranchNameOverridePowerShell: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" + + def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): + script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], + cwd=ext_ps_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}, + ) + + def test_exact_name_no_prefix(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "ps-exact-branch" + assert data["FEATURE_NUM"] == "ps-exact-branch" + + def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "099-ps-numbered" + assert data["FEATURE_NUM"] == "099" + + def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-ps-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected.""" + long_name = "a" * 245 + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + +# ── Feature Directory Resolution Tests ─────────────────────────────────────── + + +class TestFeatureDirectoryResolution: + """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + + def test_env_var_overrides_branch_lookup(self, git_repo: Path): + """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" + custom_dir = git_repo / "my-custom-specs" / "my-feature" + custom_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + assert str(custom_dir) in result.stdout + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_feature_json_overrides_branch_lookup(self, git_repo: Path): + """feature.json feature_directory takes priority over branch-based lookup.""" + custom_dir = git_repo / "specs" / "custom-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): + """Env var wins over feature.json.""" + env_dir = git_repo / "specs" / "env-feature" + env_dir.mkdir(parents=True) + json_dir = git_repo / "specs" / "json-feature" + json_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{json_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(env_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_fallback_to_branch_lookup(self, git_repo: Path): + """Without env var or feature.json, falls back to branch-based lookup.""" + subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True) + spec_dir = git_repo / "specs" / "001-test-feat" + spec_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "my-custom-specs" / "ps-feature" + custom_dir.mkdir(parents=True) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: feature.json takes priority over branch-based lookup.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "specs" / "ps-json-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") From 8472e442151e32e1ec5dbd3a0f3ee41fe6d68f68 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 9 Apr 2026 00:01:47 +0500 Subject: [PATCH 213/321] Add Spec Diagram community extension to catalog and README (#2129) Adds the spec-kit-diagram extension (3 commands, 1 hook) that auto-generates Mermaid diagrams for SDD workflow visualization, feature progress tracking, and task dependency graphs. Addresses community request in issue #467 (50+ upvotes). Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index cb3891d98a..fd7b3ce960 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | +| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ee944cde59..8d91c56744 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -322,6 +322,38 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "diagram": { + "name": "Spec Diagram", + "id": "diagram", + "description": "Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-diagram-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "diagram", + "mermaid", + "visualization", + "workflow", + "dependencies" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "docguard": { "name": "DocGuard — CDD Enforcement", "id": "docguard", From 9c73e68528e4f296d7ac149272c9fc08c1216768 Mon Sep 17 00:00:00 2001 From: 404prefrontalcortexnotfound <106208474+404prefrontalcortexnotfound@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:29:50 +1000 Subject: [PATCH 214/321] fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bash): sed replacement escaping, BSD portability, dead cleanup code Three bugs in update-agent-context.sh: 1. **sed escaping targets wrong side** (line 318-320): The escaping function escapes regex pattern characters (`[`, `.`, `*`, `^`, `$`, `+`, `{`, `}`, `|`) but these variables are used as sed *replacement* strings, not patterns. Only `&` (insert matched text), `\` (escape char), and `|` (our sed delimiter) are special in the replacement context. Also adds escaping for `project_name` which was used unescaped. 2. **BSD sed newline insertion fails on macOS** (line 364-366): Uses bash variable expansion to insert a literal newline into a sed replacement string. This works on GNU sed (Linux) but fails silently on BSD sed (macOS). Replaced with portable awk approach that works on both platforms. 3. **cleanup() removes non-existent files** (line 125-126): The cleanup trap attempts `rm -f /tmp/agent_update_*_$$` and `rm -f /tmp/manual_additions_$$` but the script never creates files matching these patterns — all temp files use `mktemp`. The wildcard with `$$` (PID) in /tmp could theoretically match unrelated files. Fixes #154 (macOS sed failure) Fixes #293 (sed expression errors) Related: #338 (shellcheck findings) * fix: restore forge case and revert copilot path change Address PR review feedback: - Restore forge) case in update_specific_agent since src/specify_cli/integrations/forge/__init__.py still exists - Revert COPILOT_FILE path from .github/agents/ back to .github/ to stay consistent with Python integration and tests - Restore FORGE_FILE variable, comments, and usage strings Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract repeated sed escaping into _esc_sed helper Address Gemini review feedback — the inline sed escaping pattern appeared 7 times in create_new_agent_file(). Extract to a single helper function for maintainability and readability. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore combined AGENTS_FILE label in update_all_existing_agents Gemini correctly identified that splitting AGENTS_FILE updates into individual calls is redundant — _update_if_new deduplicates by realpath, so only the first call logs. Restore the combined label and add back missing Pi reference. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove pre-escaped && in JS/TS commands now that _esc_sed handles it The old code manually pre-escaped & as \& in get_commands_for_language because the broken escaping function didn't handle &. Now that _esc_sed properly escapes replacement-side specials, the pre-escaping causes double-escaping: && becomes \&\& in generated files. Found by blind audit. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: split awk && mv to let set -e catch awk failures Under set -e, the left side of && does not trigger errexit on failure. Split into two statements so awk failures are fatal instead of silent. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: guard empty _CLEANUP_FILES array for Bash 3.2 compatibility On Bash 3.2, the ${arr[@]+"${arr[@]}"} pattern expands to a single empty string when the array is empty, causing rm to target .bak and .tmp in the current directory. Use explicit length check instead, which also avoids the word-splitting risk of unquoted expansion. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Bo Bobson Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/bash/update-agent-context.sh | 48 ++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b0ef4b422a..fce379b34d 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -117,13 +117,19 @@ log_warning() { echo "WARNING: $1" >&2 } +# Track temporary files for cleanup on interrupt +_CLEANUP_FILES=() + # Cleanup function for temporary files cleanup() { local exit_code=$? # Disarm traps to prevent re-entrant loop trap - EXIT INT TERM - rm -f /tmp/agent_update_*_$$ - rm -f /tmp/manual_additions_$$ + if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then + for f in "${_CLEANUP_FILES[@]}"; do + rm -f "$f" "$f.bak" "$f.tmp" + done + fi exit $exit_code } @@ -268,7 +274,7 @@ get_commands_for_language() { echo "cargo test && cargo clippy" ;; *"JavaScript"*|*"TypeScript"*) - echo "npm test \\&\\& npm run lint" + echo "npm test && npm run lint" ;; *) echo "# Add commands for $lang" @@ -281,10 +287,15 @@ get_language_conventions() { echo "$lang: Follow standard conventions" } +# Escape sed replacement-side specials for | delimiter. +# & and \ are replacement-side specials; | is our sed delimiter. +_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; } + create_new_agent_file() { local target_file="$1" local temp_file="$2" - local project_name="$3" + local project_name + project_name=$(_esc_sed "$3") local current_date="$4" if [[ ! -f "$TEMPLATE_FILE" ]]; then @@ -307,18 +318,19 @@ create_new_agent_file() { # Replace template placeholders local project_structure project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + project_structure=$(_esc_sed "$project_structure") local commands commands=$(get_commands_for_language "$NEW_LANG") - + local language_conventions language_conventions=$(get_language_conventions "$NEW_LANG") - - # Perform substitutions with error checking using safer approach - # Escape special characters for sed by using a different delimiter or escaping - local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') + + local escaped_lang=$(_esc_sed "$NEW_LANG") + local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK") + commands=$(_esc_sed "$commands") + language_conventions=$(_esc_sed "$language_conventions") + local escaped_branch=$(_esc_sed "$CURRENT_BRANCH") # Build technology stack and recent change strings conditionally local tech_stack @@ -361,17 +373,18 @@ create_new_agent_file() { fi done - # Convert \n sequences to actual newlines - newline=$(printf '\n') - sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" + # Convert literal \n sequences to actual newlines (portable — works on BSD + GNU) + awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp" + mv "$temp_file.tmp" "$temp_file" - # Clean up backup files - rm -f "$temp_file.bak" "$temp_file.bak2" + # Clean up backup files from sed -i.bak + rm -f "$temp_file.bak" # Prepend Cursor frontmatter for .mdc files so rules are auto-included if [[ "$target_file" == *.mdc ]]; then local frontmatter_file frontmatter_file=$(mktemp) || return 1 + _CLEANUP_FILES+=("$frontmatter_file") printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" cat "$temp_file" >> "$frontmatter_file" mv "$frontmatter_file" "$temp_file" @@ -395,6 +408,7 @@ update_existing_agent_file() { log_error "Failed to create temporary file" return 1 } + _CLEANUP_FILES+=("$temp_file") # Process the file in one pass local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") @@ -519,6 +533,7 @@ update_existing_agent_file() { if ! head -1 "$temp_file" | grep -q '^---'; then local frontmatter_file frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + _CLEANUP_FILES+=("$frontmatter_file") printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" cat "$temp_file" >> "$frontmatter_file" mv "$frontmatter_file" "$temp_file" @@ -571,6 +586,7 @@ update_agent_file() { log_error "Failed to create temporary file" return 1 } + _CLEANUP_FILES+=("$temp_file") if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then if mv "$temp_file" "$target_file"; then From 71143598bec2bc44e8696fad6a3e6c5b2ec9a961 Mon Sep 17 00:00:00 2001 From: toxicafunk Date: Wed, 8 Apr 2026 21:37:19 +0200 Subject: [PATCH 215/321] fix(forge): use hyphen notation in frontmatter name field (#2075) * fix(forge): use hyphen notation in frontmatter name field - Changed injected name field from 'speckit.{command}' to 'speckit-{command}' - Keeps standard filename format 'speckit.{command}.md' - Aligns with Forge's command naming convention requirements - All tests pass * feat(forge): centralize name formatting to fix extension/preset command names Address PR feedback by centralizing Forge command name formatting to ensure consistent hyphenated names across both core template setup and extension/preset command registration. Changes: - Add format_forge_command_name() utility function in forge integration - Update ForgeIntegration._apply_forge_transformations() to use centralized formatter - Add _format_name_for_agent() helper in CommandRegistrar to apply agent-specific formatting - Update CommandRegistrar.register_commands() to format names for Forge (both primary commands and aliases) - Add comprehensive test coverage for the formatter and registrar behavior Impact: - Extension commands installed for Forge now use 'name: speckit-my-extension-example' instead of 'name: speckit.my-extension.example' - Fixes ZSH/shell compatibility issues with dot notation in command names - Maintains backward compatibility for all other agents (they continue using dot notation) - Eliminates duplication between integration setup and registrar paths Example transformation: Before: name: speckit.jira.sync-status (breaks in ZSH/Forge) After: name: speckit-jira-sync-status (works everywhere) Fixes inconsistency where core templates used hyphens but extension/preset commands preserved dots, breaking Forge's naming requirements. * refactor(forge): move name formatting logic to integration module Move _format_name_for_agent function logic into Forge integration's registrar_config as a 'format_name' callback, improving separation of concerns and keeping Forge-specific logic within its integration module. Changes: - Remove _format_name_for_agent() from agents.py (shared module) - Add 'format_name' callback to Forge's registrar_config pointing to format_forge_command_name - Update CommandRegistrar to use format_name callback when available - Maintains same behavior: Forge commands use hyphenated names, others use dot notation Benefits: - Better encapsulation: Forge-specific logic lives in forge integration - More extensible: Other integrations can provide custom formatters via registrar_config - Cleaner separation: agents.py doesn't need to know about specific agent requirements * fix(forge): make format_forge_command_name idempotent Handle already-hyphenated names (speckit-foo) to prevent double-prefixing (speckit-speckit-foo). The function now returns already-formatted names unchanged, making it safe to call multiple times. Changes: - Add early return for names starting with 'speckit-' - Update docstring to clarify accepted input formats - Add examples showing idempotent behavior - Add test coverage for idempotent behavior Examples: format_forge_command_name('speckit-plan') -> 'speckit-plan' (unchanged) format_forge_command_name('speckit.plan') -> 'speckit-plan' (converted) format_forge_command_name('plan') -> 'speckit-plan' (prefixed) * test(forge): strengthen name field assertions and clarify comments Improve test_name_field_uses_hyphenated_format to fail loudly when the name field is missing instead of silently passing. Changes: - Add explicit assertion that name_match is not None before validating value - Ensures test fails if regex doesn't match (e.g., frontmatter rendering changes) - Clarify Claude comment: it doesn't use inject_name path but SKILL.md frontmatter still includes hyphenated name via build_skill_frontmatter() Before: Test would silently pass if 'name:' field was missing from frontmatter After: Test explicitly asserts field presence before validating format * docs(forge): clarify frontmatter name requirement and improve test isolation Fix misleading docstring and improve test to properly validate that the format_name callback is Forge-specific. Changes to src/specify_cli/integrations/forge/__init__.py: - Reword module docstring to clarify the requirement is specifically for the frontmatter 'name' field value, not command files or invocation - Before: 'Requires hyphenated command names ... instead of dot notation' (implied dot notation unsupported overall) - After: 'Uses a hyphenated frontmatter name value ... for shell compatibility' (clarifies it's the frontmatter field, and Forge still supports dot filenames) Changes to tests/integrations/test_integration_forge.py: - Replace Claude with Windsurf in test_registrar_does_not_affect_other_agents - Claude uses build_skill_frontmatter() which always includes hyphenated names, so testing it didn't validate that format_name callback is Forge-only - Windsurf is a standard markdown agent without inject_name - Now asserts NO 'name:' field is present, proving format_name isn't invoked - This properly validates the callback mechanism is isolated to Forge * test(forge): use parse_frontmatter for precise YAML validation Replace regex and string searches with CommandRegistrar.parse_frontmatter() to validate only YAML frontmatter, not entire file content. Prevents false positives if command body contains 'name:' lines. Changes: - test_forge_specific_transformations: Parse frontmatter dict instead of string search - test_name_field_uses_hyphenated_format: Replace regex with frontmatter parsing - test_registrar_formats_extension_command_names_for_forge: Use dict validation - test_registrar_formats_alias_names_for_forge: Use dict validation Benefits: More precise, robust against body content, better error messages, consistent with existing codebase utilities. --------- Co-authored-by: ericnoam --- src/specify_cli/agents.py | 8 +- .../integrations/forge/__init__.py | 56 ++++- tests/integrations/test_integration_forge.py | 226 +++++++++++++++++- 3 files changed, 282 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4b869283cc..ec7af88768 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -420,7 +420,9 @@ def register_commands( frontmatter.pop(key, None) if agent_config.get("inject_name") and not frontmatter.get("name"): - frontmatter["name"] = cmd_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"] @@ -454,7 +456,9 @@ def register_commands( # For agents with inject_name, render with alias-specific frontmatter if agent_config.get("inject_name"): alias_frontmatter = deepcopy(frontmatter) - alias_frontmatter["name"] = alias + # 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( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e3d5347270..e1c4d9da62 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -4,6 +4,7 @@ - Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing - Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang) - Injects `name` field into frontmatter when missing +- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH """ from __future__ import annotations @@ -15,6 +16,52 @@ from ..manifest import IntegrationManifest +def format_forge_command_name(cmd_name: str) -> str: + """Convert command name to Forge-compatible hyphenated format. + + Forge requires command names to use hyphens instead of dots for + compatibility with ZSH and other shells. This function converts + dot-notation command names to hyphenated format. + + The function is idempotent: already-formatted names are returned unchanged. + + Examples: + >>> format_forge_command_name("plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.plan") + 'speckit-plan' + >>> format_forge_command_name("speckit-plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.my-extension.example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit-my-extension-example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit.jira.sync-status") + 'speckit-jira-sync-status' + + Args: + cmd_name: Command name in dot notation (speckit.foo.bar), + hyphenated format (speckit-foo-bar), or plain name (foo) + + Returns: + Hyphenated command name with 'speckit-' prefix + """ + # Already in hyphenated format - return as-is (idempotent) + if cmd_name.startswith("speckit-"): + return cmd_name + + # Strip 'speckit.' prefix if present + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + # Replace all dots with hyphens + short_name = short_name.replace(".", "-") + + # Return with 'speckit-' prefix + return f"speckit-{short_name}" + + class ForgeIntegration(MarkdownIntegration): """Integration for Forge (forgecode.dev). @@ -39,6 +86,7 @@ class ForgeIntegration(MarkdownIntegration): "extension": ".md", "strip_frontmatter_keys": ["handoffs"], "inject_name": True, + "format_name": format_forge_command_name, # Custom name formatter } context_file = "AGENTS.md" @@ -106,7 +154,7 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str: """Apply Forge-specific transformations to processed content. 1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge) - 2. Inject 'name' field if missing + 2. Inject 'name' field if missing (using hyphenated format) """ # Parse frontmatter lines = content.split('\n') @@ -143,11 +191,11 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str: filtered_frontmatter.append(line) - # 2. Inject 'name' field if missing + # 2. Inject 'name' field if missing (using centralized formatter) has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter) if not has_name: - # Use the template name as the command name (e.g., "plan" -> "speckit.plan") - cmd_name = f"speckit.{template_name}" + # Use centralized formatter to ensure consistent hyphenated format + cmd_name = format_forge_command_name(template_name) filtered_frontmatter.insert(0, f'name: {cmd_name}') # Reconstruct content diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 10905723fb..7affd0d160 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -2,6 +2,47 @@ from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest +from specify_cli.integrations.forge import format_forge_command_name + + +class TestForgeCommandNameFormatter: + """Test the centralized Forge command name formatter.""" + + def test_simple_name_without_prefix(self): + """Test formatting a simple name without 'speckit.' prefix.""" + assert format_forge_command_name("plan") == "speckit-plan" + assert format_forge_command_name("tasks") == "speckit-tasks" + assert format_forge_command_name("specify") == "speckit-specify" + + def test_name_with_speckit_prefix(self): + """Test formatting a name that already has 'speckit.' prefix.""" + assert format_forge_command_name("speckit.plan") == "speckit-plan" + assert format_forge_command_name("speckit.tasks") == "speckit-tasks" + + def test_extension_command_name(self): + """Test formatting extension command names with dots.""" + assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example" + assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example" + + def test_complex_nested_name(self): + """Test formatting deeply nested command names.""" + assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status" + assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz" + + def test_name_with_hyphens_preserved(self): + """Test that existing hyphens are preserved.""" + assert format_forge_command_name("my-extension") == "speckit-my-extension" + assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd" + + def test_alias_formatting(self): + """Test formatting alias names.""" + assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short" + + def test_idempotent_already_hyphenated(self): + """Test that already-hyphenated names are returned unchanged (idempotent).""" + assert format_forge_command_name("speckit-plan") == "speckit-plan" + assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example" + assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status" class TestForgeIntegration: @@ -123,19 +164,22 @@ def test_templates_are_processed(self, tmp_path): def test_forge_specific_transformations(self, tmp_path): """Test Forge-specific processing: name injection and handoffs stripping.""" from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) commands_dir = tmp_path / ".forge" / "commands" + registrar = CommandRegistrar() for cmd_file in commands_dir.glob("speckit.*.md"): content = cmd_file.read_text(encoding="utf-8") + frontmatter, _ = registrar.parse_frontmatter(content) # Check that name field is injected in frontmatter - assert "\nname: " in content, f"{cmd_file.name} missing injected 'name' field" + assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter" # Check that handoffs frontmatter key is stripped - assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key" + assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter" def test_uses_parameters_placeholder(self, tmp_path): """Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files.""" @@ -168,3 +212,181 @@ def test_uses_parameters_placeholder(self, tmp_path): assert "{{parameters}}" in content, ( "checklist should contain {{parameters}} in User Input section" ) + + def test_name_field_uses_hyphenated_format(self, tmp_path): + """Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan).""" + from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + # Check that name fields use hyphenated format + registrar = CommandRegistrar() + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # Extract the name field from frontmatter using the parser + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, ( + f"{cmd_file.name} missing injected 'name' field in frontmatter" + ) + name_value = frontmatter["name"] + # Name should use hyphens, not dots + assert "." not in name_value, ( + f"{cmd_file.name} has name field with dots: {name_value} " + f"(should use hyphens for Forge/ZSH compatibility)" + ) + assert name_value.startswith("speckit-"), ( + f"{cmd_file.name} name field should start with 'speckit-': {name_value}" + ) + + +class TestForgeCommandRegistrar: + """Test CommandRegistrar's Forge-specific name formatting.""" + + def test_registrar_formats_extension_command_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts dot notation to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + # Create a test command with dot notation name + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test extension command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Forge + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Verify registration succeeded + assert "speckit.my-extension.example" in registered + + # Check the generated file has hyphenated name in frontmatter + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md" + assert forge_cmd.exists() + + content = forge_cmd.read_text(encoding="utf-8") + # Parse frontmatter to validate name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in frontmatter" + # Name field should use hyphens, not dots + assert frontmatter["name"] == "speckit-my-extension-example" + + def test_registrar_formats_alias_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts alias names to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command with alias\n" + "---\n\n" + "Test content\n", + encoding="utf-8" + ) + + # Register with Forge including an alias + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md", + "aliases": ["speckit.my-extension.ex"] + } + ] + + registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Check the alias file has hyphenated name in frontmatter + alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md" + assert alias_file.exists() + + content = alias_file.read_text(encoding="utf-8") + # Parse frontmatter to validate alias name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in alias frontmatter" + # Alias name field should also use hyphens + assert frontmatter["name"] == "speckit-my-extension-ex" + + def test_registrar_does_not_affect_other_agents(self, tmp_path): + """Verify format_name callback is Forge-specific and doesn't affect other agents.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Windsurf (standard markdown agent without inject_name) + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registrar.register_commands( + "windsurf", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Windsurf uses standard markdown format without name injection. + # The format_name callback should not be invoked for non-Forge agents. + windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md" + assert windsurf_cmd.exists() + + content = windsurf_cmd.read_text(encoding="utf-8") + # Windsurf should NOT have a name field injected + assert "name:" not in content, ( + "Windsurf should not inject name field - format_name callback should be Forge-only" + ) From cb0d9612ef4a7fd67cca2c74698fc9f7fa813653 Mon Sep 17 00:00:00 2001 From: Sharath Satish <2109335+sharathsatish@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:55:57 +0530 Subject: [PATCH 216/321] feat: update fleet extension to v1.1.0 (#2029) --- extensions/catalog.community.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8d91c56744..65fa17bb37 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -526,8 +526,8 @@ "id": "fleet", "description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.", "author": "sharathsatish", - "version": "1.0.0", - "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/sharathsatish/spec-kit-fleet", "homepage": "https://github.com/sharathsatish/spec-kit-fleet", "documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md", @@ -550,7 +550,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_at": "2026-03-06T00:00:00Z" + "updated_at": "2026-03-31T00:00:00Z" }, "iterate": { "name": "Iterate", From 1c41aacbacc2982aadcba304b9643b99fb7b3d81 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:49:19 -0500 Subject: [PATCH 217/321] fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136) typer <0.24.0 under-constrains its click dependency (click>=8.0.0), allowing resolvers to pick click <8.2 which lacks __class_getitem__ on click.Choice. This causes 'TypeError: type Choice is not subscriptable' at import time on any Python version. Pin typer>=0.24.0 (which correctly requires click>=8.2.1) and click>=8.2.1 to prevent incompatible combinations. Fixes #2134 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e46b0a14d..56978061e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ version = "0.5.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ - "typer", - "click>=8.1", + "typer>=0.24.0", + "click>=8.2.1", "rich", "platformdirs", "readchar", From aa2282ea0431feb3e0d9bc6bd911a25b38709019 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:51:35 -0500 Subject: [PATCH 218/321] chore: release 0.5.1, begin 0.5.2.dev0 development (#2137) * chore: bump version to 0.5.1 * chore: begin 0.5.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2237f7fbf0..edfed9d4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ +## [0.5.1] - 2026-04-08 + +### Changed + +- fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136) +- feat: update fleet extension to v1.1.0 (#2029) +- fix(forge): use hyphen notation in frontmatter name field (#2075) +- fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090) +- Add Spec Diagram community extension to catalog and README (#2129) +- feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117) +- fix(git): surface checkout errors for existing branches (#2122) +- Add Branch Convention community extension to catalog and README (#2128) +- docs: lighten March 2026 newsletter for readability (#2127) +- fix: restore alias compatibility for community extensions (#2110) (#2125) +- Added March 2026 newsletter (#2124) +- Add Spec Refine community extension to catalog and README (#2118) +- Add explicit-task-dependencies community preset to catalog and README (#2091) +- Add toc-navigation community preset to catalog and README (#2080) +- fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115) +- fix speckit issue for trae (#2112) +- feat: Git extension stage 1 — bundled `extensions/git` with hooks on all core commands (#1941) +- Upgraded confluence extension to v.1.1.1 (#2109) +- Update V-Model Extension Pack to v0.5.0 (#2108) +- Add canon extension and canon-core preset. (#2022) +- [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097) +- [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096) +- Add Confluence extension (#2028) +- fix: accept 4+ digit spec numbers in tests and docs (#2094) +- fix(scripts): improve git branch creation error handling (#2089) +- Add optimize extension to community catalog (#2088) +- feat: add "VS Code Ask Questions" preset (#2086) +- Add security-review v1.1.1 to community extensions catalog (#2073) +- Add `specify integration` subcommand for post-init integration management (#2083) +- Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081) +- fix: add user-invocable: true to skill frontmatter (#2077) +- fix: add actions:write permission to stale workflow (#2079) +- feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059) +- Update conduct extension to v1.0.1 (#2078) +- chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072) +- chore(deps): bump actions/configure-pages from 5 to 6 (#2071) +- feat: add spec-kit-fixit extension to community catalog (#2024) +- chore: release 0.5.0, begin 0.5.1.dev0 development (#2070) +- feat: add Forgecode agent support (#2034) + ## [0.5.0] - 2026-04-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 56978061e9..d7a55ef138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.5.1.dev0" +version = "0.5.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 55515093a25985ed601eabb39429c0528c1e72e8 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 9 Apr 2026 21:14:21 +0800 Subject: [PATCH 219/321] feat: add memorylint extension to community catalog (#2138) * feat: add memorylint extension to community catalog * chore: update speckit_version requirement to >=0.5.1 for memorylint extension * docs: register memorylint extension in README and update requirements --- README.md | 1 + extensions/catalog.community.json | 36 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd7b3ce960..dfa86965e7 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) | | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | +| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 65fa17bb37..023035d76e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -870,6 +870,38 @@ "created_at": "2026-03-26T00:00:00Z", "updated_at": "2026-03-26T00:00:00Z" }, + "memorylint": { + "name": "MemoryLint", + "id": "memorylint", + "description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.", + "author": "RbBtSn0w", + "version": "1.0.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", + "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint", + "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md", + "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.1" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "memory", + "governance", + "constitution", + "agents-md", + "process" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "onboard": { "name": "Onboard", "id": "onboard", @@ -1623,4 +1655,4 @@ "updated_at": "2026-03-16T00:00:00Z" } } -} +} \ No newline at end of file From 66125a80a95007d732d5f04dac976b02c54ababd Mon Sep 17 00:00:00 2001 From: Alfredo Perez Date: Thu, 9 Apr 2026 10:07:30 -0500 Subject: [PATCH 220/321] docs: add SpecKit Companion to Community Friends section (#2140) Add SpecKit Companion VS Code extension to the Community Friends listing alongside existing community projects. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dfa86965e7..9301fbaf82 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ Community projects that extend, visualize, or build on Spec Kit: - **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. +- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From 6af2e64e88b1371e65129b59afef3186e0ea7ed9 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Thu, 9 Apr 2026 11:29:35 -0400 Subject: [PATCH 221/321] Rewrite AGENTS.md for integration architecture (#2119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rewrite AGENTS.md for integration subpackage architecture Replaces the old AGENT_CONFIG dict-based 7-step process with documentation reflecting the integration subpackage architecture shipped in #1924. Removed: Supported Agents table, old step-by-step guide referencing AGENT_CONFIG/release scripts/case statements, Agent Categories lists, Directory Conventions section, Important Design Decisions section. Kept: About Spec Kit and Specify, Command File Formats, Argument Patterns, Devcontainer section. Added: Architecture overview, decision tree for base class selection, configure/register/scripts/test/override steps with real code examples from existing integrations (Windsurf, Gemini, Codex, Copilot). Agent-Logs-Url: https://github.com/github/spec-kit/sessions/71b25c53-7d0c-492a-9503-f40a437d5ece Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Fix JSONC comment syntax in devcontainer example Agent-Logs-Url: https://github.com/github/spec-kit/sessions/71b25c53-7d0c-492a-9503-f40a437d5ece Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs(AGENTS.md): address Copilot PR review comments - Clarify that integrations are registered by _register_builtins() in __init__.py, not self-registered at import time - Scope the key-must-match-executable rule to CLI-based integrations (requires_cli: True); IDE-based integrations use canonical identifiers - Replace placeholder in test snippet with a concrete example path (.windsurf/workflows/) - Document that hyphens in keys become underscores in test filenames (e.g. cursor-agent -> test_integration_cursor_agent.py) - Note that the argument placeholder is integration-specific (registrar_config["args"]); add Forge's {{parameters}} as an example - Apply consistency fixes to Required fields table, Key design rule callout, and Common Pitfalls #1 * docs(AGENTS.md): clarify scripts path uses Python-safe package_dir not key The scripts step previously referenced src/specify_cli/integrations//scripts/ but for hyphenated keys the actual directory is underscored (e.g. kiro-cli -> kiro_cli/). Rename the placeholder to and add a note explaining: - matches for non-hyphenated keys - uses underscores for hyphenated keys (e.g. kiro-cli -> kiro_cli/) - IntegrationBase.key always retains the original hyphenated value Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3054946896 * docs(AGENTS.md): use in pytest example command The pytest command previously used as a placeholder, but test filenames always use underscores even for hyphenated keys. This was internally inconsistent since the preceding sentence already explained the hyphen→underscore mapping. Switch to to match the actual filename on disk. Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3054962863 * docs(AGENTS.md): use in step 2 subpackage path The path src/specify_cli/integrations//__init__.py was inaccurate for hyphenated keys (e.g. kiro-cli lives in kiro_cli/, not kiro-cli/). Rename the placeholder to , define it inline (hyphens become underscores), and note that IntegrationBase.key always retains the original hyphenated value. Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3058050583 * docs(AGENTS.md): qualify 'single source of truth' to Python metadata only The registry is only authoritative for Python integration metadata. Context-update dispatcher scripts (bash + PowerShell) still require explicit per-agent cases and maintain their own supported-agent lists until they are migrated to registry-based dispatch. Tighten the claim to avoid misleading contributors into skipping the script updates. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083090261 * docs(AGENTS.md): mention ValidateSet update in PowerShell dispatcher step The update-agent-context.ps1 script has a [ValidateSet(...)] on the AgentType parameter. Without adding the new key to that list, the script rejects the argument before reaching Update-SpecificAgent. Add this as an explicit step alongside the switch case and Update-AllExistingAgents. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083217694 * fix(integrations): sort codebuddy before codex in _register_builtins() Both the import list and the _register() call list had codex before codebuddy, violating the alphabetical ordering that AGENTS.md documents. Swap them so the file matches the documented convention. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083341590 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- AGENTS.md | 564 +++++++++-------------- src/specify_cli/integrations/__init__.py | 4 +- 2 files changed, 219 insertions(+), 349 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7a06ea59b..27472ebec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,277 +10,281 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## Adding New Agent Support - -This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow. - -### Overview - -Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for: - -- **Command file formats** (Markdown, TOML, etc.) -- **Directory structures** (`.claude/commands/`, `.windsurf/workflows/`, etc.) -- **Command invocation patterns** (slash commands, CLI tools, etc.) -- **Argument passing conventions** (`$ARGUMENTS`, `{{args}}`, etc.) - -### Current Supported Agents - -| Agent | Directory | Format | CLI Tool | Description | -| -------------------------- | ---------------------- | -------- | --------------- | --------------------------- | -| **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI | -| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | -| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | -| **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) | -| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | -| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) | -| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | -| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains | -| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE | -| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI | -| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE | -| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | -| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | -| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | -| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | -| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | -| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | -| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | -| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | -| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | -| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) | -| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | -| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | -| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) | -| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI | -| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | - -### Step-by-Step Integration Guide - -Follow these steps to add a new agent (using a hypothetical new agent as an example): - -#### 1. Add to AGENT_CONFIG - -**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version. - -Add the new agent to the `AGENT_CONFIG` dictionary in `src/specify_cli/__init__.py`. This is the **single source of truth** for all agent metadata: +## Integration Architecture + +Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations//`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`. -```python -AGENT_CONFIG = { - # ... existing agents ... - "new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal) - "name": "New Agent Display Name", - "folder": ".newagent/", # Directory for agent files - "commands_subdir": "commands", # Subdirectory name for command files (default: "commands") - "install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based) - "requires_cli": True, # True if CLI tool required, False for IDE-based agents - }, -} +``` +src/specify_cli/integrations/ +├── __init__.py # INTEGRATION_REGISTRY + _register_builtins() +├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration +├── manifest.py # IntegrationManifest (file tracking) +├── claude/ # Example: SkillsIntegration subclass +│ ├── __init__.py # ClaudeIntegration class +│ └── scripts/ # Thin wrapper scripts +│ ├── update-context.sh +│ └── update-context.ps1 +├── gemini/ # Example: TomlIntegration subclass +│ ├── __init__.py +│ └── scripts/ +├── windsurf/ # Example: MarkdownIntegration subclass +│ ├── __init__.py +│ └── scripts/ +├── copilot/ # Example: IntegrationBase subclass (custom setup) +│ ├── __init__.py +│ └── scripts/ +└── ... # One subpackage per supported agent ``` -**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example: +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch. -- ✅ Use `"cursor-agent"` because the CLI tool is literally called `cursor-agent` -- ❌ Don't use `"cursor"` as a shortcut if the tool is `cursor-agent` +--- -This eliminates the need for special-case mappings throughout the codebase. +## Adding a New Integration -**Field Explanations**: +### 1. Choose a base class -- `name`: Human-readable display name shown to users -- `folder`: Directory where agent-specific files are stored (relative to project root) -- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular) - - This field enables `--ai-skills` to locate command templates correctly for skill generation -- `install_url`: Installation documentation URL (set to `None` for IDE-based agents) -- `requires_cli`: Whether the agent requires a CLI tool check during initialization +| Your agent needs… | Subclass | +|---|---| +| Standard markdown commands (`.md`) | `MarkdownIntegration` | +| TOML-format commands (`.toml`) | `TomlIntegration` | +| Skill directories (`speckit-/SKILL.md`) | `SkillsIntegration` | +| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly | -#### 2. Update CLI Help Text +Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides. -Update the `--ai` parameter help text in the `init()` command to include the new agent: +### 2. Create the subpackage + +Create `src/specify_cli/integrations//__init__.py`, where `` is the Python-safe directory name derived from ``: use the key as-is when it contains no hyphens (e.g., key `"gemini"` → `gemini/`), or replace hyphens with underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value, since that is what the CLI and registry use. For CLI-based integrations (`requires_cli: True`), the `key` should match the actual CLI tool name (the executable users install and run) so CLI checks can resolve it correctly. For IDE-based integrations (`requires_cli: False`), use the canonical integration identifier instead. + +**Minimal example — Markdown agent (Windsurf):** ```python -ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or kiro-cli"), -``` +"""Windsurf IDE integration.""" -Also update any function docstrings, examples, and error messages that list available agents. +from ..base import MarkdownIntegration -#### 3. Update README Documentation -Update the **Supported AI Agents** section in `README.md` to include the new agent: +class WindsurfIntegration(MarkdownIntegration): + key = "windsurf" + config = { + "name": "Windsurf", + "folder": ".windsurf/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".windsurf/rules/specify-rules.md" +``` -- Add the new agent to the table with appropriate support level (Full/Partial) -- Include the agent's official website link -- Add any relevant notes about the agent's implementation -- Ensure the table formatting remains aligned and consistent +**TOML agent (Gemini):** -#### 4. Update Release Package Script +```python +"""Gemini CLI integration.""" -Modify `.github/workflows/scripts/create-release-packages.sh`: +from ..base import TomlIntegration -##### Add to ALL_AGENTS array -```bash -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli) +class GeminiIntegration(TomlIntegration): + key = "gemini" + config = { + "name": "Gemini CLI", + "folder": ".gemini/", + "commands_subdir": "commands", + "install_url": "https://github.com/google-gemini/gemini-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "GEMINI.md" ``` -##### Add case statement for directory structure +**Skills agent (Codex):** -```bash -case $agent in - # ... existing cases ... - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; -esac -``` +```python +"""Codex CLI integration — skills-based agent.""" -#### 4. Update GitHub Release Script +from __future__ import annotations -Modify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages: +from ..base import IntegrationOption, SkillsIntegration -```bash -gh release create "$VERSION" \ - # ... existing packages ... - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - # Add new agent packages here + +class CodexIntegration(SkillsIntegration): + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] ``` -#### 5. Update Agent Context Scripts +#### Required fields -##### Bash script (`scripts/bash/update-agent-context.sh`) +| Field | Location | Purpose | +|---|---|---| +| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | +| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | +| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | +| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | -Add file variable: +**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). -```bash -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -``` +### 3. Register it -Add to case statement: +In `src/specify_cli/integrations/__init__.py`, add one import and one `_register()` call inside `_register_builtins()`. Both lists are alphabetical: -```bash -case "$AGENT_TYPE" in - # ... existing cases ... - windsurf) update_agent_file "$WINDSURF_FILE" "Windsurf" ;; - "") - # ... existing checks ... - [ -f "$WINDSURF_FILE" ] && update_agent_file "$WINDSURF_FILE" "Windsurf"; - # Update default creation condition - ;; -esac +```python +def _register_builtins() -> None: + # -- Imports (alphabetical) ------------------------------------------- + from .claude import ClaudeIntegration + # ... + from .newagent import NewAgentIntegration # ← add import + # ... + + # -- Registration (alphabetical) -------------------------------------- + _register(ClaudeIntegration()) + # ... + _register(NewAgentIntegration()) # ← add registration + # ... ``` -##### PowerShell script (`scripts/powershell/update-agent-context.ps1`) +### 4. Add scripts -Add file variable: +Create two thin wrapper scripts in `src/specify_cli/integrations//scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate. -```powershell -$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md' +> **Note on `` vs ``:** `` is the Python-safe directory name for your integration — it matches `` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use. + +**`update-context.sh`:** + +```bash +#!/usr/bin/env bash +# update-context.sh — integration: create/update +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" ``` -Add to switch statement: +**`update-context.ps1`:** ```powershell -switch ($AgentType) { - # ... existing cases ... - 'windsurf' { Update-AgentFile $windsurfFile 'Windsurf' } - '' { - foreach ($pair in @( - # ... existing pairs ... - @{file=$windsurfFile; name='Windsurf'} - )) { - if (Test-Path $pair.file) { Update-AgentFile $pair.file $pair.name } - } - # Update default creation condition +# update-context.ps1 — integration: create/update +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot } } -``` - -#### 6. Update CLI Tool Checks (Optional) - -For agents that require CLI tools, add checks in the `check()` command and agent validation: -```python -# In check() command -tracker.add("windsurf", "Windsurf IDE (optional)") -windsurf_ok = check_tool_for_tracker("windsurf", "https://windsurf.com/", tracker) - -# In init validation (only if CLI tool required) -elif selected_ai == "windsurf": - if not check_tool("windsurf", "Install from: https://windsurf.com/"): - console.print("[red]Error:[/red] Windsurf CLI is required for Windsurf projects") - agent_tool_missing = True +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType ``` -**Note**: CLI tool checks are now handled automatically based on the `requires_cli` field in AGENT_CONFIG. No additional code changes needed in the `check()` or `init()` commands - they automatically loop through AGENT_CONFIG and check tools as needed. - -## Important Design Decisions +Replace `` with your integration key and `` / `` with the appropriate values. -### Using Actual CLI Tool Names as Keys +You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key: -**CRITICAL**: When adding a new agent to AGENT_CONFIG, always use the **actual executable name** as the dictionary key, not a shortened or convenient version. +- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`. +- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`. -**Why this matters:** +### 5. Test it -- The `check_tool()` function uses `shutil.which(tool)` to find executables in the system PATH -- If the key doesn't match the actual CLI tool name, you'll need special-case mappings throughout the codebase -- This creates unnecessary complexity and maintenance burden +```bash +# Install into a test project +specify init my-project --integration -**Example - The Cursor Lesson:** +# Verify files were created in the commands directory configured by +# config["folder"] + config["commands_subdir"] (for example, .windsurf/workflows/) +ls -R my-project/.windsurf/workflows/ -❌ **Wrong approach** (requires special-case mapping): +# Uninstall cleanly +cd my-project && specify integration uninstall +``` -```python -AGENT_CONFIG = { - "cursor": { # Shorthand that doesn't match the actual tool - "name": "Cursor", - # ... - } -} +Each integration also has a dedicated test file at `tests/integrations/test_integration_.py`. Note that hyphens in the key are replaced with underscores in the filename (e.g., key `cursor-agent` → `test_integration_cursor_agent.py`, key `kiro-cli` → `test_integration_kiro_cli.py`). Run it with: -# Then you need special cases everywhere: -cli_tool = agent_key -if agent_key == "cursor": - cli_tool = "cursor-agent" # Map to the real tool name +```bash +pytest tests/integrations/test_integration_.py -v ``` -✅ **Correct approach** (no mapping needed): +### 6. Optional overrides -```python -AGENT_CONFIG = { - "cursor-agent": { # Matches the actual executable name - "name": "Cursor", - # ... - } -} +The base classes handle most work automatically. Override only when the agent deviates from standard patterns: -# No special cases needed - just use agent_key directly! -``` +| Override | When to use | Example | +|---|---|---| +| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | +| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag | +| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` | +| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | -**Benefits of this approach:** +**Example — Copilot (fully custom `setup`):** -- Eliminates special-case logic scattered throughout the codebase -- Makes the code more maintainable and easier to understand -- Reduces the chance of bugs when adding new agents -- Tool checking "just works" without additional mappings +Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. -#### 7. Update Devcontainer files (Optional) +### 7. Update Devcontainer files (Optional) For agents that have VS Code extensions or require CLI installation, update the devcontainer configuration files: -##### VS Code Extension-based Agents +#### VS Code Extension-based Agents For agents available as VS Code extensions, add them to `.devcontainer/devcontainer.json`: -```json +```jsonc { "customizations": { "vscode": { "extensions": [ // ... existing extensions ... - // [New Agent Name] "[New Agent Extension ID]" ] } @@ -288,7 +292,7 @@ For agents available as VS Code extensions, add them to `.devcontainer/devcontai } ``` -##### CLI-based Agents +#### CLI-based Agents For agents that require CLI tools, add installation commands to `.devcontainer/post-create.sh`: @@ -298,63 +302,16 @@ For agents that require CLI tools, add installation commands to `.devcontainer/p # Existing installations... echo -e "\n🤖 Installing [New Agent Name] CLI..." -# run_command "npm install -g [agent-cli-package]@latest" # Example for node-based CLI -# or other installation instructions (must be non-interactive and compatible with Linux Debian "Trixie" or later)... +# run_command "npm install -g [agent-cli-package]@latest" echo "✅ Done" - ``` -**Quick Tips:** - -- **Extension-based agents**: Add to the `extensions` array in `devcontainer.json` -- **CLI-based agents**: Add installation scripts to `post-create.sh` -- **Hybrid agents**: May require both extension and CLI installation -- **Test thoroughly**: Ensure installations work in the devcontainer environment - -## Agent Categories - -### CLI-Based Agents - -Require a command-line tool to be installed: - -- **Claude Code**: `claude` CLI -- **Gemini CLI**: `gemini` CLI -- **Qwen Code**: `qwen` CLI -- **opencode**: `opencode` CLI -- **Codex CLI**: `codex` CLI (requires `--ai-skills`) -- **Junie**: `junie` CLI -- **Auggie CLI**: `auggie` CLI -- **CodeBuddy CLI**: `codebuddy` CLI -- **Qoder CLI**: `qodercli` CLI -- **Kiro CLI**: `kiro-cli` CLI -- **Amp**: `amp` CLI -- **SHAI**: `shai` CLI -- **Tabnine CLI**: `tabnine` CLI -- **Kimi Code**: `kimi` CLI -- **Mistral Vibe**: `vibe` CLI -- **Pi Coding Agent**: `pi` CLI -- **iFlow CLI**: `iflow` CLI -- **Forge**: `forge` CLI - -### IDE-Based Agents - -Work within integrated development environments: - -- **GitHub Copilot**: Built into VS Code/compatible editors -- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`) -- **Windsurf**: Built into Windsurf IDE -- **Kilo Code**: Built into Kilo Code IDE -- **Roo Code**: Built into Roo Code IDE -- **IBM Bob**: Built into IBM Bob IDE -- **Trae**: Built into Trae IDE -- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`) +--- ## Command File Formats ### Markdown Format -Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge - **Standard format:** ```markdown @@ -378,8 +335,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Tabnine - ```toml description = "Command description" @@ -388,109 +343,24 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` -## Directory Conventions - -- **CLI agents**: Usually `./commands/` -- **Singular command exception**: - - opencode: `.opencode/command/` (singular `command`, not `commands`) -- **Nested path exception**: - - Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment) -- **Shared `.agents/` folder**: - - Amp: `.agents/commands/` (shared folder, not `.amp/`) - - Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-`) -- **Skills-based exceptions**: - - Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-`) -- **Prompt-based exceptions**: - - Kiro CLI: `.kiro/prompts/` - - Pi: `.pi/prompts/` - - Mistral Vibe: `.vibe/prompts/` -- **Rules-based exceptions**: - - Trae: `.trae/rules/` -- **IDE agents**: Follow IDE-specific patterns: - - Copilot: `.github/agents/` - - Cursor: `.cursor/commands/` - - Windsurf: `.windsurf/workflows/` - - Kilo Code: `.kilocode/workflows/` - - Roo Code: `.roo/commands/` - - IBM Bob: `.bob/commands/` - - Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated) - ## Argument Patterns -Different agents use different argument placeholders: +Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt: -- **Markdown/prompt-based**: `$ARGUMENTS` -- **TOML-based**: `{{args}}` -- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax) +- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents) +- **TOML-based**: `{{args}}` (e.g., Gemini) +- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) -## Special Processing Requirements - -Some agents require custom processing beyond the standard template transformations: - -### Copilot Integration - -GitHub Copilot has unique requirements: -- Commands use `.agent.md` extension (not `.md`) -- Each command gets a companion `.prompt.md` file in `.github/prompts/` -- Installs `.vscode/settings.json` with prompt file recommendations -- Context file lives at `.github/copilot-instructions.md` - -Implementation: Extends `IntegrationBase` with custom `setup()` method that: -1. Processes templates with `process_template()` -2. Generates companion `.prompt.md` files -3. Merges VS Code settings - -### Forge Integration - -Forge has special frontmatter and argument requirements: -- Uses `{{parameters}}` instead of `$ARGUMENTS` -- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) -- Injects `name` field into frontmatter when missing - -Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: -1. Inherits standard template processing from `MarkdownIntegration` -2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing -3. Applies Forge-specific transformations via `_apply_forge_transformations()` -4. Strips `handoffs` frontmatter key -5. Injects missing `name` fields -6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` (similar to `opencode`/`codex`/`pi`) and lists `forge` in their usage/help text - -### Standard Markdown Agents - -Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`: -- Simple subclass with just `key`, `config`, `registrar_config` set -- Inherits standard processing from `MarkdownIntegration.setup()` -- No custom processing needed - -## Testing New Agent Integration - -1. **Build test**: Run package creation script locally -2. **CLI test**: Test `specify init --ai ` command -3. **File generation**: Verify correct directory structure and files -4. **Command validation**: Ensure generated commands work with the agent -5. **Context update**: Test agent context update scripts - ## Common Pitfalls -1. **Using shorthand keys instead of actual CLI tool names**: Always use the actual executable name as the AGENT_CONFIG key (e.g., `"cursor-agent"` not `"cursor"`). This prevents the need for special-case mappings throughout the codebase. -2. **Forgetting update scripts**: Both bash and PowerShell scripts must be updated when adding new agents. -3. **Incorrect `requires_cli` value**: Set to `True` only for agents that actually have CLI tools to check; set to `False` for IDE-based agents. -4. **Wrong argument format**: Use correct placeholder format for each agent type (`$ARGUMENTS` for Markdown, `{{args}}` for TOML). -5. **Directory naming**: Follow agent-specific conventions exactly (check existing agents for patterns). -6. **Help text inconsistency**: Update all user-facing text consistently (help strings, docstrings, README, error messages). - -## Future Considerations - -When adding new agents: - -- Consider the agent's native command/workflow patterns -- Ensure compatibility with the Spec-Driven Development process -- Document any special requirements or limitations -- Update this guide with lessons learned -- Verify the actual CLI tool name before adding to AGENT_CONFIG +1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. +2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated. +3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. +4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. +5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. --- -*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.* +*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.* diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index c65013869e..3eb58622e7 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -51,8 +51,8 @@ def _register_builtins() -> None: from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration - from .codex import CodexIntegration from .codebuddy import CodebuddyIntegration + from .codex import CodexIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .forge import ForgeIntegration @@ -80,8 +80,8 @@ def _register_builtins() -> None: _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) - _register(CodexIntegration()) _register(CodebuddyIntegration()) + _register(CodexIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(ForgeIntegration()) From 0a121b073cdc2ddfd4fa69034a1d0cf8aead57f9 Mon Sep 17 00:00:00 2001 From: PChemGuy <39730837+pchemguy@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:23:10 +0300 Subject: [PATCH 222/321] Readme clarity (#2013) * Specify CLI Reference formatting Improves formatting of Specify CLI Reference * Available Slash Commands clarity Improve "Available Slash Commands" clarity in README.md. * Add extension/preset commands to cli reference * Extensions & Presets section clarity Improves Extensions & Presets section clarity in README.md * Removes `$` from Agent Skill * Reverts Supported AI Agents Table * Added missing Agent Skill column * Trailing whitespaces Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adds missing code block language Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revised wording Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revised specify synopsis * Update specify command reference table Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removes extra (duplicate) slashes * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed old section * missing /speckit.taskstoissues * integration command Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 115 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9301fbaf82..22518ca432 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,6 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. ## 🤖 Supported AI Agents - | Agent | Support | Notes | | ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | [Qoder CLI](https://qoder.com/cli) | ✅ | | @@ -321,22 +320,63 @@ Community projects that extend, visualize, or build on Spec Kit: | [Trae](https://www.trae.ai/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | +## Available Slash Commands + +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`. + +#### Core Commands + +Essential commands for the Spec-Driven Development workflow: + +| Command | Agent Skill | Description | +| ------------------------ | ---------------------- | -------------------------------------------------------------------------- | +| `/speckit.constitution` | `speckit-constitution` | Create or update project governing principles and development guidelines | +| `/speckit.specify` | `speckit-specify` | Define what you want to build (requirements and user stories) | +| `/speckit.plan` | `speckit-plan` | Create technical implementation plans with your chosen tech stack | +| `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation | +| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | +| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | + +#### Optional Commands + +Additional commands for enhanced quality and validation: + +| Command | Agent Skill | Description | +| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `/speckit.clarify` | `speckit-clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | +| `/speckit.analyze` | `speckit-analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | +| `/speckit.checklist` | `speckit-checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | + ## 🔧 Specify CLI Reference -The `specify` command supports the following options: +The `specify` tool is invoked as + +```text +specify [SUBCOMMAND] [OPTIONS] +``` + +and supports the following commands: ### Commands -| Command | Description | -| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | +| Command | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init` | Initialize a new Specify project from the latest template. | +| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | +| `version` | Show the currently installed Spec Kit version. | +| `extension` | Manage extensions | +| `preset` | Manage presets | +| `integration` | Manage integrations | ### `specify init` Arguments & Options +```bash +specify init [PROJECT_NAME] +``` + | Argument/Option | Type | Description | -| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | +| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | | `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | @@ -433,38 +473,6 @@ specify init my-project --ai claude --branch-numbering timestamp specify check ``` -### Available Slash Commands - -After running `specify init`, your AI coding agent will have access to these structured development commands. - -Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`. - -Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`. - -For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. - -#### Core Commands - -Essential commands for the Spec-Driven Development workflow: - -| Command | Description | -| ----------------------- | ------------------------------------------------------------------------ | -| `/speckit.constitution` | Create or update project governing principles and development guidelines | -| `/speckit.specify` | Define what you want to build (requirements and user stories) | -| `/speckit.plan` | Create technical implementation plans with your chosen tech stack | -| `/speckit.tasks` | Generate actionable task lists for implementation | -| `/speckit.implement` | Execute all tasks to build the feature according to the plan | - -#### Optional Commands - -Additional commands for enhanced quality and validation: - -| Command | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `/speckit.clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | -| `/speckit.analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | -| `/speckit.checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | - ### Environment Variables | Variable | Description | @@ -475,21 +483,18 @@ Additional commands for enhanced quality and validation: Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments: -```mermaid -block-beta - columns 1 - overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"] - presets["Presets — Customize core & extensions\n.specify/presets//templates/"] - extensions["Extensions — Add new capabilities\n.specify/extensions//templates/"] - core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"] - - style overrides fill:transparent,stroke:#999 - style presets fill:transparent,stroke:#4a9eda - style extensions fill:transparent,stroke:#4a9e4a - style core fill:transparent,stroke:#e6a817 -``` - -**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults. +| Priority | Component Type | Location | +| -------: | ------------------------------------------------- | -------------------------------- | +| ⬆ 1 | Project-Local Overrides | `.specify/templates/overrides/` | +| 2 | Presets — Customize core & extensions | `.specify/presets/templates/` | +| 3 | Extensions — Add new capabilities | `.specify/extensions/templates/` | +| ⬇ 4 | Spec Kit Core — Built-in SDD commands & templates | `.specify/templates/` | + +- **Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. +- Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. +- **Extension/preset commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). +- If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. +- If no overrides or customizations exist, Spec Kit uses its core defaults. ### Extensions — Add New Capabilities From 8013d0b57e0ccdee635dfe8d3efcad4aed483f41 Mon Sep 17 00:00:00 2001 From: Sakit Date: Thu, 9 Apr 2026 22:35:03 +0400 Subject: [PATCH 223/321] Add multi-repo-branching preset to community catalog (#2139) * Add nested-repos to community catalog - Preset ID: nested-repos - Version: 1.0.0 - Author: sakitA - Description: Multi-module nested repository support for independent repos and git submodules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump catalog updated_at timestamp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update nested-repos preset: commands-only, 0 templates Removed template overrides to reduce core content duplication. Commands instruct AI to add nested-repo sections dynamically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename preset: nested-repos -> multi-repo-branching Updated preset ID, name, description, and all URLs to reflect the new repository name and clearer preset identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove templates: 0 from catalog provides section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove accidentally committed .claude folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: restore templates key and add README entry - Add templates: 0 back to provides for catalog consistency - Add multi-repo-branching to Community Presets table in README.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 22518ca432..2eb60af31c 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 625bc9ed50..378b264c17 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-04-09T08:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -78,6 +78,33 @@ "wave-dag" ] }, + "multi-repo-branching": { + "name": "Multi-Repo Branching", + "id": "multi-repo-branching", + "version": "1.0.0", + "description": "Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases.", + "author": "sakitA", + "repository": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "download_url": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "documentation": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/blob/master/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "multi-repo-branching", + "multi-module", + "submodules", + "monorepo" + ], + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "pirate": { "name": "Pirate Speak (Full)", "id": "pirate", From efeb5489c3276c9ad18ffdbd7b9d457d07dc45a5 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 9 Apr 2026 23:53:54 +0500 Subject: [PATCH 224/321] Add Worktree Isolation extension to community catalog (#2143) - 3 commands: create, list, clean worktrees for parallel feature development - 1 hook: after_specify for auto-worktree creation - Addresses community request in issue #61 (36+ upvotes) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 2eb60af31c..b672cf0f72 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The following community-contributed extensions are available in [`catalog.commun | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | +| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 023035d76e..aa94a09066 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1653,6 +1653,38 @@ "stars": 0, "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" + }, + "worktree": { + "name": "Worktree Isolation", + "id": "worktree", + "description": "Spawn isolated git worktrees for parallel feature development without checkout switching.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-worktree/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" } } } \ No newline at end of file From 674a66449ae2c9d2fa058c4aae2fe32c059a80df Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 00:02:03 +0500 Subject: [PATCH 225/321] Add Bugfix Workflow community extension to catalog and README (#2135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Bugfix Workflow community extension to catalog and README Adds the spec-kit-bugfix extension (3 commands, 1 hook) that provides a structured bugfix workflow — capture bugs, trace to spec artifacts, and surgically patch specs without regenerating from scratch. Addresses community request in issue #619 (25+ upvotes, maintainer-approved). Co-Authored-By: Claude Sonnet 4.6 * Bump catalog updated_at to 2026-04-09 to match new entry date Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index b672cf0f72..064554c39c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | +| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index aa94a09066..87f2d56347 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -138,6 +138,38 @@ "created_at": "2026-04-08T00:00:00Z", "updated_at": "2026-04-08T00:00:00Z" }, + "bugfix": { + "name": "Bugfix Workflow", + "id": "bugfix", + "description": "Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-bugfix/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "bugfix", + "debugging", + "workflow", + "traceability", + "maintenance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "canon": { "name": "Canon", "id": "canon", From e70495c2b8bc4d32f8165b7ea01dc84a17747e0f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:17:11 -0500 Subject: [PATCH 226/321] chore: release 0.6.0, begin 0.6.1.dev0 development (#2144) * chore: bump version to 0.6.0 * chore: begin 0.6.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edfed9d4ab..139221d075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ +## [0.6.0] - 2026-04-09 + +### Changed + +- Add Bugfix Workflow community extension to catalog and README (#2135) +- Add Worktree Isolation extension to community catalog (#2143) +- Add multi-repo-branching preset to community catalog (#2139) +- Readme clarity (#2013) +- Rewrite AGENTS.md for integration architecture (#2119) +- docs: add SpecKit Companion to Community Friends section (#2140) +- feat: add memorylint extension to community catalog (#2138) +- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137) + ## [0.5.1] - 2026-04-08 ### Changed diff --git a/pyproject.toml b/pyproject.toml index d7a55ef138..e43f812724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.5.2.dev0" +version = "0.6.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From bc0288832e78d1226c8a87c276c934a87b18a109 Mon Sep 17 00:00:00 2001 From: Wes Etheredge Date: Fri, 10 Apr 2026 07:27:15 -0500 Subject: [PATCH 227/321] Add Status Report extension to community catalog (#2123) - Extension ID: status-report - Version: 1.2.5 - Author: Open-Agent-Tools - Description: Project status, feature progress, and next-action recommendations for spec-driven workflows Co-authored-by: Unserious AI <121459476+unseriousAI@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 1 + extensions/catalog.community.json | 32 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 064554c39c..b389dde795 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | +| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 87f2d56347..cd37ef3d02 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T14:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1516,6 +1516,36 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" }, + "status-report": { + "name": "Status Report", + "id": "status-report", + "description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.", + "author": "Open-Agent-Tools", + "version": "1.2.5", + "download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip", + "repository": "https://github.com/Open-Agent-Tools/spec-kit-status", + "homepage": "https://github.com/Open-Agent-Tools/spec-kit-status", + "documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "workflow", + "project-management", + "status" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T15:05:14Z", + "updated_at": "2026-04-08T15:05:14Z" + }, "superb": { "name": "Superpowers Bridge", "id": "superb", From 7f1e38491ff170d2764f2a5f3e1ad1e436c3c288 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:32:48 +0200 Subject: [PATCH 228/321] chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146) * chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 * Removed invalid aliases * Change updated at --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index cd37ef3d02..848ce79e78 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1331,8 +1331,8 @@ "id": "review", "description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.", "author": "ismaelJimenez", - "version": "1.0.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip", "repository": "https://github.com/ismaelJimenez/spec-kit-review", "homepage": "https://github.com/ismaelJimenez/spec-kit-review", "documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md", @@ -1358,7 +1358,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_at": "2026-03-06T00:00:00Z" + "updated_at": "2026-04-09T00:00:00Z" }, "security-review": { "name": "Security Review", @@ -1658,8 +1658,8 @@ "id": "verify", "description": "Post-implementation quality gate that validates implemented code against specification artifacts.", "author": "ismaelJimenez", - "version": "1.0.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip", + "version": "1.0.3", + "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip", "repository": "https://github.com/ismaelJimenez/spec-kit-verify", "homepage": "https://github.com/ismaelJimenez/spec-kit-verify", "documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md", @@ -1683,7 +1683,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-03T00:00:00Z", - "updated_at": "2026-03-03T00:00:00Z" + "updated_at": "2026-04-09T00:00:00Z" }, "verify-tasks": { "name": "Verify Tasks Extension", From b6e19b49ec0e6e718fd86ee7fadeaa29724ea8b5 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 19:10:42 +0500 Subject: [PATCH 229/321] Add TinySpec extension to community catalog (#2147) * Add TinySpec extension to community catalog - 3 commands: tinyspec, implement, classify for lightweight single-file workflow - 1 hook: before_specify for auto-classifying task complexity - Addresses community request in issue #1174 (22+ reactions) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b389dde795..0ca2da7663 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ The following community-contributed extensions are available in [`catalog.commun | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 848ce79e78..942a5605f7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T14:30:00Z", + "updated_at": "2026-04-10T12:34:56Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1621,6 +1621,38 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "tinyspec": { + "name": "TinySpec", + "id": "tinyspec", + "description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "lightweight", + "small-tasks", + "workflow", + "productivity", + "efficiency" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From 5732de60d0297f67d12d4786ed4e1205ee977ac1 Mon Sep 17 00:00:00 2001 From: Gabriel Henrique <51464658+gabrielhmsantos@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:35:19 -0300 Subject: [PATCH 230/321] feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156) Use SkillsIntegration so workflows ship as speckit-*/SKILL.md. Update init next-steps, extension hook invocation, docs, and tests. Made-with: Cursor --- docs/upgrade.md | 2 +- src/specify_cli/__init__.py | 10 +++++-- src/specify_cli/extensions.py | 3 ++ .../integrations/cursor_agent/__init__.py | 30 +++++++++++++++---- .../test_integration_cursor_agent.py | 25 +++++++++++++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index cd5cc124fe..aecbb7879b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -292,7 +292,7 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a ```bash ls -la .claude/commands/ # Claude Code ls -la .gemini/commands/ # Gemini - ls -la .cursor/commands/ # Cursor + ls -la .cursor/skills/ # Cursor ls -la .pi/prompts/ # Pi Coding Agent ``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 11b6e0eda5..7f343f7a14 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1338,7 +1338,7 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax. + # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) @@ -1347,7 +1347,8 @@ def init( kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode + cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1356,6 +1357,9 @@ def init( if claude_skill_mode and not ai_skills: steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") step_num += 1 + if cursor_agent_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -1365,6 +1369,8 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" + if cursor_agent_skill_mode: + return f"/speckit-{name}" return f"/speckit.{name}" steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index da1a5f4472..d03018b024 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2170,6 +2170,7 @@ def _render_hook_invocation(self, command: Any) -> str: codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" + cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: @@ -2178,6 +2179,8 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" + if cursor_skill_mode and skill_name: + return f"/{skill_name}" return f"/{command_id}" diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index c244a7c01a..a5472654fa 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -1,21 +1,39 @@ -"""Cursor IDE integration.""" +"""Cursor IDE integration. -from ..base import MarkdownIntegration +Cursor Agent uses the ``.cursor/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" +from __future__ import annotations -class CursorAgentIntegration(MarkdownIntegration): +from ..base import IntegrationOption, SkillsIntegration + + +class CursorAgentIntegration(SkillsIntegration): key = "cursor-agent" config = { "name": "Cursor", "folder": ".cursor/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".cursor/commands", + "dir": ".cursor/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + context_file = ".cursor/rules/specify-rules.mdc" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (recommended for Cursor)", + ), + ] diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 71b7db1c98..3384fdc14f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,11 +1,28 @@ """Tests for CursorAgentIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +from .test_integration_base_skills import SkillsIntegrationTests -class TestCursorAgentIntegration(MarkdownIntegrationTests): +class TestCursorAgentIntegration(SkillsIntegrationTests): KEY = "cursor-agent" FOLDER = ".cursor/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".cursor/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".cursor/skills" CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestCursorAgentAutoPromote: + """--ai cursor-agent auto-promotes to integration path.""" + + def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): + """--ai cursor-agent should work the same as --integration cursor-agent.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"]) + + assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" + assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() + From 8bb08ae1a019ebfadfe6f364edb3aba20dd8339d Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 19:51:20 +0500 Subject: [PATCH 231/321] Add PR Bridge extension to community catalog (#2148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3 commands: generate PR descriptions, reviewer checklists, and change summaries - 1 hook: after_implement for auto-generating PR description - Closes the SDD workflow loop: specify → plan → tasks → implement → PR --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 0ca2da7663..492b5cc11e 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ The following community-contributed extensions are available in [`catalog.commun | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | +| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 942a5605f7..1dbecc9d86 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1030,6 +1030,38 @@ "created_at": "2026-03-27T08:22:30Z", "updated_at": "2026-03-27T08:22:30Z" }, + "pr-bridge": { + "name": "PR Bridge", + "id": "pr-bridge", + "description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "pull-request", + "automation", + "traceability", + "workflow", + "review" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "presetify": { "name": "Presetify", "id": "presetify", From d1b95c2f5904f27c92d553918c083141135cf0bb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:29:18 -0500 Subject: [PATCH 232/321] fix: bundled extensions should not have download URLs (#2155) * fix: bundled extensions should not have download URLs (#2151) - Remove selftest from default catalog (not a published extension) - Replace download_url with 'bundled: true' flag for git extension - Add bundled check in extension add flow with clear error message when bundled extension is missing from installed package - Add bundled check in download_extension() with specific error - Direct users to reinstall via uv with full GitHub URL - Add 3 regression tests for bundled extension handling * refactor: address review - move bundled check up-front, extract reinstall constant - Move bundled check before download_url inspection in download_extension() so bundled extensions can never be downloaded even with a URL present - Extract REINSTALL_COMMAND constant to avoid duplicated install strings * fix: allow bundled extensions with download_url to be updated Bundled extensions should only be blocked from download when they have no download_url. If a newer version is published to the catalog with a URL, users should be able to install it to get bug fixes. Add test for bundled-with-URL download path. --- extensions/catalog.json | 18 +----- src/specify_cli/__init__.py | 15 ++++- src/specify_cli/extensions.py | 10 +++ tests/test_extensions.py | 116 ++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 17 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index a039883ba2..de9372e2bc 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "git": { @@ -10,27 +10,13 @@ "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip", + "bundled": true, "tags": [ "git", "branching", "workflow", "core" ] - }, - "selftest": { - "name": "Spec Kit Self-Test Utility", - "id": "selftest", - "version": "1.0.0", - "description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.", - "author": "spec-kit-core", - "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", - "tags": [ - "testing", - "core", - "utility" - ] } } } \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7f343f7a14..e37c4b45f6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3007,7 +3007,7 @@ def extension_add( priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND project_root = Path.cwd() @@ -3109,6 +3109,19 @@ def extension_add( manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + # Enforce install_allowed policy if not ext_info.get("_install_allowed", True): catalog_name = ext_info.get("_catalog_name", "community") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d03018b024..8f45c425ad 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -38,6 +38,8 @@ }) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") +REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" + def _load_core_command_names() -> frozenset[str]: """Discover bundled core command names from the packaged templates. @@ -1870,6 +1872,14 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + # Bundled extensions without a download URL must be installed locally + if ext_info.get("bundled") and not ext_info.get("download_url"): + raise ExtensionError( + f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Try reinstalling: {REINSTALL_COMMAND}" + ) + download_url = ext_info.get("download_url") if not download_url: raise ExtensionError(f"Extension '{extension_id}' has no download URL") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5aed03dcf..a6ddff8e1a 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2995,6 +2995,122 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) + def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): + """extension add should give a clear error when a bundled extension is not found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns a bundled extension without download_url + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "git", + "name": "Git Branching Workflow", + "version": "1.0.0", + "description": "Git branching extension", + "bundled": True, + "_install_allowed": True, + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "git"], + catch_exceptions=True, + ) + + assert result.exit_code != 0 + assert "bundled with spec-kit" in result.output + assert "reinstall" in result.output.lower() + + +class TestDownloadExtensionBundled: + """Tests for download_extension handling of bundled extensions.""" + + def test_download_extension_raises_for_bundled(self, temp_dir): + """download_extension should raise a clear error for bundled extensions without a URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_ext_info = { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Git workflow", + "bundled": True, + } + + with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): + with pytest.raises(ExtensionError, match="bundled with spec-kit"): + catalog.download_extension("git") + + def test_download_extension_allows_bundled_with_url(self, temp_dir): + """download_extension should allow bundled extensions that have a download_url (newer version).""" + from unittest.mock import patch, MagicMock + import urllib.request + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_with_url = { + "name": "Git Branching Workflow", + "id": "git", + "version": "2.0.0", + "description": "Git workflow", + "bundled": True, + "download_url": "https://example.com/git-2.0.0.zip", + } + + mock_response = MagicMock() + mock_response.read.return_value = b"fake zip data" + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ + patch.object(urllib.request, "urlopen", return_value=mock_response): + result = catalog.download_extension("git") + assert result.name == "git-2.0.0.zip" + + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): + """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + non_bundled_ext_info = { + "name": "Some Extension", + "id": "some-ext", + "version": "1.0.0", + "description": "Test", + } + + with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): + with pytest.raises(ExtensionError, match="has no download URL"): + catalog.download_extension("some-ext") + class TestExtensionUpdateCLI: """CLI integration tests for extension update command.""" From f43b85096c823d0ecb813ec8b6a48213e277d133 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 22:11:25 +0500 Subject: [PATCH 233/321] Add SpecTest extension to community catalog (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SpecTest extension to community catalog Adds spec-kit-spectest: auto-generate test scaffolds from spec criteria. 4 commands: - /speckit.test.generate — generate framework-native test scaffolds - /speckit.test.coverage — map spec requirements to test coverage - /speckit.test.gaps — find untested requirements with suggestions - /speckit.test.plan — generate structured test plan documents 1 hook: after_implement (gap detection) Bridges the spec-to-test gap in the SDD workflow. * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix spectest created_at/updated_at to use current timestamp per Copilot review Set both to 2026-04-10T16:00:00Z instead of midnight. --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 492b5cc11e..c274df3dc0 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1dbecc9d86..34f0443d70 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T12:34:56Z", + "updated_at": "2026-04-10T16:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1486,6 +1486,39 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "spectest": { + "name": "SpecTest", + "id": "spectest", + "description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "testing", + "test-generation", + "coverage", + "quality", + "automation", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T16:00:00Z", + "updated_at": "2026-04-10T16:00:00Z" + }, "staff-review": { "name": "Staff Review Extension", "id": "staff-review", From 97ea7cf6a0d4b5353e597e54e7eb1fe870325924 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 22:20:55 +0500 Subject: [PATCH 234/321] Add CI Guard extension to community catalog (#2157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CI Guard extension to community catalog Adds spec-kit-ci-guard: spec compliance gates for CI/CD pipelines. 5 commands: - /speckit.ci.check — run all compliance checks with pass/fail - /speckit.ci.report — generate requirement traceability matrix - /speckit.ci.gate — configure merge gate rules and thresholds - /speckit.ci.drift — detect bidirectional spec-to-code drift - /speckit.ci.badge — generate spec compliance badges 2 hooks: before_implement, after_implement Bridges the gap between SDD workflow and CI/CD enforcement. * Fix updated_at to monotonically increase per Copilot review Set to 2026-04-10T15:00:00Z (later than previous 2026-04-10T12:34:56Z). --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c274df3dc0..78f6d18121 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The following community-contributed extensions are available in [`catalog.commun | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | +| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 34f0443d70..8cea7f83ba 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T16:00:00Z", + "updated_at": "2026-04-10T17:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -205,6 +205,39 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "ci-guard": { + "name": "CI Guard", + "id": "ci-guard", + "description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 5, + "hooks": 2 + }, + "tags": [ + "ci-cd", + "compliance", + "governance", + "quality-gate", + "drift-detection", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T17:00:00Z", + "updated_at": "2026-04-10T17:00:00Z" + }, "checkpoint": { "name": "Checkpoint Extension", "id": "checkpoint", From 74e3f45aa91dd4e30e2765ef92811fbd3d213f9e Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 23:16:26 +0500 Subject: [PATCH 235/321] Add Brownfield Bootstrap extension to community catalog (#2145) - 4 commands: scan, bootstrap, validate, migrate for existing codebases - 1 hook: after_init for auto-scanning project after spec-kit initialization - Addresses community request in issue #1436 (30+ reactions) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 78f6d18121..0fb388a840 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | +| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8cea7f83ba..c4312d5412 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -138,6 +138,38 @@ "created_at": "2026-04-08T00:00:00Z", "updated_at": "2026-04-08T00:00:00Z" }, + "brownfield": { + "name": "Brownfield Bootstrap", + "id": "brownfield", + "description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "brownfield", + "bootstrap", + "existing-project", + "migration", + "onboarding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "bugfix": { "name": "Bugfix Workflow", "id": "bugfix", From 43cb0fa7ab76463c6685d48d75b7f173cfd592e9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:18:06 -0500 Subject: [PATCH 236/321] feat: add bundled lean preset with minimal workflow commands (#2161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add bundled lean preset with minimal workflow commands Add a lean preset that overrides the 5 core workflow commands (specify, plan, tasks, implement, constitution) with minimal prompts that produce exactly one artifact each — no extension hooks, no scripts, no git branching, no templates. Bundled preset infrastructure: - Add _locate_bundled_preset() mirroring _locate_bundled_extension() - Update 'specify init --preset' to try bundled -> catalog fallback - Update 'specify preset add' to try bundled -> catalog fallback - Add bundled guard in download_pack() for presets without download URLs - Add lean to presets/catalog.json with 'bundled: true' marker - Add lean to pyproject.toml force-include for wheel packaging - Align error messages with bundled extension error pattern Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator) * refactor: address review — clean up unused imports, strengthen test assertions - Remove unused MagicMock import and cache_dir setup in download test - Assert 'bundled' and 'reinstall' in CLI error output (not just exit code) - Mock catalog in missing-locally test for deterministic bundled error path - Fix test versions to satisfy updated speckit_version >=0.6.0 requirement * refactor: address review — fix constitution paths, add REINSTALL_COMMAND to presets.py - Fix constitution path to .specify/memory/constitution.md in plan, tasks, implement commands (matching core command convention) - Include REINSTALL_COMMAND in download_pack() bundled guard for consistent recovery instructions across bundled extensions and presets * refactor: address review — explicit feature_directory paths, ZIP cleanup in finally - Prefix spec.md/plan.md/tasks.md with / in plan, tasks, and implement commands so the agent doesn't operate on repo root by mistake - Move ZIP unlink into finally block in init --preset path so cleanup runs even when install_from_zip raises (matching preset_add pattern) * refactor: address review — replace Unicode em dashes with ASCII, fix grammar - Replace all Unicode em dashes with ASCII hyphens in preset.yml and catalog.json to avoid decode errors on non-UTF-8 environments - Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md' * refactor: address review - align task format between tasks and implement - Remove undefined [P] marker from implement (lean uses sequential execution) - Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]' - Simplify implement to execute tasks in order without parallel complexity * refactor: address review - parse frontmatter instead of raw substring search - Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts keys in YAML frontmatter instead of brittle 'scripts:' substring search --- presets/catalog.json | 20 +- presets/lean/commands/speckit.constitution.md | 15 ++ presets/lean/commands/speckit.implement.md | 22 +++ presets/lean/commands/speckit.plan.md | 19 ++ presets/lean/commands/speckit.specify.md | 23 +++ presets/lean/commands/speckit.tasks.md | 19 ++ presets/lean/preset.yml | 50 +++++ pyproject.toml | 2 + src/specify_cli/__init__.py | 130 +++++++++---- src/specify_cli/presets.py | 10 + tests/test_presets.py | 179 ++++++++++++++++++ 11 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 presets/lean/commands/speckit.constitution.md create mode 100644 presets/lean/commands/speckit.implement.md create mode 100644 presets/lean/commands/speckit.plan.md create mode 100644 presets/lean/commands/speckit.specify.md create mode 100644 presets/lean/commands/speckit.tasks.md create mode 100644 presets/lean/preset.yml diff --git a/presets/catalog.json b/presets/catalog.json index ca40f85280..5650092baf 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -1,6 +1,22 @@ { "schema_version": "1.0", - "updated_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", - "presets": {} + "presets": { + "lean": { + "name": "Lean Workflow", + "id": "lean", + "version": "1.0.0", + "description": "Minimal core workflow commands - just the prompt, just the artifact", + "author": "github", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "lean", + "minimal", + "workflow", + "core" + ] + } + } } diff --git a/presets/lean/commands/speckit.constitution.md b/presets/lean/commands/speckit.constitution.md new file mode 100644 index 0000000000..920337003e --- /dev/null +++ b/presets/lean/commands/speckit.constitution.md @@ -0,0 +1,15 @@ +--- +description: Create or update the project constitution. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Create or update the project constitution and store it in `.specify/memory/constitution.md`. + - Project name, guiding principles, non-negotiable rules + - Derive from user input and existing repo context (README, docs) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md new file mode 100644 index 0000000000..fc68a1f8b1 --- /dev/null +++ b/presets/lean/commands/speckit.implement.md @@ -0,0 +1,22 @@ +--- +description: Execute the implementation plan by processing all tasks in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md` and `/tasks.md`. + +3. **Execute tasks** in order: + - Complete each task before moving to the next + - Mark completed tasks by changing `- [ ]` to `- [x]` in `/tasks.md` + - Halt on failure and report the issue + +4. **Validate**: Verify all tasks are completed and the implementation matches the spec. diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md new file mode 100644 index 0000000000..9fbbe4c371 --- /dev/null +++ b/presets/lean/commands/speckit.plan.md @@ -0,0 +1,19 @@ +--- +description: Create a plan and store it in plan.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md`. + +3. Create an implementation plan and store it in `/plan.md`. + - Technical context: tech stack, dependencies, project structure + - Design decisions, architecture, file structure diff --git a/presets/lean/commands/speckit.specify.md b/presets/lean/commands/speckit.specify.md new file mode 100644 index 0000000000..c15353557a --- /dev/null +++ b/presets/lean/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: Create a specification and store it in spec.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided. + +2. Create the directory and write `.specify/feature.json`: + ```json + { "feature_directory": "" } + ``` + +3. Create a specification from the user input and store it in `/spec.md`. + - Overview, functional requirements, user scenarios, success criteria + - Every requirement must be testable + - Make informed defaults for unspecified details diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md new file mode 100644 index 0000000000..724a7b8400 --- /dev/null +++ b/presets/lean/commands/speckit.tasks.md @@ -0,0 +1,19 @@ +--- +description: Create the tasks needed for implementation and store them in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md`. + +3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. + - Every task uses checklist format: `- [ ] [TaskID] Description with file path` + - Organized by phase: setup, foundational, user stories in priority order, polish diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml new file mode 100644 index 0000000000..eae84928c8 --- /dev/null +++ b/presets/lean/preset.yml @@ -0,0 +1,50 @@ +schema_version: "1.0" + +preset: + id: "lean" + name: "Lean Workflow" + version: "1.0.0" + description: "Minimal core workflow commands - just the prompt, just the artifact" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.6.0" + +provides: + templates: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Lean specify - create spec.md from a feature description" + replaces: "speckit.specify" + + - type: "command" + name: "speckit.plan" + file: "commands/speckit.plan.md" + description: "Lean plan - create plan.md from the spec" + replaces: "speckit.plan" + + - type: "command" + name: "speckit.tasks" + file: "commands/speckit.tasks.md" + description: "Lean tasks - create tasks.md from plan and spec" + replaces: "speckit.tasks" + + - type: "command" + name: "speckit.implement" + file: "commands/speckit.implement.md" + description: "Lean implement - execute tasks from tasks.md" + replaces: "speckit.implement" + + - type: "command" + name: "speckit.constitution" + file: "commands/speckit.constitution.md" + description: "Lean constitution - create or update project constitution" + replaces: "speckit.constitution" + +tags: + - "lean" + - "minimal" + - "workflow" diff --git a/pyproject.toml b/pyproject.toml index e43f812724..5c4e464c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled presets (installable via `specify preset add ` or `specify init --preset `) +"presets/lean" = "specify_cli/core_pack/presets/lean" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e37c4b45f6..0bbf42ad5a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return None +def _locate_bundled_preset(preset_id: str) -> Path | None: + """Return the path to a bundled preset, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``presets//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', preset_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + return None + + def _install_shared_infra( project_path: Path, script_type: str, @@ -1266,27 +1291,44 @@ def init( preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() - # Try local directory first, then catalog + # Try local directory first, then bundled, then catalog local_path = Path(preset).resolve() if local_path.is_dir() and (local_path / "preset.yml").exists(): preset_manager.install_from_directory(local_path, speckit_ver) else: - preset_catalog = PresetCatalog(project_path) - pack_info = preset_catalog.get_pack_info(preset) - if not pack_info: - console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + bundled_path = _locate_bundled_preset(preset) + if bundled_path: + preset_manager.install_from_directory(bundled_path, speckit_ver) else: - try: - zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) - # Clean up downloaded ZIP to avoid cache accumulation + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + elif pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "This usually means the spec-kit installation is incomplete or corrupted." + ) + console.print(f"Try reinstalling: {REINSTALL_COMMAND}") + else: + zip_path = None try: - zip_path.unlink(missing_ok=True) - except OSError: - # Best-effort cleanup; failure to delete is non-fatal - pass - except PresetError as preset_err: - console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + finally: + if zip_path is not None: + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") @@ -2140,28 +2182,50 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") elif pack_id: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(pack_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...") + manifest = manager.install_from_directory(bundled_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(pack_id) - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") - raise typer.Exit(1) + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + raise typer.Exit(1) - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) - try: - zip_path = catalog.download_pack(pack_id) - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - finally: - if 'zip_path' in locals() and zip_path.exists(): - zip_path.unlink(missing_ok=True) + console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(pack_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) else: console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") raise typer.Exit(1) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 137d1d22a8..3a0f469a77 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1587,6 +1587,16 @@ def download_pack( f"Preset '{pack_id}' not found in catalog" ) + # Bundled presets without a download URL must be installed locally + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + raise PresetError( + f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Use 'specify preset add {pack_id}' to install from the bundled package, " + f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" + ) + if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") raise PresetError( diff --git a/tests/test_presets.py b/tests/test_presets.py index d22264f806..95af7a900f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2865,3 +2865,182 @@ def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() + + +# ===== Lean Preset Tests ===== + + +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" + +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] + + +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" + + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() + + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands + + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" + + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" + + def test_lean_commands_have_no_scripts(self): + """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar + + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" + assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" + + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") + + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + + resolver = PresetResolver(project_dir) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" + + +# ===== Bundled Preset Locator Tests ===== + + +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" + + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() + + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("nonexistent-preset") + assert path is None + + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="0.6.0"): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() + + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] + + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) + + catalog_data = { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.presets.PresetCatalog") as MockCatalog: + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output From 1cb794e516216f5046a84013e1c480b3aba5b61c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:22:42 -0500 Subject: [PATCH 237/321] chore: release 0.6.1, begin 0.6.2.dev0 development (#2162) * chore: bump version to 0.6.1 * chore: begin 0.6.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139221d075..6fd932a4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ +## [0.6.1] - 2026-04-10 + +### Changed + +- feat: add bundled lean preset with minimal workflow commands (#2161) +- Add Brownfield Bootstrap extension to community catalog (#2145) +- Add CI Guard extension to community catalog (#2157) +- Add SpecTest extension to community catalog (#2159) +- fix: bundled extensions should not have download URLs (#2155) +- Add PR Bridge extension to community catalog (#2148) +- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156) +- Add TinySpec extension to community catalog (#2147) +- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146) +- Add Status Report extension to community catalog (#2123) +- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144) + ## [0.6.0] - 2026-04-09 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 5c4e464c9b..7fec6cc6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.1.dev0" +version = "0.6.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From cdbea09e1a00b0899148e82a4c366bed7482065f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:57:47 -0500 Subject: [PATCH 238/321] fix: skip docs deployment workflow on forks (#2171) Add repository check to build and deploy jobs so they skip with success on forks, avoiding failed Pages deployments for contributors. --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 847f564557..5f1f97dc77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,7 @@ concurrency: jobs: # Build job build: + if: github.repository == 'github/spec-kit' runs-on: ubuntu-latest steps: - name: Checkout @@ -56,6 +57,7 @@ jobs: # Deploy job deploy: + if: github.repository == 'github/spec-kit' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From 52ed84d7234d5eb1312a01ca19abff407b8fa200 Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Mon, 13 Apr 2026 08:42:08 -0400 Subject: [PATCH 239/321] Update ralph extension to v1.0.1 in community catalog (#2192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extension ID: ralph - Version: 1.0.0 → 1.0.1 - Author: Rubiss - Changes: Fixed bash task count bug, removed hardcoded model, added regression tests, CI workflow, and release pipeline. - Release: https://github.com/Rubiss/spec-kit-ralph/releases/tag/v1.0.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c4312d5412..ec7ad87c55 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T17:00:00Z", + "updated_at": "2026-04-12T19:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1225,8 +1225,8 @@ "id": "ralph", "description": "Autonomous implementation loop using AI agent CLI.", "author": "Rubiss", - "version": "1.0.0", - "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip", "repository": "https://github.com/Rubiss/spec-kit-ralph", "homepage": "https://github.com/Rubiss/spec-kit-ralph", "documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md", @@ -1259,7 +1259,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-09T00:00:00Z", - "updated_at": "2026-03-09T00:00:00Z" + "updated_at": "2026-04-12T19:00:00Z" }, "reconcile": { "name": "Reconcile Extension", From b67b2856b15aabdd0e2764079d4b1e45bc256923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20K=C3=B6yk=C4=B1ran?= Date: Mon, 13 Apr 2026 15:55:44 +0300 Subject: [PATCH 240/321] feat(agents): add Goose AI agent support (#2015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integrations): add YamlIntegration base class for YAML recipe agents Signed-off-by: Furkan Köykıran * feat(integrations): add Goose integration subpackage with YAML recipe support Signed-off-by: Furkan Köykıran * feat(integrations): register GooseIntegration in the integration registry Signed-off-by: Furkan Köykıran * feat(agents): add YAML format support to CommandRegistrar for extension/preset commands Signed-off-by: Furkan Köykıran * feat(scripts): add goose agent type to bash update-agent-context script Signed-off-by: Furkan Köykıran * feat(scripts): add goose agent type to PowerShell update-agent-context script Signed-off-by: Furkan Köykıran * docs(agents): add Goose to supported agents table and integration notes Signed-off-by: Furkan Köykıran * docs(readme): add Goose to supported agents table Signed-off-by: Furkan Köykıran * test(integrations): add YamlIntegrationTests base mixin for YAML agent testing Signed-off-by: Furkan Köykıran * test(integrations): add Goose integration tests Signed-off-by: Furkan Köykıran * test(consistency): add Goose consistency checks for config, registrar, and scripts Signed-off-by: Furkan Köykıran * docs(agents): move Goose to YAML Format section in Command File Formats Goose uses YAML recipes, not Markdown. Remove it from the Markdown Format list and add a dedicated YAML Format subsection with a representative recipe example showing prompt: | and {{args}} placeholders. * refactor(agents): delegate render_yaml_command to YamlIntegration Remove the duplicate header dict, yaml.safe_dump call, body indentation, and _human_title logic from CommandRegistrar.render_yaml_command(). Delegate to YamlIntegration._render_yaml() and _human_title() so YAML recipe output stays consistent across the init-time generation and command-registration code paths. * fix(agents): guard alias output path against directory traversal Validate that alias_file resolves within commands_dir before writing. Uses the same resolve().relative_to() pattern already established in extensions.py for ZIP path containment checks. * docs(agents): add Goose to Multi-Agent Support comment list in update-agent-context.sh * fix(agents): add goose to print_summary Usage line in bash context script The print_summary() function listed all supported agents in its Usage output but omitted goose, making it inconsistent with the header docs and the error message in update_specific_agent(). Signed-off-by: Furkan Köykıran * fix(agents): add goose to Print-Summary Usage line in PowerShell context script The Print-Summary function listed all supported agents in its Usage output but omitted goose, making it inconsistent with the ValidateSet and the header documentation. Signed-off-by: Furkan Köykıran * fix(agents): normalize description and title types in YamlIntegration.setup() YAML frontmatter can contain non-string types (null, list, int). Add isinstance checks matching TomlIntegration._extract_description() to ensure Goose recipes always receive valid string fields. Signed-off-by: Furkan Köykıran * fix(agents): validate shared script exists before exec in Goose bash wrapper Add Forge-style check that the shared update-agent-context.sh is present and executable, producing a clear error instead of a cryptic shell exec failure when the shared script is missing. Signed-off-by: Furkan Köykıran * fix(agents): validate shared script exists before invoke in Goose PowerShell wrapper Add Forge-style Test-Path check that the shared update-agent-context.ps1 exists, producing a clear error instead of a cryptic PowerShell failure when the shared script is missing. Signed-off-by: Furkan Köykıran * fix(agents): normalize title and description types in render_yaml_command() Extension/preset frontmatter can contain non-string types. Add isinstance checks matching the normalization in YamlIntegration.setup() so both code paths produce valid Goose recipe fields. Signed-off-by: Furkan Köykıran * fix(agents): replace $ARGUMENTS with arg_placeholder in process_template() Signed-off-by: Furkan Köykıran * test(agents): assert $ARGUMENTS absent from generated YAML recipes Signed-off-by: Furkan Köykıran * test(agents): assert $ARGUMENTS absent from generated TOML commands Signed-off-by: Furkan Köykıran * fix(tests): rewrite docstring to avoid embedded triple-quote in TOML test Signed-off-by: Furkan Köykıran --------- Signed-off-by: Furkan Köykıran --- AGENTS.md | 69 ++- README.md | 3 +- scripts/bash/update-agent-context.sh | 15 +- scripts/powershell/update-agent-context.ps1 | 12 +- src/specify_cli/agents.py | 185 +++++-- src/specify_cli/integrations/__init__.py | 3 + src/specify_cli/integrations/base.py | 242 ++++++++- .../integrations/goose/__init__.py | 21 + .../goose/scripts/update-context.ps1 | 33 ++ .../goose/scripts/update-context.sh | 38 ++ .../test_integration_base_toml.py | 182 +++++-- .../test_integration_base_yaml.py | 459 ++++++++++++++++++ tests/integrations/test_integration_goose.py | 11 + tests/test_agent_config_consistency.py | 97 +++- 14 files changed, 1228 insertions(+), 142 deletions(-) create mode 100644 src/specify_cli/integrations/goose/__init__.py create mode 100644 src/specify_cli/integrations/goose/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/goose/scripts/update-context.sh create mode 100644 tests/integrations/test_integration_base_yaml.py create mode 100644 tests/integrations/test_integration_goose.py diff --git a/AGENTS.md b/AGENTS.md index 27472ebec9..2b076dc384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Each AI agent is a self-contained **integration subpackage** under `src/specify_ ``` src/specify_cli/integrations/ ├── __init__.py # INTEGRATION_REGISTRY + _register_builtins() -├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration +├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration ├── manifest.py # IntegrationManifest (file tracking) ├── claude/ # Example: SkillsIntegration subclass │ ├── __init__.py # ClaudeIntegration class @@ -48,6 +48,7 @@ The registry is the **single source of truth for Python integration metadata**. |---|---| | Standard markdown commands (`.md`) | `MarkdownIntegration` | | TOML-format commands (`.toml`) | `TomlIntegration` | +| YAML recipe files (`.yaml`) | `YamlIntegration` | | Skill directories (`speckit-/SKILL.md`) | `SkillsIntegration` | | Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly | @@ -343,16 +344,82 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` +### YAML Format + +Used by: Goose + +```yaml +version: 1.0.0 +title: "Command Title" +description: "Command description" +author: + contact: spec-kit +extensions: + - type: builtin + name: developer +activities: + - Spec-Driven Development +prompt: | + Command content with {SCRIPT} and {{args}} placeholders. +``` + ## Argument Patterns Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt: - **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents) - **TOML-based**: `{{args}}` (e.g., Gemini) +- **YAML-based**: `{{args}}` (e.g., Goose) - **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) +## Special Processing Requirements + +Some agents require custom processing beyond the standard template transformations: + +### Copilot Integration + +GitHub Copilot has unique requirements: +- Commands use `.agent.md` extension (not `.md`) +- Each command gets a companion `.prompt.md` file in `.github/prompts/` +- Installs `.vscode/settings.json` with prompt file recommendations +- Context file lives at `.github/copilot-instructions.md` + +Implementation: Extends `IntegrationBase` with custom `setup()` method that: +1. Processes templates with `process_template()` +2. Generates companion `.prompt.md` files +3. Merges VS Code settings + +### Forge Integration + +Forge has special frontmatter and argument requirements: +- Uses `{{parameters}}` instead of `$ARGUMENTS` +- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) +- Injects `name` field into frontmatter when missing + +Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: +1. Inherits standard template processing from `MarkdownIntegration` +2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing +3. Applies Forge-specific transformations via `_apply_forge_transformations()` +4. Strips `handoffs` frontmatter key +5. Injects missing `name` fields +6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text + +### Goose Integration + +Goose is a YAML-format agent using Block's recipe system: +- Uses `.goose/recipes/` directory for YAML recipe files +- Uses `{{args}}` argument placeholder +- Produces YAML with `prompt: |` block scalar for command content + +Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): +1. Processes templates through the standard placeholder pipeline +2. Extracts title and description from frontmatter +3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) +4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping +5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge) + ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. diff --git a/README.md b/README.md index 0fb388a840..e366ad5b13 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Community projects that extend, visualize, or build on Spec Kit: | [Cursor](https://cursor.sh/) | ✅ | | | [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | +| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support | | [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | | [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | | [Jules](https://jules.google.com/) | ✅ | | @@ -654,7 +655,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index fce379b34d..2f71bb893c 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic # Leave empty to update all existing agent files set -e @@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid +# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid # updating the same file multiple times. AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" @@ -710,12 +710,15 @@ update_specific_agent() { forge) update_agent_file "$AGENTS_FILE" "Forge" || return 1 ;; + goose) + update_agent_file "$AGENTS_FILE" "Goose" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic" exit 1 ;; esac @@ -759,7 +762,7 @@ update_all_existing_agents() { _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false @@ -800,7 +803,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 12caa306da..3ee45d383c 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')] [string]$AgentType ) @@ -68,6 +68,7 @@ $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' $IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' $FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' +$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -417,8 +418,9 @@ function Update-SpecificAgent { 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } + 'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false } } } @@ -460,7 +462,7 @@ function Update-AllExistingAgents { if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false } + if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false } if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } @@ -490,7 +492,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]' } function Main { diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ec7af88768..e978d0136e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -18,6 +18,7 @@ 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": @@ -75,7 +76,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]: return {}, content frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3:].strip() + body = content[end_marker + 3 :].strip() try: frontmatter = yaml.safe_load(frontmatter_str) or {} @@ -100,7 +101,9 @@ def render_frontmatter(fm: dict) -> str: if not fm: return "" - yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True) + 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: @@ -146,16 +149,16 @@ def rewrite_project_relative_paths(text: str) -> str: # ".specify/extensions//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) + text = re.sub( + r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text + ) - return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") + 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 + self, frontmatter: dict, body: str, source_id: str, context_note: str = None ) -> str: """Render command in Markdown format. @@ -172,12 +175,7 @@ def render_markdown_command( context_note = f"\n\n" return self.render_frontmatter(frontmatter) + "\n" + context_note + body - def render_toml_command( - self, - frontmatter: dict, - body: str, - source_id: str - ) -> str: + def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str: """Render command in TOML format. Args: @@ -192,7 +190,7 @@ def render_toml_command( if "description" in frontmatter: toml_lines.append( - f'description = {self._render_basic_toml_string(frontmatter["description"])}' + f"description = {self._render_basic_toml_string(frontmatter['description'])}" ) toml_lines.append("") @@ -226,6 +224,41 @@ def _render_basic_toml_string(value: str) -> str: ) 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, @@ -252,9 +285,13 @@ def render_skill_command( frontmatter = {} if agent_name in {"codex", "kimi"}: - body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) - description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") + description = frontmatter.get( + "description", f"Spec-kit workflow command: {skill_name}" + ) skill_frontmatter = self.build_skill_frontmatter( agent_name, skill_name, @@ -288,7 +325,9 @@ def build_skill_frontmatter( return skill_frontmatter @staticmethod - def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: + 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 @@ -312,7 +351,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr 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" + 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 or default_variant in agent_scripts: @@ -334,7 +375,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = agent_scripts.get(script_variant) if script_variant else None + agent_script_command = ( + agent_scripts.get(script_variant) if script_variant else None + ) if agent_script_command: agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{AGENT_SCRIPT}", agent_script_command) @@ -342,7 +385,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) return CommandRegistrar.rewrite_project_relative_paths(body) - def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: + def _convert_argument_placeholder( + self, content: str, from_placeholder: str, to_placeholder: str + ) -> str: """Convert argument placeholder format. Args: @@ -356,14 +401,16 @@ def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_ return content.replace(from_placeholder, to_placeholder) @staticmethod - def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str: + 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[len("speckit.") :] short_name = short_name.replace(".", "-") return f"speckit-{short_name}" @@ -375,7 +422,7 @@ def register_commands( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> List[str]: """Register commands for a specific agent. @@ -432,12 +479,24 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": output = self.render_skill_command( - agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, + output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command(frontmatter, body, source_id, context_note) + output = self.render_markdown_command( + frontmatter, body, source_id, context_note + ) elif agent_config["format"] == "toml": 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']}") @@ -451,34 +510,68 @@ def register_commands( registered.append(cmd_name) for alias in cmd_info.get("aliases", []): - alias_output_name = self._compute_output_name(agent_name, alias, agent_config) + 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 + 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 + 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) + 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) + 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']}") + 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 + agent_name, + alias_output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) - alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" + alias_file = ( + commands_dir / f"{alias_output_name}{agent_config['extension']}" + ) + try: + alias_file.resolve().relative_to(commands_dir.resolve()) + except ValueError: + raise ValueError( + f"Alias output path escapes commands directory: {alias_file!r}" + ) alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.write_text(alias_output, encoding="utf-8") if agent_name == "copilot": @@ -506,7 +599,7 @@ def register_commands_for_all_agents( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -529,8 +622,12 @@ def register_commands_for_all_agents( if agent_dir.exists(): try: registered = self.register_commands( - agent_name, commands, source_id, source_dir, project_root, - context_note=context_note + agent_name, + commands, + source_id, + source_dir, + project_root, + context_note=context_note, ) if registered: results[agent_name] = registered @@ -540,9 +637,7 @@ def register_commands_for_all_agents( return results def unregister_commands( - self, - registered_commands: Dict[str, List[str]], - project_root: Path + self, registered_commands: Dict[str, List[str]], project_root: Path ) -> None: """Remove previously registered command files from agent directories. @@ -559,13 +654,17 @@ def unregister_commands( 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) + 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() if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + prompt_file = ( + project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + ) if prompt_file.exists(): prompt_file.unlink() diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 3eb58622e7..a5fb3833dc 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None: # -- Register built-in integrations -------------------------------------- + def _register_builtins() -> None: """Register all built-in integrations. @@ -58,6 +59,7 @@ def _register_builtins() -> None: from .forge import ForgeIntegration from .gemini import GeminiIntegration from .generic import GenericIntegration + from .goose import GooseIntegration from .iflow import IflowIntegration from .junie import JunieIntegration from .kilocode import KilocodeIntegration @@ -87,6 +89,7 @@ def _register_builtins() -> None: _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) + _register(GooseIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 1b09347dcd..87eca9d3bf 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -28,6 +28,7 @@ # IntegrationOption # --------------------------------------------------------------------------- + @dataclass(frozen=True) class IntegrationOption: """Declares an option that an integration accepts via ``--integration-options``. @@ -51,6 +52,7 @@ class IntegrationOption: # IntegrationBase — abstract base class # --------------------------------------------------------------------------- + class IntegrationBase(ABC): """Abstract base class every integration must implement. @@ -275,7 +277,7 @@ def process_template( 2. Replace ``{SCRIPT}`` with the extracted script command 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` with *arg_placeholder* + 5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* 6. Replace ``__AGENT__`` with *agent_name* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ @@ -348,8 +350,9 @@ def process_template( output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} + # 5. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) + content = content.replace("$ARGUMENTS", arg_placeholder) # 6. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) @@ -358,6 +361,7 @@ def process_template( # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar + content = CommandRegistrar.rewrite_project_relative_paths(content) return content @@ -433,9 +437,7 @@ def install( **opts: Any, ) -> list[Path]: """High-level install — calls ``setup()`` and returns created files.""" - return self.setup( - project_root, manifest, parsed_options=parsed_options, **opts - ) + return self.setup(project_root, manifest, parsed_options=parsed_options, **opts) def uninstall( self, @@ -452,6 +454,7 @@ def uninstall( # MarkdownIntegration — covers ~20 standard agents # --------------------------------------------------------------------------- + class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. @@ -492,12 +495,18 @@ def setup( dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest @@ -512,6 +521,7 @@ def setup( # TomlIntegration — TOML-format agents (Gemini, Tabnine) # --------------------------------------------------------------------------- + class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. @@ -603,13 +613,17 @@ def _render_toml_string(value: str) -> str: if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" - return '"' + ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) + '"' + return ( + '"' + + ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + '"' + ) @staticmethod def _render_toml(description: str, body: str) -> str: @@ -628,7 +642,9 @@ def _render_toml(description: str, body: str) -> str: toml_lines: list[str] = [] if description: - toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}") + toml_lines.append( + f"description = {TomlIntegration._render_toml_string(description)}" + ) toml_lines.append("") body = body.rstrip("\n") @@ -665,13 +681,19 @@ def setup( dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}" + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) dst_name = self.command_filename(src_file.stem) @@ -684,6 +706,188 @@ def setup( return created +# --------------------------------------------------------------------------- +# YamlIntegration — YAML-format agents (Goose) +# --------------------------------------------------------------------------- + + +class YamlIntegration(IntegrationBase): + """Concrete base for integrations that use YAML recipe format. + + Mirrors ``TomlIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config`` (and optionally + ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + YAML recipe format (version, title, description, prompt block scalar). + """ + + def command_filename(self, template_name: str) -> str: + """YAML commands use ``.yaml`` extension.""" + return f"speckit.{template_name}.yaml" + + @staticmethod + def _extract_frontmatter(content: str) -> dict[str, Any]: + """Extract frontmatter as a dict from YAML frontmatter block.""" + import yaml + + if not content.startswith("---"): + return {} + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return {} + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return {} + + frontmatter_text = "".join(lines[1:frontmatter_end]) + try: + fm = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return {} + + return fm if isinstance(fm, dict) else {} + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining body content.""" + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _human_title(identifier: str) -> str: + """Convert an identifier to a human-readable title. + + Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``, + and ``_`` with spaces before title-casing. + """ + text = identifier + if text.startswith("speckit."): + text = text[len("speckit.") :] + return text.replace(".", " ").replace("-", " ").replace("_", " ").title() + + @staticmethod + def _render_yaml(title: str, description: str, body: str, source_id: str) -> str: + """Render a YAML recipe file from title, description, and body. + + Produces a Goose-compatible recipe with a literal block scalar + for the prompt content. Uses ``yaml.safe_dump()`` for the + header fields to ensure proper escaping. + """ + import yaml + + header = { + "version": "1.0.0", + "title": title, + "description": description, + "author": {"contact": "spec-kit"}, + "extensions": [{"type": "builtin", "name": "developer"}], + "activities": ["Spec-Driven Development"], + } + + header_yaml = yaml.safe_dump( + header, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ).strip() + + # Indent each line for YAML block scalar + indented = "\n".join(f" {line}" for line in body.split("\n")) + + lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"] + return "\n".join(lines) + "\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + fm = self._extract_frontmatter(raw) + description = fm.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + title = fm.get("title", "") or fm.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title: + title = self._human_title(src_file.stem) + + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) + _, body = self._split_frontmatter(processed) + yaml_content = self._render_yaml( + title, description, body, f"templates/commands/{src_file.name}" + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + yaml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created + + # --------------------------------------------------------------------------- # SkillsIntegration — skills-format agents (Codex, Kimi, Agy) # --------------------------------------------------------------------------- @@ -713,9 +917,7 @@ def skills_dest(self, project_root: Path) -> Path: Raises ``ValueError`` when ``config`` or ``folder`` is missing. """ if not self.config: - raise ValueError( - f"{type(self).__name__}.config is not set." - ) + raise ValueError(f"{type(self).__name__}.config is not set.") folder = self.config.get("folder") if not folder: raise ValueError( diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py new file mode 100644 index 0000000000..0fc4d9d57a --- /dev/null +++ b/src/specify_cli/integrations/goose/__init__.py @@ -0,0 +1,21 @@ +"""Goose integration — Block's open source AI agent.""" + +from ..base import YamlIntegration + + +class GooseIntegration(YamlIntegration): + key = "goose" + config = { + "name": "Goose", + "folder": ".goose/", + "commands_subdir": "recipes", + "install_url": "https://block.github.io/goose/docs/getting-started/installation", + "requires_cli": True, + } + registrar_config = { + "dir": ".goose/recipes", + "format": "yaml", + "args": "{{args}}", + "extension": ".yaml", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/goose/scripts/update-context.ps1 b/src/specify_cli/integrations/goose/scripts/update-context.ps1 new file mode 100644 index 0000000000..eeb31f6296 --- /dev/null +++ b/src/specify_cli/integrations/goose/scripts/update-context.ps1 @@ -0,0 +1,33 @@ +# update-context.ps1 — Goose integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if (-not (Test-Path $sharedScript)) { + Write-Error "Error: shared agent context updater not found: $sharedScript" + Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1." + exit 1 +} + +& $sharedScript -AgentType goose +exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/goose/scripts/update-context.sh b/src/specify_cli/integrations/goose/scripts/update-context.sh new file mode 100755 index 0000000000..759ae3045a --- /dev/null +++ b/src/specify_cli/integrations/goose/scripts/update-context.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# update-context.sh — Goose integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if [ ! -x "$shared_script" ]; then + echo "Error: shared agent context updater not found or not executable:" >&2 + echo " $shared_script" >&2 + echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2 + exit 1 +fi + +exec "$shared_script" goose diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index fcded1834e..4d0bfe2cfe 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -84,7 +84,9 @@ def test_setup_writes_to_correct_directory(self, tmp_path): m = IntegrationManifest(self.KEY, tmp_path) created = i.setup(tmp_path, m) expected_dir = i.commands_dest(tmp_path) - assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0, "No command files were created" for f in cmd_files: @@ -134,6 +136,12 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): # At least one file should contain {{args}} from the {ARGS} placeholder has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) assert has_args, "No TOML command file contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "TOML command still contains $ARGUMENTS instead of {{args}}" + ) @pytest.mark.parametrize( ("frontmatter", "expected"), @@ -156,19 +164,13 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): ), ], ) - def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected): + def test_toml_extract_description_supports_block_scalars( + self, frontmatter, expected + ): assert TomlIntegration._extract_description(frontmatter) == expected def test_split_frontmatter_ignores_indented_delimiters(self): - content = ( - "---\n" - "description: |\n" - " line one\n" - " ---\n" - " line two\n" - "---\n" - "Body\n" - ) + content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n" frontmatter, body = TomlIntegration._split_frontmatter(content) @@ -205,7 +207,7 @@ def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "---" not in parsed["prompt"] def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): - """Multiline body ending with `"` must not produce `""""` (#2113).""" + """Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113).""" i = get_integration(self.KEY) template = tmp_path / "sample.md" template.write_text( @@ -230,7 +232,9 @@ def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): assert '"""\n' in raw, "body must use multiline basic string" parsed = tomllib.loads(raw) assert parsed["prompt"].endswith('specified?"') - assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch): """Body containing `\"\"\"` and ending with `'` falls back to escaped basic string.""" @@ -254,11 +258,15 @@ def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch) assert len(cmd_files) == 1 raw = cmd_files[0].read_text(encoding="utf-8") - assert "''''" not in raw, "literal string must not produce ambiguous closing quotes" + assert "''''" not in raw, ( + "literal string must not produce ambiguous closing quotes" + ) parsed = tomllib.loads(raw) assert parsed["prompt"].endswith("'single'") assert '"""triple"""' in parsed["prompt"] - assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): """Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline).""" @@ -284,8 +292,9 @@ def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): raw = cmd_files[0].read_text(encoding="utf-8") parsed = tomllib.loads(raw) assert parsed["prompt"] == "Line one\nPlain body content" - assert raw.rstrip().endswith('content"""'), \ + assert raw.rstrip().endswith('content"""'), ( "closing delimiter should be inline when body does not end with a quote" + ) def test_toml_is_valid(self, tmp_path): """Every generated TOML file must parse without errors.""" @@ -354,7 +363,14 @@ def test_sh_script_is_executable(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + sh = ( + tmp_path + / ".specify" + / "integrations" + / self.KEY + / "scripts" + / "update-context.sh" + ) assert os.access(sh, os.X_OK) # -- CLI auto-promote ------------------------------------------------- @@ -369,10 +385,20 @@ def test_ai_flag_auto_promotes(self, tmp_path): try: os.chdir(project) runner = CliRunner() - result = runner.invoke(app, [ - "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", - "--ignore-agent-tools", - ], catch_exceptions=False) + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" @@ -390,13 +416,25 @@ def test_integration_flag_creates_files(self, tmp_path): try: os.chdir(project) runner = CliRunner() - result = runner.invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", - "--ignore-agent-tools", - ], catch_exceptions=False) + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) - assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + assert result.exit_code == 0, ( + f"init --integration {self.KEY} failed: {result.output}" + ) i = get_integration(self.KEY) cmd_dir = i.commands_dest(project) assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" @@ -406,8 +444,15 @@ def test_integration_flag_creates_files(self, tmp_path): # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "analyze", "checklist", "clarify", "constitution", - "implement", "plan", "specify", "tasks", "taskstoissues", + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", ] def _expected_files(self, script_variant: str) -> list[str]: @@ -425,23 +470,38 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") # Framework files - files.append(f".specify/integration.json") - files.append(f".specify/init-options.json") + files.append(".specify/integration.json") + files.append(".specify/init-options.json") files.append(f".specify/integrations/{self.KEY}.manifest.json") - files.append(f".specify/integrations/speckit.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") if script_variant == "sh": - for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "update-agent-context.sh"]: + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + "update-agent-context.sh", + ]: files.append(f".specify/scripts/bash/{name}") else: - for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "update-agent-context.ps1", + ]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", - "constitution-template.md", "plan-template.md", - "spec-template.md", "tasks-template.md"]: + for name in [ + "agent-file-template.md", + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: files.append(f".specify/templates/{name}") files.append(".specify/memory/constitution.md") @@ -457,15 +517,26 @@ def test_complete_file_inventory_sh(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", - ], catch_exceptions=False) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" - actual = sorted(p.relative_to(project).as_posix() - for p in project.rglob("*") if p.is_file()) + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) expected = self._expected_files("sh") assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -482,15 +553,26 @@ def test_complete_file_inventory_ps(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "ps", - "--no-git", "--ignore-agent-tools", - ], catch_exceptions=False) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" - actual = sorted(p.relative_to(project).as_posix() - for p in project.rglob("*") if p.is_file()) + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) expected = self._expected_files("ps") assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py new file mode 100644 index 0000000000..b0f59a627d --- /dev/null +++ b/tests/integrations/test_integration_base_yaml.py @@ -0,0 +1,459 @@ +"""Reusable test mixin for standard YamlIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``YamlIntegrationTests``. + +Mirrors ``TomlIntegrationTests`` closely — same test structure, +adapted for YAML recipe output format. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import YamlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class YamlIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".goose/" + COMMANDS_SUBDIR: str — e.g. "recipes" + REGISTRAR_DIR: str — e.g. ".goose/recipes" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_yaml_integration(self): + assert isinstance(get_integration(self.KEY), YamlIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "yaml" + assert i.registrar_config["args"] == "{{args}}" + assert i.registrar_config["extension"] == ".yaml" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".yaml") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_yaml_has_title(self, tmp_path): + """Every YAML recipe should have a title field.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "title:" in content, f"{f.name} missing title field" + + def test_yaml_has_prompt(self, tmp_path): + """Every YAML recipe should have a prompt block scalar.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "prompt: |" in content, f"{f.name} missing prompt block scalar" + + def test_yaml_uses_correct_arg_placeholder(self, tmp_path): + """YAML recipes must use {{args}} placeholder.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) + assert has_args, "No YAML recipe contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "YAML recipe still contains $ARGUMENTS instead of {{args}}" + ) + + def test_yaml_is_valid(self, tmp_path): + """Every generated YAML file must parse without errors.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + # Strip trailing source comment before parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + try: + parsed = yaml.safe_load("\n".join(yaml_lines)) + except Exception as exc: + raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc + assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key" + assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key" + + def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Summary line one\n" + "scripts:\n" + " sh: scripts/bash/example.sh\n" + "---\n" + "Body line one\n" + "Body line two\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + content = cmd_files[0].read_text(encoding="utf-8") + # Strip source comment for parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + parsed = yaml.safe_load("\n".join(yaml_lines)) + + assert "description:" not in parsed["prompt"] + assert "scripts:" not in parsed["prompt"] + assert "---" not in parsed["prompt"] + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = ( + tmp_path + / ".specify" + / "integrations" + / self.KEY + / "scripts" + / "update-context.sh" + ) + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, ( + f"init --integration {self.KEY} failed: {result.output}" + ) + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*.yaml")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files (.yaml) + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.yaml") + + # Integration scripts + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") + + # Framework files + files.append(".specify/integration.json") + files.append(".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + "update-agent-context.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "update-agent-context.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "agent-file-template.md", + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py new file mode 100644 index 0000000000..6483666f36 --- /dev/null +++ b/tests/integrations/test_integration_goose.py @@ -0,0 +1,11 @@ +"""Tests for GooseIntegration.""" + +from .test_integration_base_yaml import YamlIntegrationTests + + +class TestGooseIntegration(YamlIntegrationTests): + KEY = "goose" + FOLDER = ".goose/" + COMMANDS_SUBDIR = "recipes" + REGISTRAR_DIR = ".goose/recipes" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 35d8c02f7e..9cfe1ddbc9 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -50,16 +50,25 @@ def test_init_ai_help_includes_roo_and_kiro_alias(self): def test_devcontainer_kiro_installer_uses_pinned_checksum(self): """Devcontainer installer should always verify Kiro installer via pinned SHA256.""" - post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8") - - assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text + post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text( + encoding="utf-8" + ) + + assert ( + 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' + in post_create_text + ) assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text def test_agent_context_scripts_use_kiro_cli(self): """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "kiro-cli" in bash_text assert "kiro-cli" in pwsh_text @@ -89,8 +98,12 @@ def test_extension_registrar_includes_tabnine(self): def test_agent_context_scripts_include_tabnine(self): """Agent context scripts should support tabnine agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "tabnine" in bash_text assert "TABNINE_FILE" in bash_text @@ -121,7 +134,9 @@ def test_kimi_in_extension_registrar(self): def test_kimi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -155,8 +170,12 @@ def test_trae_in_extension_registrar(self): def test_trae_in_agent_context_scripts(self): """Agent context scripts should support trae agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "trae" in bash_text assert "TRAE_FILE" in bash_text @@ -165,7 +184,9 @@ def test_trae_in_agent_context_scripts(self): def test_trae_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -200,7 +221,9 @@ def test_pi_in_extension_registrar(self): def test_pi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -210,8 +233,12 @@ def test_pi_in_powershell_validate_set(self): def test_agent_context_scripts_include_pi(self): """Agent context scripts should support pi agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "pi" in bash_text assert "Pi Coding Agent" in bash_text @@ -242,8 +269,12 @@ def test_iflow_in_extension_registrar(self): def test_iflow_in_agent_context_scripts(self): """Agent context scripts should support iflow agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "iflow" in bash_text assert "IFLOW_FILE" in bash_text @@ -253,3 +284,37 @@ def test_iflow_in_agent_context_scripts(self): def test_ai_help_includes_iflow(self): """CLI help text for --ai should include iflow.""" assert "iflow" in AI_ASSISTANT_HELP + + # --- Goose consistency checks --- + + def test_goose_in_agent_config(self): + """AGENT_CONFIG should include goose with correct folder and commands_subdir.""" + assert "goose" in AGENT_CONFIG + assert AGENT_CONFIG["goose"]["folder"] == ".goose/" + assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes" + assert AGENT_CONFIG["goose"]["requires_cli"] is True + + def test_goose_in_extension_registrar(self): + """Extension command registrar should include goose targeting .goose/recipes.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "goose" in cfg + assert cfg["goose"]["dir"] == ".goose/recipes" + assert cfg["goose"]["format"] == "yaml" + assert cfg["goose"]["args"] == "{{args}}" + + def test_goose_in_agent_context_scripts(self): + """Agent context scripts should support goose agent type.""" + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") + + assert "goose" in bash_text + assert "goose" in pwsh_text + + def test_ai_help_includes_goose(self): + """CLI help text for --ai should include goose.""" + assert "goose" in AI_ASSISTANT_HELP From e27896e68174ec8f106964043e3f9b820450ecd2 Mon Sep 17 00:00:00 2001 From: Fatima367 <170196704+Fatima367@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:22:46 +0500 Subject: [PATCH 241/321] feat: add GitHub Issues Integration to community catalog (#2188) * feat: add GitHub Issues Integration to community catalog Add GitHub Issues Integration extension to the community catalog and README. Extension provides: - /speckit.github-issues.import - Import GitHub Issue and generate spec.md - /speckit.github-issues.sync - Keep specs updated with issue changes - /speckit.github-issues.link - Add bidirectional traceability Resolves #2175 * Modify created_at and updated_at timestamps Updated timestamps for created_at and updated_at fields. * Update updated_at date in catalog.community.json --- README.md | 1 + extensions/catalog.community.json | 44 +++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e366ad5b13..1d9baf09e1 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | +| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ec7ad87c55..0c80a87669 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-12T19:00:00Z", + "updated_at": "2026-04-13T14:39:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -649,6 +649,46 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-31T00:00:00Z" }, + "github-issues": { + "name": "GitHub Issues Integration", + "id": "github-issues", + "description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability", + "author": "Fatima367", + "version": "1.0.0", + "download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Fatima367/spec-kit-github-issues", + "homepage": "https://github.com/Fatima367/spec-kit-github-issues", + "documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md", + "changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "gh", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "integration", + "github", + "issues", + "import", + "sync", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-12T15:30:00Z", + "updated_at": "2026-04-13T14:39:00Z" + }, "iterate": { "name": "Iterate", "id": "iterate", @@ -1911,4 +1951,4 @@ "updated_at": "2026-04-09T00:00:00Z" } } -} \ No newline at end of file +} From aa85b2f166aff608176363da08c4d4c7afc18a88 Mon Sep 17 00:00:00 2001 From: Abdullah Khan <136432132+DevAbdullah90@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:01:37 +0500 Subject: [PATCH 242/321] feat: Register "What-if Analysis" community extension (#2182) * feat: implement read-only what-if analysis command * chore: polish what-if analysis (Claude hints + optional tasks) * refactor: deliver what-if analysis as a standalone extension * Move What-if extension to standalone repo and update community catalog * Fix: Reorder whatif extension alphabetically in community catalog --- README.md | 1 + extensions/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 1d9baf09e1..94fcdadc8e 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ The following community-contributed extensions are available in [`catalog.commun | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | +| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0c80a87669..4f8accfef0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1918,6 +1918,34 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" }, + "whatif": { + "name": "What-if Analysis", + "id": "whatif", + "description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.", + "author": "DevAbdullah90", + "version": "1.0.0", + "repository": "https://github.com/DevAbdullah90/spec-kit-whatif", + "homepage": "https://github.com/DevAbdullah90/spec-kit-whatif", + "documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "analysis", + "planning", + "simulation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, "worktree": { "name": "Worktree Isolation", "id": "worktree", From fe75a456272e105387b7f8985d885ae5d22d4c54 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:04:40 +0200 Subject: [PATCH 243/321] feat: Update catalog.community.json for preset-fiction-book-writing (#2199) * feat: Update catalog.community.json for preset-fiction-book-writing * Add fiction-book-writing preset to community catalog - Preset ID: fiction-book-writing - Version: 1.3.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * doc: added fiction-book-writing preset link in README.md * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- README.md | 1 + presets/catalog.community.json | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/README.md b/README.md index 94fcdadc8e..6647c5657d 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 378b264c17..b212037661 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -78,6 +78,40 @@ "wave-dag" ] }, + "fiction-book-writing": { + "name": "Fiction Book Writing", + "id": "fiction-book-writing", + "version": "1.3.0", + "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 21, + "commands": 17, + "scripts": 1 + }, + "tags": [ + "writing", + "novel", + "book", + "fiction", + "storytelling", + "creative-writing", + "kdp", + "single-pov", + "multi-pov", + "export" + ], + "created_at": "2026-04-09T08:00:00Z", + "updated_at": "2026-04-09T08:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From bb7da09b6584278ee8bde8b0414f4ea517ed71d7 Mon Sep 17 00:00:00 2001 From: dango85 Date: Mon, 13 Apr 2026 14:34:54 -0500 Subject: [PATCH 244/321] Add Worktrees extension to community catalog (#2207) - Extension ID: worktrees - Version: 1.0.0 - Author: dango85 - Description: Default-on worktree isolation for parallel agents Made-with: Cursor Co-authored-by: Abishek Yadav --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 6647c5657d..8b47146405 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ The following community-contributed extensions are available in [`catalog.commun | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | +| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4f8accfef0..4cfa66cb19 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1977,6 +1977,38 @@ "stars": 0, "created_at": "2026-04-09T00:00:00Z", "updated_at": "2026-04-09T00:00:00Z" + }, + "worktrees": { + "name": "Worktrees", + "id": "worktrees", + "description": "Default-on worktree isolation for parallel agents — sibling or nested layout", + "author": "dango85", + "version": "1.0.0", + "download_url": "https://github.com/dango85/spec-kit-worktree-parallel/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dango85/spec-kit-worktree-parallel", + "homepage": "https://github.com/dango85/spec-kit-worktree-parallel", + "documentation": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/README.md", + "changelog": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "agents" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" } } } From de93528fad7d43da2a872770e8e16c239d343f18 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:35:22 -0500 Subject: [PATCH 245/321] chore: release 0.6.2, begin 0.6.3.dev0 development (#2205) * chore: bump version to 0.6.2 * chore: begin 0.6.3.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd932a4b2..928bc74b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ +## [0.6.2] - 2026-04-13 + +### Changed + +- feat: Register "What-if Analysis" community extension (#2182) +- feat: add GitHub Issues Integration to community catalog (#2188) +- feat(agents): add Goose AI agent support (#2015) +- Update ralph extension to v1.0.1 in community catalog (#2192) +- fix: skip docs deployment workflow on forks (#2171) +- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162) + ## [0.6.1] - 2026-04-10 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 7fec6cc6ed..7253358f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.2.dev0" +version = "0.6.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 4687c33b0ff83181a7e1eb14bf76b3576a53539b Mon Sep 17 00:00:00 2001 From: Gabriel Henrique <51464658+gabrielhmsantos@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:11:17 -0300 Subject: [PATCH 246/321] feat(scripts): optional single-segment branch prefix for gitflow (#2202) * feat(scripts): optional single-segment branch prefix for gitflow - Add spec_kit_effective_branch_name / Get-SpecKitEffectiveBranchName: when branch matches prefix/rest with exactly one slash, validate and resolve specs/ using only the rest (e.g. feat/001-my-feature). - Wire into check_feature_branch, find_feature_dir_by_prefix (bash) and Test-FeatureBranch, Find-FeatureDirByPrefix + Get-FeaturePathsEnv (PS). - Align git extension git-common with core validation; remove unused get_feature_dir / Get-FeatureDir helpers. - Extend tests in test_timestamp_branches.py and test_git_extension.py. Made-with: Cursor * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(ps): align feature-dir resolution errors with bash (no throw under Stop) Find-FeatureDirByPrefix: on ambiguous prefix matches, write errors to stderr and return $null instead of throwing, matching find_feature_dir_by_prefix. Get-FeaturePathsEnv: narrow try/catch to ConvertFrom-Json only; add Get-FeatureDirFromBranchPrefixOrExit to mirror bash get_feature_paths (stderr + exit 1) when prefix lookup fails, avoiding unhandled terminating errors under $ErrorActionPreference = 'Stop' in check-prerequisites, setup-plan, and update-agent-context. Made-with: Cursor * Update tests/test_timestamp_branches.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/git/scripts/powershell/git-common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/git/scripts/bash/git-common.sh | 39 ++++--- .../git/scripts/powershell/git-common.ps1 | 35 +++--- scripts/bash/common.sh | 23 +++- scripts/powershell/common.ps1 | 95 ++++++++++++---- tests/extensions/git/test_git_extension.py | 37 +++++++ tests/test_timestamp_branches.py | 101 ++++++++++++++++-- 6 files changed, 268 insertions(+), 62 deletions(-) diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 882a385e28..b78356d1c6 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -11,10 +11,22 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + # Validate that a branch name matches the expected feature branch pattern. # Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -23,19 +35,20 @@ check_feature_branch() { return 0 fi - # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) - if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 - fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) - if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - return 0 + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 fi - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 + return 0 } diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 8a9c4fd6cc..82210000b6 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -15,6 +15,14 @@ function Test-HasGit { } } +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -27,24 +35,17 @@ function Test-FeatureBranch { return $true } - # Reject malformed timestamps (7-digit date or no trailing slug) - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or - ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - if ($hasMalformedTimestamp) { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false - } + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - $isTimestamp = $Branch -match '^\d{8}-\d{6}-' - - if ($isSequential -or $isTimestamp) { - return $true + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false } - - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false + return $true } diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 04af7d794f..b41d17dec3 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -114,8 +114,19 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -124,6 +135,9 @@ check_feature_branch() { return 0 fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") + # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false @@ -131,7 +145,7 @@ check_feature_branch() { is_sequential=true fi if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi @@ -139,13 +153,12 @@ check_feature_branch() { return 0 } -get_feature_dir() { echo "$1/specs/$2"; } - # Find feature directory by numeric prefix instead of exact branch match # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" - local branch_name="$2" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") local specs_dir="$repo_root/specs" # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35ed884f0f..0d6544aaf4 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -127,6 +127,16 @@ function Test-HasGit { } } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -138,22 +148,69 @@ function Test-FeatureBranch { Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" return $true } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") return $false } return $true } -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" +# Resolve specs/ by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix). +function Find-FeatureDirByPrefix { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$Branch + ) + $specsDir = Join-Path $RepoRoot 'specs' + $branchName = Get-SpecKitEffectiveBranchName $Branch + + $prefix = $null + if ($branchName -match '^(\d{8}-\d{6})-') { + $prefix = $Matches[1] + } elseif ($branchName -match '^(\d{3,})-') { + $prefix = $Matches[1] + } else { + return (Join-Path $specsDir $branchName) + } + + $dirMatches = @() + if (Test-Path -LiteralPath $specsDir -PathType Container) { + $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue) + } + + if ($dirMatches.Count -eq 0) { + return (Join-Path $specsDir $branchName) + } + if ($dirMatches.Count -eq 1) { + return $dirMatches[0].FullName + } + $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' ' + [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names") + [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.') + return $null +} + +# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1). +function Get-FeatureDirFromBranchPrefixOrExit { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$CurrentBranch + ) + $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch + if ($null -eq $resolved) { + [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory') + exit 1 + } + return $resolved } function Get-FeaturePathsEnv { @@ -164,7 +221,7 @@ function Get-FeaturePathsEnv { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) - # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) + # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { $featureDir = $env:SPECIFY_FEATURE_DIRECTORY @@ -173,22 +230,24 @@ function Get-FeaturePathsEnv { $featureDir = Join-Path $repoRoot $featureDir } } elseif (Test-Path $featureJson) { + $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw try { - $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json - if ($featureConfig.feature_directory) { - $featureDir = $featureConfig.feature_directory - # Normalize relative paths to absolute under repo root - if (-not [System.IO.Path]::IsPathRooted($featureDir)) { - $featureDir = Join-Path $repoRoot $featureDir - } - } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch - } + $featureConfig = $featureJsonRaw | ConvertFrom-Json } catch { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_") + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } [PSCustomObject]@{ diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 098caf53b7..50ab9c7b6b 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -587,3 +587,40 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): capture_output=True, text=True, ) assert result.returncode != 0 + + def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): + """git-common check_feature_branch matches core: one optional path prefix.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestGitCommonPowerShell: + def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2161d2893c..b258fa98d1 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -26,6 +26,13 @@ EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +HAS_PWSH = shutil.which("pwsh") is not None + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + return HAS_PWSH + @pytest.fixture def git_repo(tmp_path: Path) -> Path: @@ -271,6 +278,30 @@ def test_rejects_7digit_timestamp_without_slug(self): result = source_and_call('check_feature_branch "2026031-143022" "true"') assert result.returncode != 0 + def test_accepts_single_prefix_sequential(self): + """Optional gitflow-style prefix: one segment + sequential feature name.""" + result = source_and_call('check_feature_branch "feat/004-my-feature" "true"') + assert result.returncode == 0 + + def test_accepts_single_prefix_timestamp(self): + """Optional prefix + timestamp-style feature name.""" + result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"') + assert result.returncode == 0 + + def test_rejects_invalid_suffix_with_single_prefix(self): + result = source_and_call('check_feature_branch "feat/main" "true"') + assert result.returncode != 0 + assert "feat/main" in result.stderr + + def test_rejects_two_level_prefix_before_feature(self): + """More than one slash: no stripping; whole name must match (fails).""" + result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"') + assert result.returncode != 0 + + def test_rejects_malformed_timestamp_with_prefix(self): + result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── @@ -303,6 +334,67 @@ def test_four_digit_sequential_prefix(self, tmp_path: Path): assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + def test_sequential_with_single_path_prefix(self, tmp_path: Path): + """Strip one optional prefix segment before prefix directory lookup.""" + (tmp_path / "specs" / "004-only-dir").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir" + + def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): + (tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical" + + +# ── get_feature_paths + single-prefix integration ─────────────────────────── + + +class TestGetFeaturePathsSinglePrefix: + def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): + """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" + (tmp_path / ".specify").mkdir() + (tmp_path / "specs" / "001-target-spec").mkdir(parents=True) + cmd = ( + f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' + f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' + ) + result = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): + """PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + spec_dir = git_repo / "specs" / "001-ps-prefix-spec" + spec_dir.mkdir(parents=True) + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip() + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + # ── get_current_branch Tests ───────────────────────────────────────────────── @@ -791,15 +883,6 @@ def test_dry_run_no_git(self, no_git_dir: Path): # ── PowerShell Dry-Run Tests ───────────────────────────────────────────────── -def _has_pwsh() -> bool: - """Check if pwsh is available.""" - try: - subprocess.run(["pwsh", "--version"], capture_output=True, check=True) - return True - except (FileNotFoundError, subprocess.CalledProcessError): - return False - - def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: """Run create-new-feature.ps1 from the temp repo's scripts directory.""" script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" From 03a9163633a38b498ad70e5ce3e464edca46d961 Mon Sep 17 00:00:00 2001 From: ysumanth06 Date: Mon, 13 Apr 2026 18:12:27 -0500 Subject: [PATCH 247/321] =?UTF-8?q?Add=20SFSpeckit=20=E2=80=94=20Salesforc?= =?UTF-8?q?e=20SDD=20Extension=20(#2208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SFSpeckit — Salesforce SDD Extension * chore: update catalog updated_at timestamp --- README.md | 1 + extensions/catalog.community.json | 46 ++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b47146405..c729fe02f1 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4cfa66cb19..61731b22d9 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T14:39:00Z", + "updated_at": "2026-04-13T23:01:30Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1529,6 +1529,50 @@ "created_at": "2026-04-03T03:24:03Z", "updated_at": "2026-04-03T04:15:00Z" }, + "sf": { + "name": "SFSpeckit — Salesforce Spec-Driven Development", + "id": "sf", + "description": "Enterprise-Grade Spec-Driven Development (SDD) Framework for Salesforce.", + "author": "Sumanth Yanamala", + "version": "1.0.0", + "download_url": "https://github.com/ysumanth06/spec-kit-sf/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/ysumanth06/spec-kit-sf", + "homepage": "https://ysumanth06.github.io/spec-kit-sf/", + "documentation": "https://ysumanth06.github.io/spec-kit-sf/introduction.html", + "changelog": "https://github.com/ysumanth06/spec-kit-sf/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0", + "tools": [ + { + "name": "sf", + "version": ">=2.0.0", + "required": true + }, + { + "name": "gh", + "version": ">=2.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 18, + "hooks": 2 + }, + "tags": [ + "salesforce", + "enterprise", + "sdlc", + "apex", + "devops" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T22:11:30Z", + "updated_at": "2026-04-13T22:11:30Z" + }, "ship": { "name": "Ship Release Extension", "id": "ship", From c0152e4f3d4a550cd78d60f625dbfc85312bc8a6 Mon Sep 17 00:00:00 2001 From: Rafa Gomes <565337+0xrafasec@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:17:28 -0300 Subject: [PATCH 248/321] docs(catalog): add claude-ask-questions to community preset catalog (#2191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add claude-ask-questions preset for AskUserQuestion rendering Delivers the /speckit.clarify and /speckit.checklist AskUserQuestion integration as a stackable preset under presets/claude-ask-questions/ instead of modifying core templates or ClaudeIntegration. - presets/claude-ask-questions/preset.yml registers command overrides for speckit.clarify and speckit.checklist following the same pattern as the bundled lean preset. - Override commands replace the Markdown-table question-rendering blocks with AskUserQuestion instructions. Option | Description maps to {label, description} for clarify; Option | Candidate | Why It Matters maps to {label: Candidate, description: Why It Matters} for checklist. Recommended option is placed first with a "Recommended — " prefix; a final "Custom"/"Short" option preserves the free-form ≤5-word escape hatch. - Registered in presets/catalog.json as a bundled preset. Core templates, ClaudeIntegration, and the existing test suite are left untouched, so non-Claude agents and users who do not install this preset see no behavior change. Closes github/spec-kit#2181 Co-Authored-By: Claude Opus 4.6 * refactor: move claude-ask-questions preset to external repo Per maintainer feedback on #2191, presets should be hosted on the author's own GitHub repository and registered in catalog.community.json rather than bundled in spec-kit. Removes the bundled preset directory and its entry from the official catalog, and adds a community catalog entry pointing at the external repository and release archive. Co-Authored-By: Claude Opus 4.6 * docs(catalog): sync claude-ask-questions description with upstream preset * revert: keep presets/catalog.json updated_at unchanged No entries in the official catalog changed in this PR, so the timestamp bump was spurious. Addresses Copilot review feedback on #2191. --------- Co-authored-by: Claude Opus 4.6 --- presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index b212037661..bc105e7486 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T08:00:00Z", + "updated_at": "2026-04-13T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -53,6 +53,33 @@ "spec-first" ] }, + "claude-ask-questions": { + "name": "Claude AskUserQuestion", + "id": "claude-ask-questions", + "version": "1.0.0", + "description": "Upgrades /speckit.clarify and /speckit.checklist on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question.", + "author": "0xrafasec", + "repository": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "download_url": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "documentation": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "claude", + "ask-user-question", + "clarify", + "checklist" + ], + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, "explicit-task-dependencies": { "name": "Explicit Task Dependencies", "id": "explicit-task-dependencies", From a00e6799182520cb8905eb59e61510753cbe981d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:11:56 -0500 Subject: [PATCH 249/321] Add workflow engine with catalog system (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add workflow engine with step registry, expression engine, catalog system, and CLI commands Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Add comprehensive tests for workflow engine (94 tests) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Address review feedback: do-while condition preservation and URL scheme validation Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Address review feedback, add CLI dispatch, interactive gates, and docs Review comments (7/7): - Add explanatory comment to empty except block - Implement workflow catalog download with cleanup on failure - Add input type coercion for number/boolean/enum - Fix example workflow to remove non-existent output references - Fix while_loop and if_then condition defaults (string 'false' → bool False) - Fix resume step index tracking with step_offset parameter CLI dispatch: - Add build_exec_args() and dispatch_command() to IntegrationBase - Override for Claude (skills: /speckit-specify), Gemini (-m flag), Codex (codex exec), Copilot (--agent speckit.specify) - CommandStep invokes installed commands by name via integration CLI - Add PromptStep for arbitrary inline prompts (10th step type) - Stream CLI output live to terminal (no silent blocking) - Remove timeout when streaming (user can Ctrl+C) - Ctrl+C saves state as PAUSED for clean resume Interactive gates: - Gate steps prompt [1] approve [2] reject in TTY - Fall back to PAUSED in non-interactive environments - Resume re-executes the gate for interactive prompting Documentation: - workflows/README.md — user guide - workflows/ARCHITECTURE.md — internals with Mermaid diagrams - workflows/PUBLISHING.md — catalog submission guide Tests: 94 → 122 workflow tests, 1362 total (all passing) * Fix ruff lint errors: unused imports, f-string placeholders, undefined name * Address second review: registry-backed validation, shell failures, loop/fan-out execution, URL validation - VALID_STEP_TYPES now queries STEP_REGISTRY dynamically - Shell step returns FAILED on non-zero exit code - Persist workflow YAML in run directory for reliable resume - Resume loads from run copy, falls back to installed workflow - Engine iterates while/do-while loops up to max_iterations - Engine expands fan-out per item with context.item - HTTPS URL validation for catalog workflow installs (HTTP allowed for localhost) - Fix catalog merge priority docstring (lower number wins) - Fix dispatch_command docstring (no build_exec_args_for_command) - Gate on_reject=retry pauses for re-prompt on resume - Update docs to 10 step types, add prompt step to tables and README * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Address third review: fan-out IDs, catalog guards, shell coercion, docs - Fan-out generates unique per-item step IDs and collects results - Catalog merge skips non-dict workflow entries (malformed data guard) - Shell step coerces run_cmd to str after expression evaluation - urlopen timeout=30 for catalog workflow installs - yaml.dump with sort_keys=False, allow_unicode=True for catalog configs - Document streaming timeout as intentionally unbounded (user Ctrl+C) - Document --allow-all-tools as required for non-interactive + future enhancement - Update test docstring and PUBLISHING.md to 10 step types with prompt * Validate final URL after redirects in catalog fetch urlopen follows redirects, so validate the response URL against the same HTTPS/localhost rules to prevent redirect-based downgrade attacks. * Address fourth review: filter arg eval, tags normalization, install redirect check - Filter arguments now evaluated via _evaluate_simple_expression() so default(42) returns int not string - Tags normalized: non-list/non-string values handled gracefully - Install URL redirect validation (same as catalog fetch) - Remove unused 'skipped' variable in catalog config parsing - Author 'github' → 'GitHub' in example workflow - Document nested step resume limitation (re-runs parent step) * Add explanatory comment to empty except ValueError block * Address fifth review: expression parsing, fan-out output, URL install, gate options - Move string literal parsing before operator detection in expressions so quoted strings with operators (e.g. 'a in b') are not mis-parsed - Fan-out: remove max_concurrency from persisted output, fix docstring to reflect sequential execution - workflow add: support URL sources with HTTPS/redirect validation, validate workflow ID is non-empty before writing files - Deduplicate local install logic via _validate_and_install_local() - Remove 'edit' gate option from speckit workflow (not implemented) * Add comments to empty except ValueError blocks in URL install * Address sixth review: operator precedence, fan_in cleanup, registry resilience, docs - Fix or/and operator precedence (or parsed first = lower precedence) - Restore context.fan_in after fan-in step completes - Catch JSONDecodeError in registry load for corrupted files - Replace print() with on_step_start callback (library-safe) - Gate validation warns when on_reject set but no reject option - Shell step: document shell=True security tradeoff - README: sdd-pipeline → speckit, parallel → sequential for fan-out - ARCHITECTURE.md: parallel → fan-out/fan-in in diagram * Address seventh review: string literal before pipe, type annotations, validate on install - Move string literal check above pipe filter parsing so 'a | b' works - Fix type annotations: input_values list[str] | None, run_id str | None - Run validate_workflow() before installing from local path/URL - Remove duplicate string literal check from expression parser * Address eighth review: fan-out namespaced IDs, early return, catalog validation - Fan-out per-item step IDs use _fanout_{step_id}_{base}_{idx} namespace to avoid collisions with user-defined step IDs - Early return after fan-out loop when state is paused/failed/aborted - Catalog installs parse + validate downloaded YAML before registering, using definition metadata instead of catalog entry for registry * Address ninth review: populate catalog, fix indentation, priority, README - Add speckit workflow entry to catalog.json so it's discoverable - Fix shell step output dict indentation - Catalog add_catalog priority derived from max existing + 1 - README Quick Start clarified with install + local file examples * Address tenth review: max_iterations validation, catalog config guard, version alignment - Validate max_iterations is int >= 1 in while and do-while steps - Guard add_catalog against corrupted config (non-dict/non-list) - Align speckit_version requirement to >=0.6.1 (current package version) - Fan-out template validation uses separate seen_ids set to avoid false duplication errors with user-defined step IDs * Address eleventh review: command step fails without CLI, ID mismatch warning, state persistence - Command step returns FAILED when CLI not installed (was silent COMPLETED) - Catalog install warns on workflow ID vs catalog key mismatch - Engine persists state.save() before returning on unknown step type - Update tests to expect FAILED for command steps without CLI - Integration tests use shell steps for CLI-independent execution * Address twelfth review: type annotations, version examples, streaming docs, requires - Fix workflow_search type annotations (str | None) - PUBLISHING.md: speckit_version >=0.15.0 → >=0.6.1 - Document that exit_code is captured and referenceable by later steps - Mark requires as declared-but-not-enforced (planned enhancement) - Note full stdout/stderr capture as planned enhancement * Enforce catalog key matches workflow ID (fail instead of warn) * Bundle speckit workflow: auto-install during specify init - Add workflows/speckit to pyproject.toml force-include for wheel builds - Add _locate_bundled_workflow() helper (mirrors _locate_bundled_extension) - Auto-install speckit workflow during specify init (after git extension) - Update all integration file inventory tests to expect workflow files * Address fourteenth review: prompt fails without CLI, resolved step data, fan-out normalization - PromptStep returns FAILED when CLI not installed (was silent COMPLETED) - Engine step_data prefers resolved values from step output - Fan-out normalizes output.results=[] for empty item lists - subprocess.run inherits stdout/stderr (no explicit sys.stdout) - Registry tests use issubset for extensibility * Address fifteenth review: fan_in docstring, gate defaults, validation guards, reserved prefix - FanInStep docstring: aggregate-only, no blocking semantics - FanInStep: guard output_config as dict, handle None - Gate validate: use same default options as execute - Validate inputs is dict and steps is list before iterating - Reserve _fanout_ prefix in step ID validation - PUBLISHING.md: remove unenforced checklist items, add _fanout_ note * Address sixteenth review: docs regex, fan_in try/finally, hyphenated dot-path keys - PUBLISHING.md: update ID regex docs to match implementation (single-char OK) - FanInStep: wrap expression evaluation in try/finally for context.fan_in - Expression dot-path: allow hyphens in keys before list index (e.g. run-tests[0]) * Make speckit workflow integration-agnostic, document Copilot CLI requirement - Workflow integration selectable via input (default: claude) - Each command step uses {{ inputs.integration }} instead of hardcoded copilot - Copilot docstring documents CLI requirement for workflow dispatch - Added install_url for Copilot CLI docs * Address seventeenth review: project checks, catalog robustness - Add .specify/ project check to workflow run/resume/status/search/info - remove_catalog validates config shape (dict + list) before indexing - _fetch_single_catalog validates response is a dict - _get_merged_workflows raises when all catalogs fail to fetch - add_catalog guards against non-dict catalog entries in config * Address eighteenth review: condition coercion, gate abort result, while default, cache guard, resume log - evaluate_condition treats plain 'false'/'true' strings as booleans - Gate abort returns StepResult(FAILED) instead of raising exception so step output is persisted in state for inspection - while_loop max_iterations optional (default 10), validation aligned - Catalog cache fallback catches invalid JSON gracefully - resume() appends workflow_finished log entry like execute() * Address nineteenth review: allow-all-tools opt-in, empty catalogs, abort dead code, while docstring - --allow-all-tools controlled by SPECKIT_ALLOW_ALL_TOOLS env var (default: 1) Set to 0 to disable automatic tool approval for Copilot CLI - Empty catalogs list falls back to built-in defaults (not an error) - Remove unreachable WorkflowAbortError catches from execute/resume (gate abort now returns StepResult(FAILED) instead of raising) - while_loop docstring updated: max_iterations is optional (default 10) * Address twentieth review: gate abort maps to ABORTED status, do-while max_iterations optional - Engine detects output.aborted from gate step and sets RunStatus.ABORTED (was unreachable — gate abort returned FAILED but status was always FAILED) - do-while max_iterations now optional (default 10), aligned with while_loop - do-while docstring and validation updated accordingly * Coerce default_options to dict, align bundled workflow ID regex with validator * Gate validates string options, prompt uses resolved integration, loop normalizes max_iterations * Use parentId:childId convention for nested step IDs - Fan-out per-item IDs use parentId:templateId:index (e.g. parallel:impl:0) - Reserve ':' in user step IDs (validation rejects) - Replaces _fanout_ prefix with cleaner namespacing - Expressions like {{ steps.parallel:impl:0.output.file }} work naturally * Validate workflow version is semantic versioning (X.Y.Z) * Schema version validation, strict semver, load_workflow docstring, preserve max_concurrency - Validate schema_version is '1.0' (reject unknown future schemas) - Strict semver regex: ^\d+\.\d+\.\d+$ (rejects 1.0.0beta etc.) - load_workflow docstring: 'parsed' not 'validated' - Keep max_concurrency in fan-out output (was dropped) - do_while docstring: engine re-evaluates step_config condition - ARCHITECTURE.md: document nested resume limitation * Path traversal prevention, loop step ID namespacing - RunState validates run_id is alphanumeric+hyphens (no path separators) - workflow_add validates catalog source doesn't escape workflows_dir - Loop iterations namespace nested step IDs as parentId:childId:iteration so multiple iterations don't overwrite each other in context/state --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- pyproject.toml | 2 + src/specify_cli/__init__.py | 719 +++++++ src/specify_cli/integrations/base.py | 176 ++ .../integrations/codex/__init__.py | 15 + .../integrations/copilot/__init__.py | 104 +- src/specify_cli/workflows/__init__.py | 68 + src/specify_cli/workflows/base.py | 132 ++ src/specify_cli/workflows/catalog.py | 540 +++++ src/specify_cli/workflows/engine.py | 778 +++++++ src/specify_cli/workflows/expressions.py | 300 +++ src/specify_cli/workflows/steps/__init__.py | 1 + .../workflows/steps/command/__init__.py | 155 ++ .../workflows/steps/do_while/__init__.py | 61 + .../workflows/steps/fan_in/__init__.py | 61 + .../workflows/steps/fan_out/__init__.py | 58 + .../workflows/steps/gate/__init__.py | 121 ++ .../workflows/steps/if_then/__init__.py | 55 + .../workflows/steps/prompt/__init__.py | 156 ++ .../workflows/steps/shell/__init__.py | 75 + .../workflows/steps/switch/__init__.py | 70 + .../workflows/steps/while_loop/__init__.py | 68 + .../test_integration_base_markdown.py | 3 + .../test_integration_base_skills.py | 5 + .../test_integration_base_toml.py | 3 + .../test_integration_base_yaml.py | 3 + .../integrations/test_integration_copilot.py | 4 + .../integrations/test_integration_generic.py | 4 + tests/test_workflows.py | 1803 +++++++++++++++++ workflows/ARCHITECTURE.md | 211 ++ workflows/PUBLISHING.md | 285 +++ workflows/README.md | 339 ++++ workflows/catalog.community.json | 6 + workflows/catalog.json | 16 + workflows/speckit/workflow.yml | 63 + 34 files changed, 6458 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/workflows/__init__.py create mode 100644 src/specify_cli/workflows/base.py create mode 100644 src/specify_cli/workflows/catalog.py create mode 100644 src/specify_cli/workflows/engine.py create mode 100644 src/specify_cli/workflows/expressions.py create mode 100644 src/specify_cli/workflows/steps/__init__.py create mode 100644 src/specify_cli/workflows/steps/command/__init__.py create mode 100644 src/specify_cli/workflows/steps/do_while/__init__.py create mode 100644 src/specify_cli/workflows/steps/fan_in/__init__.py create mode 100644 src/specify_cli/workflows/steps/fan_out/__init__.py create mode 100644 src/specify_cli/workflows/steps/gate/__init__.py create mode 100644 src/specify_cli/workflows/steps/if_then/__init__.py create mode 100644 src/specify_cli/workflows/steps/prompt/__init__.py create mode 100644 src/specify_cli/workflows/steps/shell/__init__.py create mode 100644 src/specify_cli/workflows/steps/switch/__init__.py create mode 100644 src/specify_cli/workflows/steps/while_loop/__init__.py create mode 100644 tests/test_workflows.py create mode 100644 workflows/ARCHITECTURE.md create mode 100644 workflows/PUBLISHING.md create mode 100644 workflows/README.md create mode 100644 workflows/catalog.community.json create mode 100644 workflows/catalog.json create mode 100644 workflows/speckit/workflow.yml diff --git a/pyproject.toml b/pyproject.toml index 7253358f78..db53f2cb58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled workflows (auto-installed during `specify init`) +"workflows/speckit" = "specify_cli/core_pack/workflows/speckit" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bbf42ad5a..c33281e2b4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return None +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + """Return the path to a bundled workflow directory, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``workflows//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + return None + + def _locate_bundled_preset(preset_id: str) -> Path | None: """Return the path to a bundled preset, or None. @@ -1159,6 +1184,7 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("git", "Install git extension"), + ("workflow", "Install bundled workflow"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -1262,6 +1288,37 @@ def init( else: tracker.skip("git", "--no-git flag") + # Install bundled speckit workflow + try: + bundled_wf = _locate_bundled_workflow("speckit") + if bundled_wf: + from .workflows.catalog import WorkflowRegistry + from .workflows.engine import WorkflowDefinition + wf_registry = WorkflowRegistry(project_path) + if wf_registry.is_installed("speckit"): + tracker.complete("workflow", "already installed") + else: + import shutil as _shutil + dest_wf = project_path / ".specify" / "workflows" / "speckit" + dest_wf.mkdir(parents=True, exist_ok=True) + _shutil.copy2( + bundled_wf / "workflow.yml", + dest_wf / "workflow.yml", + ) + definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") + wf_registry.add("speckit", { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }) + tracker.complete("workflow", "speckit installed") + else: + tracker.skip("workflow", "bundled workflow not found") + except Exception as wf_err: + sanitized_wf = str(wf_err).replace('\n', ' ').strip() + tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") + # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) @@ -4136,6 +4193,668 @@ def extension_set_priority( console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") +# ===== Workflow Commands ===== + +workflow_app = typer.Typer( + name="workflow", + help="Manage and run automation workflows", + add_completion=False, +) +app.add_typer(workflow_app, name="workflow") + +workflow_catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) +workflow_app.add_typer(workflow_catalog_app, name="catalog") + + +@workflow_app.command("run") +def workflow_run( + source: str = typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), +): + """Run a workflow from an installed ID or local YAML path.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + definition = engine.load_workflow(source) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Workflow not found: {source}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] Invalid workflow: {exc}") + raise typer.Exit(1) + + # Validate + errors = engine.validate(definition) + if errors: + console.print("[red]Workflow validation failed:[/red]") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + # Parse inputs + inputs: dict[str, Any] = {} + if input_values: + for kv in input_values: + if "=" not in kv: + console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") + raise typer.Exit(1) + key, _, value = kv.partition("=") + inputs[key.strip()] = value.strip() + + console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") + console.print(f"[dim]Version: {definition.version}[/dim]\n") + + try: + state = engine.execute(definition, inputs) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Workflow failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + console.print(f"[dim]Run ID: {state.run_id}[/dim]") + + if state.status.value == "paused": + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") + + +@workflow_app.command("resume") +def workflow_resume( + run_id: str = typer.Argument(..., help="Run ID to resume"), +): + """Resume a paused or failed workflow run.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + state = engine.resume(run_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Resume failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + + +@workflow_app.command("status") +def workflow_status( + run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), +): + """Show workflow run status.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + + if run_id: + try: + from .workflows.engine import RunState + state = RunState.load(run_id, project_root) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") + + console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + console.print(f" Workflow: {state.workflow_id}") + console.print(f" Status: [{color}]{state.status.value}[/{color}]") + console.print(f" Created: {state.created_at}") + console.print(f" Updated: {state.updated_at}") + + if state.current_step_id: + console.print(f" Current: {state.current_step_id}") + + if state.step_results: + console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") + console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() + if not runs: + console.print("[yellow]No workflow runs found.[/yellow]") + return + + console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") + console.print( + f" [{sc}]●[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) + + +@workflow_app.command("list") +def workflow_list(): + """List installed workflows.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + installed = registry.list() + + if not installed: + console.print("[yellow]No workflows installed.[/yellow]") + console.print("\nInstall a workflow with:") + console.print(" [cyan]specify workflow add [/cyan]") + return + + console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") + desc = wf_data.get("description", "") + if desc: + console.print(f" {desc}") + console.print() + + +@workflow_app.command("add") +def workflow_add( + source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), +): + """Install a workflow from catalog, URL, or local path.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowDefinition + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + workflows_dir = project_root / ".specify" / "workflows" + + def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + definition = WorkflowDefinition.from_yaml(yaml_path) + except (ValueError, yaml.YAMLError) as exc: + console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") + raise typer.Exit(1) + if not definition.id or not definition.id.strip(): + console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + dest_dir = workflows_dir / definition.id + dest_dir.mkdir(parents=True, exist_ok=True) + import shutil + shutil.copy2(yaml_path, dest_dir / "workflow.yml") + registry.add(definition.id, { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + }) + console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") + + # Try as URL (http/https) + if source.startswith("http://") or source.startswith("https://"): + from ipaddress import ip_address + from urllib.parse import urlparse + from urllib.request import urlopen # noqa: S310 + + parsed_src = urlparse(source) + src_host = parsed_src.hostname or "" + src_loopback = src_host == "localhost" + if not src_loopback: + try: + src_loopback = ip_address(src_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. + pass + if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): + console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") + raise typer.Exit(1) + + import tempfile + try: + with urlopen(source, timeout=30) as resp: # noqa: S310 + final_url = resp.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_lb = final_host == "localhost" + if not final_lb: + try: + final_lb = ip_address(final_host).is_loopback + except ValueError: + # Redirect host is not an IP literal; keep loopback as determined above. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): + console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") + raise typer.Exit(1) + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: + tmp.write(resp.read()) + tmp_path = Path(tmp.name) + except typer.Exit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") + raise typer.Exit(1) + try: + _validate_and_install_local(tmp_path, source) + finally: + tmp_path.unlink(missing_ok=True) + return + + # Try as a local file/directory + source_path = Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.exists(): + console.print(f"[red]Error:[/red] No workflow.yml found in {source}") + raise typer.Exit(1) + _validate_and_install_local(wf_file, str(source_path)) + return + + # Try from catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(source) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not info: + console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") + raise typer.Exit(1) + + if not info.get("_install_allowed", True): + console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") + console.print("Direct installation is not enabled for this catalog source.") + raise typer.Exit(1) + + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") + raise typer.Exit(1) + + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from ipaddress import ip_address + from urllib.parse import urlparse + + parsed_url = urlparse(workflow_url) + url_host = parsed_url.hostname or "" + is_loopback = False + if url_host == "localhost": + is_loopback = True + else: + try: + is_loopback = ip_address(url_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): + console.print( + f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." + ) + raise typer.Exit(1) + + workflow_dir = workflows_dir / source + # Validate that source is a safe directory name (no path traversal) + try: + workflow_dir.resolve().relative_to(workflows_dir.resolve()) + except ValueError: + console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") + raise typer.Exit(1) + workflow_file = workflow_dir / "workflow.yml" + + try: + from urllib.request import urlopen # noqa: S310 — URL comes from catalog + + workflow_dir.mkdir(parents=True, exist_ok=True) + with urlopen(workflow_url, timeout=30) as response: # noqa: S310 + # Validate final URL after redirects + final_url = response.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_loopback = final_host == "localhost" + if not final_loopback: + try: + final_loopback = ip_address(final_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" + ) + raise typer.Exit(1) + workflow_file.write_bytes(response.read()) + except Exception as exc: + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") + raise typer.Exit(1) + + # Validate the downloaded workflow before registering + try: + definition = WorkflowDefinition.from_yaml(workflow_file) + except (ValueError, yaml.YAMLError) as exc: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + # Enforce that the workflow's internal ID matches the catalog key + if definition.id and definition.id != source: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " + f"does not match catalog key ({source!r}). " + f"The catalog entry may be misconfigured." + ) + raise typer.Exit(1) + + registry.add(source, { + "name": definition.name or info.get("name", source), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + }) + console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") + + +@workflow_app.command("remove") +def workflow_remove( + workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), +): + """Uninstall a workflow.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + + if not registry.is_installed(workflow_id): + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") + raise typer.Exit(1) + + # Remove workflow files + workflow_dir = project_root / ".specify" / "workflows" / workflow_id + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir) + + registry.remove(workflow_id) + console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") + + +@workflow_app.command("search") +def workflow_search( + query: str | None = typer.Argument(None, help="Search query"), + tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), +): + """Search workflow catalogs.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + catalog = WorkflowCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No workflows found.[/yellow]") + return + + console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") + desc = wf.get("description", "") + if desc: + console.print(f" {desc}") + tags = wf.get("tags", []) + if tags: + console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") + console.print() + + +@workflow_app.command("info") +def workflow_info( + workflow_id: str = typer.Argument(..., help="Workflow ID"), +): + """Show workflow details and step graph.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + # Check installed first + registry = WorkflowRegistry(project_root) + installed = registry.get(workflow_id) + + engine = WorkflowEngine(project_root) + + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass + + if definition: + console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") + console.print(f" Version: {definition.version}") + if definition.author: + console.print(f" Author: {definition.author}") + if definition.description: + console.print(f" Description: {definition.description}") + if definition.default_integration: + console.print(f" Integration: {definition.default_integration}") + if installed: + console.print(" [green]Installed[/green]") + + if definition.inputs: + console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + + if definition.steps: + console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + console.print(f" → {step.get('id', '?')} [{stype}]") + return + + # Try catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: + info = None + + if info: + console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") + console.print(f" Version: {info.get('version', '?')}") + if info.get("description"): + console.print(f" Description: {info['description']}") + if info.get("tags"): + console.print(f" Tags: {', '.join(info['tags'])}") + console.print(" [yellow]Not installed[/yellow]") + else: + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") + raise typer.Exit(1) + + +@workflow_catalog_app.command("list") +def workflow_catalog_list(): + """List configured workflow catalog sources.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + catalog = WorkflowCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" + console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") + console.print(f" {cfg['url']}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@workflow_catalog_app.command("add") +def workflow_catalog_add( + url: str = typer.Argument(..., help="Catalog URL to add"), + name: str = typer.Option(None, "--name", help="Catalog name"), +): + """Add a workflow catalog source.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + catalog.add_catalog(url, name) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {url}") + + +@workflow_catalog_app.command("remove") +def workflow_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove a workflow catalog source by index.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + + def main(): app() diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 87eca9d3bf..26501e623f 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -91,6 +91,123 @@ def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive execution. + + Returns a list of command-line tokens that will execute *prompt* + non-interactively using this integration's CLI tool, or ``None`` + if the integration does not support CLI dispatch. + + Subclasses for CLI-based integrations should override this. + """ + return None + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the native slash-command invocation for a Spec Kit command. + + The CLI tools discover and execute commands from installed files + on disk. This method builds the invocation string the CLI + expects — e.g. ``"/speckit.specify my-feature"`` for markdown + agents or ``"/speckit-specify my-feature"`` for skills agents. + + *command_name* may be a full dotted name like + ``"speckit.specify"`` or a bare stem like ``"specify"``. + """ + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + + invocation = f"/speckit.{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch a Spec Kit command through this integration's CLI. + + By default this builds a slash-command invocation with + ``build_command_invocation()`` and passes that prompt to + ``build_exec_args()`` to construct the CLI command line. + Integrations with custom dispatch behavior can override + ``build_command_invocation()``, ``build_exec_args()``, or + ``dispatch_command()`` directly. + + When *stream* is ``True`` (the default), stdout and stderr are + piped directly to the terminal so the user sees live output. + When ``False``, output is captured and returned in the dict. + + Returns a dict with ``exit_code``, ``stdout``, and ``stderr``. + Raises ``NotImplementedError`` if the integration does not + support CLI dispatch. + """ + import subprocess + + prompt = self.build_command_invocation(command_name, args) + # When streaming to the terminal, request text output so the + # user sees readable output instead of raw JSONL events. + exec_args = self.build_exec_args( + prompt, model=model, output_json=not stream + ) + + if exec_args is None: + msg = ( + f"Integration {self.key!r} does not support CLI dispatch. " + f"Override build_exec_args() to enable it." + ) + raise NotImplementedError(msg) + + cwd = str(project_root) if project_root else None + + if stream: + # No timeout when streaming — the user sees live output and + # can Ctrl+C at any time. The timeout parameter is only + # applied in the captured (non-streaming) branch below. + try: + result = subprocess.run( + exec_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + # -- Primitives — building blocks for setup() ------------------------- def shared_commands_dir(self) -> Path | None: @@ -466,6 +583,22 @@ class MarkdownIntegration(IntegrationBase): integration-specific scripts (``update-context.sh`` / ``.ps1``). """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def setup( self, project_root: Path, @@ -534,6 +667,22 @@ class TomlIntegration(IntegrationBase): TOML format (``description`` key + ``prompt`` multiline string). """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def command_filename(self, template_name: str) -> str: """TOML commands use ``.toml`` extension.""" return f"speckit.{template_name}.toml" @@ -908,6 +1057,22 @@ class SkillsIntegration(IntegrationBase): ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def skills_dest(self, project_root: Path) -> Path: """Return the absolute path to the skills output directory. @@ -926,6 +1091,17 @@ def skills_dest(self, project_root: Path) -> Path: subdir = self.config.get("commands_subdir", "skills") return project_root / folder / subdir + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Skills use ``/speckit-`` (hyphenated directory name).""" + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + + invocation = f"/speckit-{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + def setup( self, project_root: Path, diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index f6415f9bb2..b3b509b654 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -28,6 +28,21 @@ class CodexIntegration(SkillsIntegration): } context_file = "AGENTS.md" + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Codex uses ``codex exec "prompt"`` for non-interactive mode. + args: list[str] = ["codex", "exec", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.append("--json") + return args + @classmethod def options(cls) -> list[IntegrationOption]: return [ diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 036f2e1db7..e389138a84 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -19,14 +19,19 @@ class CopilotIntegration(IntegrationBase): - """Integration for GitHub Copilot in VS Code.""" + """Integration for GitHub Copilot (VS Code IDE + CLI). + + The IDE integration (``requires_cli: False``) installs ``.agent.md`` + command files. Workflow dispatch additionally requires the + ``copilot`` CLI to be installed separately. + """ key = "copilot" config = { "name": "GitHub Copilot", "folder": ".github/", "commands_subdir": "agents", - "install_url": None, + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", "requires_cli": False, } registrar_config = { @@ -37,6 +42,101 @@ class CopilotIntegration(IntegrationBase): } context_file = ".github/copilot-instructions.md" + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # GitHub Copilot CLI uses ``copilot -p "prompt"`` for + # non-interactive mode. --allow-all-tools is required for the + # agent to perform file edits and shell commands. Controlled + # by SPECKIT_ALLOW_ALL_TOOLS env var (default: enabled). + import os + args = ["copilot", "-p", prompt] + if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0": + args.append("--allow-all-tools") + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Copilot agents are not slash-commands — just return the args as prompt.""" + return args or "" + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch via ``--agent speckit.`` instead of slash-commands. + + Copilot ``.agent.md`` files are agents, not skills. The CLI + selects them with ``--agent `` and the prompt is just + the user's arguments. + """ + import subprocess + + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + agent_name = f"speckit.{stem}" + + prompt = args or "" + import os + cli_args = [ + "copilot", "-p", prompt, + "--agent", agent_name, + ] + if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0": + cli_args.append("--allow-all-tools") + if model: + cli_args.extend(["--model", model]) + if not stream: + cli_args.extend(["--output-format", "json"]) + + cwd = str(project_root) if project_root else None + + if stream: + try: + result = subprocess.run( + cli_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + cli_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" return f"speckit.{template_name}.agent.md" diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py new file mode 100644 index 0000000000..13782f620b --- /dev/null +++ b/src/specify_cli/workflows/__init__.py @@ -0,0 +1,68 @@ +"""Workflow engine for multi-step, resumable automation workflows. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances. +- ``WorkflowEngine`` — orchestrator that loads, validates, and executes + workflow YAML definitions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import StepBase + +# Maps step type_key → StepBase instance. +STEP_REGISTRY: dict[str, StepBase] = {} + + +def _register_step(step: StepBase) -> None: + """Register a step type instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = step.type_key + if not key: + raise ValueError("Cannot register step type with an empty type_key.") + if key in STEP_REGISTRY: + raise KeyError(f"Step type with key {key!r} is already registered.") + STEP_REGISTRY[key] = step + + +def get_step_type(type_key: str) -> StepBase | None: + """Return the step type for *type_key*, or ``None`` if not registered.""" + return STEP_REGISTRY.get(type_key) + + +# -- Register built-in step types ---------------------------------------- + +def _register_builtin_steps() -> None: + """Register all built-in step types.""" + from .steps.command import CommandStep + from .steps.do_while import DoWhileStep + from .steps.fan_in import FanInStep + from .steps.fan_out import FanOutStep + from .steps.gate import GateStep + from .steps.if_then import IfThenStep + from .steps.prompt import PromptStep + from .steps.shell import ShellStep + from .steps.switch import SwitchStep + from .steps.while_loop import WhileStep + + _register_step(CommandStep()) + _register_step(DoWhileStep()) + _register_step(FanInStep()) + _register_step(FanOutStep()) + _register_step(GateStep()) + _register_step(IfThenStep()) + _register_step(PromptStep()) + _register_step(ShellStep()) + _register_step(SwitchStep()) + _register_step(WhileStep()) + + +_register_builtin_steps() diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py new file mode 100644 index 0000000000..b144ca903d --- /dev/null +++ b/src/specify_cli/workflows/base.py @@ -0,0 +1,132 @@ +"""Base classes for workflow step types. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class StepStatus(str, Enum): + """Status of a step execution.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + PAUSED = "paused" + + +class RunStatus(str, Enum): + """Status of a workflow run.""" + + CREATED = "created" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + FAILED = "failed" + ABORTED = "aborted" + + +@dataclass +class StepContext: + """Execution context passed to each step. + + Contains everything the step needs to resolve expressions, dispatch + commands, and record results. + """ + + #: Resolved workflow inputs (from user prompts / defaults). + inputs: dict[str, Any] = field(default_factory=dict) + + #: Accumulated step results keyed by step ID. + #: Each entry is ``{"integration": ..., "model": ..., "options": ..., + #: "input": ..., "output": ...}``. + steps: dict[str, dict[str, Any]] = field(default_factory=dict) + + #: Current fan-out item (set only inside fan-out iterations). + item: Any = None + + #: Fan-in aggregated results (set only for fan-in steps). + fan_in: dict[str, Any] = field(default_factory=dict) + + #: Workflow-level default integration key. + default_integration: str | None = None + + #: Workflow-level default model. + default_model: str | None = None + + #: Workflow-level default options. + default_options: dict[str, Any] = field(default_factory=dict) + + #: Project root path. + project_root: str | None = None + + #: Current run ID. + run_id: str | None = None + + +@dataclass +class StepResult: + """Return value from a step execution.""" + + #: Step status. + status: StepStatus = StepStatus.COMPLETED + + #: Output data (stored as ``steps..output``). + output: dict[str, Any] = field(default_factory=dict) + + #: Nested steps to execute (for control-flow steps like if/then). + next_steps: list[dict[str, Any]] = field(default_factory=list) + + #: Error message if step failed. + error: str | None = None + + +class StepBase(ABC): + """Abstract base class for workflow step types. + + Every step type — built-in or extension-provided — implements this + interface and registers in ``STEP_REGISTRY``. + """ + + #: Matches the ``type:`` value in workflow YAML. + type_key: str = "" + + @abstractmethod + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + """Execute the step with the given config and context. + + Parameters + ---------- + config: + The step configuration from workflow YAML. + context: + The execution context with inputs, accumulated step results, etc. + + Returns + ------- + StepResult with status, output data, and optional nested steps. + """ + + def validate(self, config: dict[str, Any]) -> list[str]: + """Validate step configuration and return a list of error messages. + + An empty list means the configuration is valid. + """ + errors: list[str] = [] + if "id" not in config: + errors.append("Step is missing required 'id' field.") + return errors + + def can_resume(self, state: dict[str, Any]) -> bool: + """Return whether this step can be resumed from the given state.""" + return True diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py new file mode 100644 index 0000000000..da5c60b5c8 --- /dev/null +++ b/src/specify_cli/workflows/catalog.py @@ -0,0 +1,540 @@ +"""Workflow catalog — discovery, install, and management of workflows. + +Mirrors the existing extension/preset catalog pattern with: +- Multi-catalog stack (env var → project → user → built-in) +- SHA256-hashed per-URL caching with 1-hour TTL +- Workflow registry for installed workflow tracking +- Search across all configured catalog sources +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class WorkflowCatalogError(Exception): + """Base error for workflow catalog operations.""" + + +class WorkflowValidationError(WorkflowCatalogError): + """Validation error for catalog config or workflow data.""" + + +# --------------------------------------------------------------------------- +# CatalogEntry +# --------------------------------------------------------------------------- + + +@dataclass +class WorkflowCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# WorkflowRegistry +# --------------------------------------------------------------------------- + + +class WorkflowRegistry: + """Manages the registry of installed workflows. + + Tracks installed workflows and their metadata in + ``.specify/workflows/workflow-registry.json``. + """ + + REGISTRY_FILE = "workflow-registry.json" + SCHEMA_VERSION = "1.0" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.registry_path = self.workflows_dir / self.REGISTRY_FILE + self.data = self._load() + + 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): + # Corrupted registry file — reset to default + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + + def save(self) -> None: + """Persist registry to disk.""" + self.workflows_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=2) + + def add(self, workflow_id: str, metadata: dict[str, Any]) -> None: + """Add or update an installed workflow entry.""" + from datetime import datetime, timezone + + existing = self.data["workflows"].get(workflow_id, {}) + metadata["installed_at"] = existing.get( + "installed_at", datetime.now(timezone.utc).isoformat() + ) + metadata["updated_at"] = datetime.now(timezone.utc).isoformat() + self.data["workflows"][workflow_id] = metadata + self.save() + + def remove(self, workflow_id: str) -> bool: + """Remove an installed workflow entry. Returns True if found.""" + if workflow_id in self.data["workflows"]: + del self.data["workflows"][workflow_id] + self.save() + return True + return False + + def get(self, workflow_id: str) -> dict[str, Any] | None: + """Get metadata for an installed workflow.""" + return self.data["workflows"].get(workflow_id) + + def list(self) -> dict[str, dict[str, Any]]: + """Return all installed workflows.""" + return dict(self.data["workflows"]) + + def is_installed(self, workflow_id: str) -> bool: + """Check if a workflow is installed.""" + return workflow_id in self.data["workflows"] + + +# --------------------------------------------------------------------------- +# WorkflowCatalog +# --------------------------------------------------------------------------- + + +class WorkflowCatalog: + """Manages workflow catalog fetching, caching, and searching. + + Resolution order for catalog sources: + 1. ``SPECKIT_WORKFLOW_CATALOG_URL`` env var (overrides all) + 2. Project-level ``.specify/workflow-catalogs.yml`` + 3. User-level ``~/.specify/workflow-catalogs.yml`` + 4. Built-in defaults (official + community) + """ + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.cache_dir = self.workflows_dir / ".cache" + + # -- Catalog resolution ----------------------------------------------- + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise WorkflowValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config( + self, config_path: Path + ) -> list[WorkflowCatalogEntry] | None: + """Load catalog stack configuration from a YAML file.""" + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise WorkflowValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + # Empty catalogs list (e.g. after removing last entry) + # is valid — fall back to built-in defaults. + return None + if not isinstance(catalogs_data, list): + raise WorkflowValidationError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + + entries: list[WorkflowCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise WorkflowValidationError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise WorkflowValidationError( + f"Invalid priority for catalog " + f"'{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ( + "true", + "yes", + "1", + ) + else: + install_allowed = bool(raw_install) + entries.append( + WorkflowCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise WorkflowValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs." + ) + return entries + + def get_active_catalogs(self) -> list[WorkflowCatalogEntry]: + """Get the ordered list of active catalogs.""" + # 1. Environment variable override + env_url = os.environ.get("SPECKIT_WORKFLOW_CATALOG_URL", "").strip() + if env_url: + self._validate_catalog_url(env_url) + return [ + WorkflowCatalogEntry( + url=env_url, + name="env-override", + priority=1, + install_allowed=True, + description="From SPECKIT_WORKFLOW_CATALOG_URL", + ) + ] + + # 2. Project-level config + project_config = self.project_root / ".specify" / "workflow-catalogs.yml" + project_entries = self._load_catalog_config(project_config) + if project_entries is not None: + return project_entries + + # 3. User-level config + home = Path.home() + user_config = home / ".specify" / "workflow-catalogs.yml" + user_entries = self._load_catalog_config(user_config) + if user_entries is not None: + return user_entries + + # 4. Built-in defaults + return [ + WorkflowCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Official workflows", + ), + WorkflowCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed workflows (discovery only)", + ), + ] + + # -- Caching ---------------------------------------------------------- + + def _get_cache_paths(self, url: str) -> tuple[Path, Path]: + """Get cache file paths for a URL (hash-based).""" + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"workflow-catalog-{url_hash}.json" + meta_file = self.cache_dir / f"workflow-catalog-{url_hash}-meta.json" + return cache_file, meta_file + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached data for a URL is still fresh.""" + _, meta_file = self._get_cache_paths(url) + if not meta_file.exists(): + return False + try: + with open(meta_file, encoding="utf-8") as f: + meta = json.load(f) + fetched_at = meta.get("fetched_at", 0) + return (time.time() - fetched_at) < self.CACHE_DURATION + except (json.JSONDecodeError, OSError): + return False + + def _fetch_single_catalog( + self, entry: WorkflowCatalogEntry, force_refresh: bool = False + ) -> dict[str, Any]: + """Fetch a single catalog, using cache when possible.""" + cache_file, meta_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + + # Fetch from URL — validate scheme before opening and after redirects + from urllib.parse import urlparse + from urllib.request import urlopen + + def _validate_catalog_url(url: str) -> None: + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowCatalogError( + f"Refusing to fetch catalog from non-HTTPS URL: {url}" + ) + + _validate_catalog_url(entry.url) + + try: + with urlopen(entry.url, timeout=30) as resp: # noqa: S310 + _validate_catalog_url(resp.geturl()) + data = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + # Fall back to cache if available + if cache_file.exists(): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError, OSError): + pass + raise WorkflowCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise WorkflowCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + + # Write cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + with open(meta_file, "w", encoding="utf-8") as f: + json.dump({"url": entry.url, "fetched_at": time.time()}, f) + + return data + + def _get_merged_workflows( + self, force_refresh: bool = False + ) -> dict[str, dict[str, Any]]: + """Merge workflows from all active catalogs (lower priority number wins).""" + catalogs = self.get_active_catalogs() + merged: dict[str, dict[str, Any]] = {} + fetch_errors = 0 + + # Process later/higher-numbered entries first so earlier/lower-numbered + # entries overwrite them on workflow ID conflicts. + for entry in reversed(catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + except WorkflowCatalogError: + fetch_errors += 1 + continue + workflows = data.get("workflows", {}) + # Handle both dict and list formats + if isinstance(workflows, dict): + for wf_id, wf_data in workflows.items(): + if not isinstance(wf_data, dict): + continue + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + elif isinstance(workflows, list): + for wf_data in workflows: + if not isinstance(wf_data, dict): + continue + wf_id = wf_data.get("id", "") + if wf_id: + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + if fetch_errors == len(catalogs) and catalogs: + raise WorkflowCatalogError( + "All configured catalogs failed to fetch." + ) + return merged + + # -- Public API ------------------------------------------------------- + + def search( + self, + query: str | None = None, + tag: str | None = None, + ) -> list[dict[str, Any]]: + """Search workflows across all configured catalogs.""" + merged = self._get_merged_workflows() + results: list[dict[str, Any]] = [] + + for wf_id, wf_data in merged.items(): + wf_data.setdefault("id", wf_id) + if query: + q = query.lower() + searchable = " ".join( + [ + wf_data.get("name", ""), + wf_data.get("description", ""), + wf_data.get("id", ""), + ] + ).lower() + if q not in searchable: + continue + if tag: + raw_tags = wf_data.get("tags", []) + tags = raw_tags if isinstance(raw_tags, list) else [] + normalized_tags = [t.lower() for t in tags if isinstance(t, str)] + if tag.lower() not in normalized_tags: + continue + results.append(wf_data) + return results + + def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None: + """Get details for a specific workflow from the catalog.""" + merged = self._get_merged_workflows() + wf = merged.get(workflow_id) + if wf: + wf.setdefault("id", workflow_id) + return wf + + def get_catalog_configs(self) -> list[dict[str, Any]]: + """Return current catalog configuration as a list of dicts.""" + entries = self.get_active_catalogs() + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: str | None = None) -> None: + """Add a catalog source to the project-level config.""" + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + + 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 WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + # Check for duplicate URL (guard against non-dict entries) + for cat in catalogs: + if isinstance(cat, dict) and cat.get("url") == url: + raise WorkflowValidationError( + f"Catalog URL already configured: {url}" + ) + + # Derive priority from the highest existing priority + 1 + max_priority = max( + (cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)), + default=0, + ) + catalogs.append( + { + "name": name or f"catalog-{len(catalogs) + 1}", + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + 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" / "workflow-catalogs.yml" + if not config_path.exists(): + raise WorkflowValidationError("No catalog config file found.") + + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + + if index < 0 or index >= len(catalogs): + raise WorkflowValidationError( + f"Catalog index {index} out of range (0-{len(catalogs) - 1})." + ) + + removed = catalogs.pop(index) + data["catalogs"] = catalogs + + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + if isinstance(removed, dict): + return removed.get("name", f"catalog-{index + 1}") + return f"catalog-{index + 1}" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py new file mode 100644 index 0000000000..d6a73bbeb0 --- /dev/null +++ b/src/specify_cli/workflows/engine.py @@ -0,0 +1,778 @@ +"""Workflow engine — loads, validates, and executes workflow YAML definitions. + +The engine is the orchestrator that: +- Parses workflow YAML definitions +- Validates step configurations and requirements +- Executes steps sequentially, dispatching to the correct step type +- Manages state persistence for resume capability +- Handles control flow (branching, loops, fan-out/fan-in) +""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .base import RunStatus, StepContext, StepResult, StepStatus + + +# -- Workflow Definition -------------------------------------------------- + + +class WorkflowDefinition: + """Parsed and validated workflow YAML definition.""" + + def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> None: + self.data = data + self.source_path = source_path + + workflow = data.get("workflow", {}) + self.id: str = workflow.get("id", "") + self.name: str = workflow.get("name", "") + self.version: str = workflow.get("version", "0.0.0") + self.author: str = workflow.get("author", "") + self.description: str = workflow.get("description", "") + self.schema_version: str = data.get("schema_version", "1.0") + + # Defaults + self.default_integration: str | None = workflow.get("integration") + self.default_model: str | None = workflow.get("model") + self.default_options: dict[str, Any] = workflow.get("options") or {} + if not isinstance(self.default_options, dict): + self.default_options = {} + + # Requirements (declared but not yet enforced at runtime; + # enforcement is a planned enhancement) + self.requires: dict[str, Any] = data.get("requires", {}) + + # Inputs + self.inputs: dict[str, Any] = data.get("inputs", {}) + + # Steps + self.steps: list[dict[str, Any]] = data.get("steps", []) + + @classmethod + def from_yaml(cls, path: Path) -> WorkflowDefinition: + """Load a workflow definition from a YAML file.""" + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data, source_path=path) + + @classmethod + def from_string(cls, content: str) -> WorkflowDefinition: + """Load a workflow definition from a YAML string.""" + data = yaml.safe_load(content) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data) + + +# -- Workflow Validation -------------------------------------------------- + +# ID format: lowercase alphanumeric with hyphens +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") + +# Valid step types (matching STEP_REGISTRY keys) +def _get_valid_step_types() -> set[str]: + """Return valid step types from the registry, with a built-in fallback.""" + from . import STEP_REGISTRY + if STEP_REGISTRY: + return set(STEP_REGISTRY.keys()) + return { + "command", "shell", "prompt", "gate", "if", + "switch", "while", "do-while", "fan-out", "fan-in", + } + + +def validate_workflow(definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition and return a list of error messages. + + An empty list means the workflow is valid. + """ + errors: list[str] = [] + + # -- Schema version --------------------------------------------------- + if definition.schema_version not in ("1.0", "1"): + errors.append( + f"Unsupported schema_version {definition.schema_version!r}. " + f"Expected '1.0'." + ) + + # -- Top-level fields ------------------------------------------------- + if not definition.id: + errors.append("Workflow is missing 'workflow.id'.") + elif not _ID_PATTERN.match(definition.id): + errors.append( + f"Workflow ID {definition.id!r} must be lowercase alphanumeric " + f"with hyphens." + ) + + if not definition.name: + errors.append("Workflow is missing 'workflow.name'.") + + if not definition.version: + errors.append("Workflow is missing 'workflow.version'.") + elif not re.match(r"^\d+\.\d+\.\d+$", definition.version): + errors.append( + f"Workflow version {definition.version!r} is not valid " + f"semantic versioning (expected X.Y.Z)." + ) + + # -- Inputs ----------------------------------------------------------- + if not isinstance(definition.inputs, dict): + errors.append("'inputs' must be a mapping (or omitted).") + else: + for input_name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + errors.append(f"Input {input_name!r} must be a mapping.") + continue + input_type = input_def.get("type") + if input_type and input_type not in ("string", "number", "boolean"): + errors.append( + f"Input {input_name!r} has invalid type {input_type!r}. " + f"Must be 'string', 'number', or 'boolean'." + ) + + # -- Steps ------------------------------------------------------------ + if not isinstance(definition.steps, list): + errors.append("'steps' must be a list.") + return errors + if not definition.steps: + errors.append("Workflow has no steps defined.") + + seen_ids: set[str] = set() + _validate_steps(definition.steps, seen_ids, errors) + + return errors + + +def _validate_steps( + steps: list[dict[str, Any]], + seen_ids: set[str], + errors: list[str], +) -> None: + """Recursively validate a list of steps.""" + from . import STEP_REGISTRY + + for step_config in steps: + if not isinstance(step_config, dict): + errors.append(f"Step must be a mapping, got {type(step_config).__name__}.") + continue + + step_id = step_config.get("id") + if not step_id: + errors.append("Step is missing 'id' field.") + continue + + if ":" in step_id: + errors.append( + f"Step ID {step_id!r} contains ':' which is reserved " + f"for engine-generated nested IDs (parentId:childId)." + ) + + if step_id in seen_ids: + errors.append(f"Duplicate step ID {step_id!r}.") + seen_ids.add(step_id) + + # Determine step type + step_type = step_config.get("type", "command") + if step_type not in _get_valid_step_types(): + errors.append( + f"Step {step_id!r} has invalid type {step_type!r}." + ) + continue + + # Delegate to step-specific validation + step_impl = STEP_REGISTRY.get(step_type) + if step_impl: + step_errors = step_impl.validate(step_config) + errors.extend(step_errors) + + # Recursively validate nested steps + for nested_key in ("then", "else", "steps"): + nested = step_config.get(nested_key) + if isinstance(nested, list): + _validate_steps(nested, seen_ids, errors) + + # Validate switch cases + cases = step_config.get("cases") + if isinstance(cases, dict): + for _case_key, case_steps in cases.items(): + if isinstance(case_steps, list): + _validate_steps(case_steps, seen_ids, errors) + + # Validate switch default + default = step_config.get("default") + if isinstance(default, list): + _validate_steps(default, seen_ids, errors) + + # Validate fan-out nested step (template — not added to seen_ids + # since the engine generates parentId:templateId:index at runtime) + fan_step = step_config.get("step") + if isinstance(fan_step, dict): + fan_errors: list[str] = [] + _validate_steps([fan_step], set(), fan_errors) + errors.extend(fan_errors) + + +# -- Run State Persistence ------------------------------------------------ + + +class RunState: + """Manages workflow run state for persistence and resume.""" + + def __init__( + self, + run_id: str | None = None, + workflow_id: str = "", + project_root: Path | None = None, + ) -> None: + self.run_id = run_id or str(uuid.uuid4())[:8] + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id): + msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only." + raise ValueError(msg) + self.workflow_id = workflow_id + self.project_root = project_root or Path(".") + self.status = RunStatus.CREATED + self.current_step_index = 0 + self.current_step_id: str | None = None + self.step_results: dict[str, dict[str, Any]] = {} + self.inputs: dict[str, Any] = {} + self.created_at = datetime.now(timezone.utc).isoformat() + self.updated_at = self.created_at + self.log_entries: list[dict[str, Any]] = [] + + @property + def runs_dir(self) -> Path: + return self.project_root / ".specify" / "workflows" / "runs" / self.run_id + + def save(self) -> None: + """Persist current state to disk.""" + self.updated_at = datetime.now(timezone.utc).isoformat() + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + + state_data = { + "run_id": self.run_id, + "workflow_id": self.workflow_id, + "status": self.status.value, + "current_step_index": self.current_step_index, + "current_step_id": self.current_step_id, + "step_results": self.step_results, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + with open(runs_dir / "state.json", "w", encoding="utf-8") as f: + json.dump(state_data, f, indent=2) + + inputs_data = {"inputs": self.inputs} + with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f: + json.dump(inputs_data, f, indent=2) + + @classmethod + def load(cls, run_id: str, project_root: Path) -> RunState: + """Load a run state from disk.""" + runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id + state_path = runs_dir / "state.json" + if not state_path.exists(): + msg = f"Run state not found: {state_path}" + raise FileNotFoundError(msg) + + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + + state = cls( + run_id=state_data["run_id"], + workflow_id=state_data["workflow_id"], + project_root=project_root, + ) + state.status = RunStatus(state_data["status"]) + state.current_step_index = state_data.get("current_step_index", 0) + state.current_step_id = state_data.get("current_step_id") + state.step_results = state_data.get("step_results", {}) + state.created_at = state_data.get("created_at", "") + state.updated_at = state_data.get("updated_at", "") + + inputs_path = runs_dir / "inputs.json" + if inputs_path.exists(): + with open(inputs_path, encoding="utf-8") as f: + inputs_data = json.load(f) + state.inputs = inputs_data.get("inputs", {}) + + return state + + def append_log(self, entry: dict[str, Any]) -> None: + """Append a log entry to the run log.""" + entry["timestamp"] = datetime.now(timezone.utc).isoformat() + self.log_entries.append(entry) + + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +# -- Workflow Engine ------------------------------------------------------ + + +class WorkflowEngine: + """Orchestrator that loads, validates, and executes workflow definitions.""" + + def __init__(self, project_root: Path | None = None) -> None: + self.project_root = project_root or Path(".") + self.on_step_start: Any = None # Callable[[str, str], None] | None + + def load_workflow(self, source: str | Path) -> WorkflowDefinition: + """Load a workflow from an installed ID or a local YAML path. + + Parameters + ---------- + source: + Either a workflow ID (looked up in the installed workflows + directory) or a path to a YAML file. + + Returns + ------- + A parsed ``WorkflowDefinition`` (not yet validated; call + ``validate_workflow()`` or ``engine.validate()`` separately). + + Raises + ------ + FileNotFoundError: + If the workflow file cannot be found. + ValueError: + If the workflow YAML is invalid. + """ + path = Path(source) + + # Try as a direct file path first + if path.suffix in (".yml", ".yaml") and path.exists(): + return WorkflowDefinition.from_yaml(path) + + # Try as an installed workflow ID + installed_path = ( + self.project_root + / ".specify" + / "workflows" + / str(source) + / "workflow.yml" + ) + if installed_path.exists(): + return WorkflowDefinition.from_yaml(installed_path) + + msg = f"Workflow not found: {source}" + raise FileNotFoundError(msg) + + def validate(self, definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition.""" + return validate_workflow(definition) + + def execute( + self, + definition: WorkflowDefinition, + inputs: dict[str, Any] | None = None, + run_id: str | None = None, + ) -> RunState: + """Execute a workflow definition. + + Parameters + ---------- + definition: + The validated workflow definition. + inputs: + User-provided input values. + run_id: + Optional run ID (auto-generated if not provided). + + Returns + ------- + The final ``RunState`` after execution completes (or pauses). + """ + from . import STEP_REGISTRY + + state = RunState( + run_id=run_id, + workflow_id=definition.id, + project_root=self.project_root, + ) + + # Persist a copy of the workflow definition so resume can + # reload it even if the original source is no longer available + # (e.g. a local YAML path that was moved or deleted). + run_dir = self.project_root / ".specify" / "workflows" / "runs" / state.run_id + run_dir.mkdir(parents=True, exist_ok=True) + workflow_copy = run_dir / "workflow.yml" + import yaml + with open(workflow_copy, "w", encoding="utf-8") as f: + yaml.safe_dump(definition.data, f, sort_keys=False) + + # Resolve inputs + resolved_inputs = self._resolve_inputs(definition, inputs or {}) + state.inputs = resolved_inputs + state.status = RunStatus.RUNNING + state.save() + + context = StepContext( + inputs=resolved_inputs, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + # Execute steps + try: + self._execute_steps(definition.steps, context, state, STEP_REGISTRY) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "workflow_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def resume(self, run_id: str) -> RunState: + """Resume a paused or failed workflow run.""" + state = RunState.load(run_id, self.project_root) + if state.status not in (RunStatus.PAUSED, RunStatus.FAILED): + msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}." + raise ValueError(msg) + + # Load the workflow definition — try the persisted copy in the + # run directory first so resume works even if the original + # source (e.g. a local YAML path) is no longer available. + run_dir = self.project_root / ".specify" / "workflows" / "runs" / run_id + run_copy = run_dir / "workflow.yml" + if run_copy.exists(): + definition = WorkflowDefinition.from_yaml(run_copy) + else: + definition = self.load_workflow(state.workflow_id) + + # Restore context + context = StepContext( + inputs=state.inputs, + steps=state.step_results, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + from . import STEP_REGISTRY + + state.status = RunStatus.RUNNING + state.save() + + # Resume from the current step — re-execute it so gates + # can prompt interactively again. + remaining_steps = definition.steps[state.current_step_index :] + step_offset = state.current_step_index + + try: + self._execute_steps( + remaining_steps, context, state, STEP_REGISTRY, + step_offset=step_offset, + ) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "resume_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def _execute_steps( + self, + steps: list[dict[str, Any]], + context: StepContext, + state: RunState, + registry: dict[str, Any], + *, + step_offset: int = 0, + ) -> None: + """Execute a list of steps sequentially.""" + for i, step_config in enumerate(steps): + step_id = step_config.get("id", f"step-{i}") + step_type = step_config.get("type", "command") + + state.current_step_id = step_id + if step_offset >= 0: + state.current_step_index = step_offset + i + state.save() + + state.append_log( + {"event": "step_started", "step_id": step_id, "type": step_type} + ) + + # Log progress — use the engine's on_step_start callback if set, + # otherwise stay silent (library-safe default). + label = step_config.get("command", "") or step_type + if self.on_step_start is not None: + self.on_step_start(step_id, label) + + step_impl = registry.get(step_type) + if not step_impl: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": f"Unknown step type: {step_type!r}", + } + ) + state.save() + return + + result: StepResult = step_impl.execute(step_config, context) + + # Record step results — prefer resolved values from step output + step_data = { + "integration": result.output.get("integration") + or step_config.get("integration") + or context.default_integration, + "model": result.output.get("model") + or step_config.get("model") + or context.default_model, + "options": result.output.get("options") + or step_config.get("options", {}), + "input": result.output.get("input") + or step_config.get("input", {}), + "output": result.output, + "status": result.status.value, + } + context.steps[step_id] = step_data + state.step_results[step_id] = step_data + + state.append_log( + { + "event": "step_completed", + "step_id": step_id, + "status": result.status.value, + } + ) + + # Handle gate pauses + if result.status == StepStatus.PAUSED: + state.status = RunStatus.PAUSED + state.save() + return + + # Handle failures + if result.status == StepStatus.FAILED: + # Gate abort (output.aborted) maps to ABORTED status + if result.output.get("aborted"): + state.status = RunStatus.ABORTED + state.append_log( + { + "event": "workflow_aborted", + "step_id": step_id, + } + ) + else: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": result.error, + } + ) + state.save() + return + + # Execute nested steps (from control flow) + # NOTE: Nested steps run with step_offset=-1 so they don't + # update current_step_index. If a nested step pauses, + # resume will re-run the parent step and its nested body. + # A step-path stack for exact nested resume is a future + # enhancement. + if result.next_steps: + self._execute_steps( + result.next_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Loop iteration: while/do-while re-evaluate after body + if step_type in ("while", "do-while"): + from .expressions import evaluate_condition + + max_iters = step_config.get("max_iterations") + if not isinstance(max_iters, int) or max_iters < 1: + max_iters = 10 + condition = step_config.get("condition", False) + for _loop_iter in range(max_iters - 1): + if not evaluate_condition(condition, context): + break + # Namespace nested step IDs per iteration + iter_steps = [] + for ns in result.next_steps: + ns_copy = dict(ns) + if "id" in ns_copy: + ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" + iter_steps.append(ns_copy) + self._execute_steps( + iter_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Fan-out: execute nested step template per item with unique IDs + if step_type == "fan-out": + items = result.output.get("items", []) + template = result.output.get("step_template", {}) + if template and items: + fan_out_results = [] + for item_idx, item_val in enumerate(result.output["items"]): + context.item = item_val + # Per-item ID: parentId:templateId:index + item_step = dict(template) + base_id = item_step.get("id", "item") + item_step["id"] = f"{step_id}:{base_id}:{item_idx}" + self._execute_steps( + [item_step], context, state, registry, + step_offset=-1, + ) + # Collect per-item result for fan-in + item_result = context.steps.get(item_step["id"], {}) + fan_out_results.append(item_result.get("output", {})) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + break + context.item = None + # Preserve original output and add collected results + fan_out_output = dict(result.output) + fan_out_output["results"] = fan_out_results + context.steps[step_id]["output"] = fan_out_output + state.step_results[step_id]["output"] = fan_out_output + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + else: + # Empty items or no template — normalize output + result.output["results"] = [] + context.steps[step_id]["output"] = result.output + state.step_results[step_id]["output"] = result.output + + def _resolve_inputs( + self, + definition: WorkflowDefinition, + provided: dict[str, Any], + ) -> dict[str, Any]: + """Resolve workflow inputs against definitions and provided values.""" + resolved: dict[str, Any] = {} + for name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + continue + if name in provided: + resolved[name] = self._coerce_input( + name, provided[name], input_def + ) + elif "default" in input_def: + resolved[name] = input_def["default"] + elif input_def.get("required", False): + msg = f"Required input {name!r} not provided." + raise ValueError(msg) + return resolved + + @staticmethod + def _coerce_input( + name: str, value: Any, input_def: dict[str, Any] + ) -> Any: + """Coerce a provided input value to the declared type.""" + input_type = input_def.get("type", "string") + enum_values = input_def.get("enum") + + if input_type == "number": + try: + value = float(value) + if value == int(value): + value = int(value) + except (ValueError, TypeError): + msg = f"Input {name!r} expected a number, got {value!r}." + raise ValueError(msg) from None + elif input_type == "boolean": + if isinstance(value, str): + if value.lower() in ("true", "1", "yes"): + value = True + elif value.lower() in ("false", "0", "no"): + value = False + else: + msg = f"Input {name!r} expected a boolean, got {value!r}." + raise ValueError(msg) + + if enum_values is not None and value not in enum_values: + msg = ( + f"Input {name!r} value {value!r} not in allowed " + f"values: {enum_values}." + ) + raise ValueError(msg) + + return value + + def list_runs(self) -> list[dict[str, Any]]: + """List all workflow runs in the project.""" + runs_dir = self.project_root / ".specify" / "workflows" / "runs" + if not runs_dir.exists(): + return [] + + runs: list[dict[str, Any]] = [] + for run_dir in sorted(runs_dir.iterdir()): + if not run_dir.is_dir(): + continue + state_path = run_dir / "state.json" + if state_path.exists(): + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + runs.append(state_data) + return runs + + +class WorkflowAbortError(Exception): + """Raised when a workflow is aborted (e.g., gate rejection).""" diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py new file mode 100644 index 0000000000..3a2d3fbf2a --- /dev/null +++ b/src/specify_cli/workflows/expressions.py @@ -0,0 +1,300 @@ +"""Sandboxed expression evaluator for workflow templates. + +Provides a safe Jinja2 subset for evaluating expressions in workflow YAML. +No file I/O, no imports, no arbitrary code execution. +""" + +from __future__ import annotations + +import re +from typing import Any + + +# -- Custom filters ------------------------------------------------------- + +def _filter_default(value: Any, default_value: Any = "") -> Any: + """Return *default_value* when *value* is ``None`` or empty string.""" + if value is None or value == "": + return default_value + return value + + +def _filter_join(value: Any, separator: str = ", ") -> str: + """Join a list into a string with *separator*.""" + if isinstance(value, list): + return separator.join(str(v) for v in value) + return str(value) + + +def _filter_map(value: Any, attr: str) -> list[Any]: + """Map a list of dicts to a specific attribute.""" + if isinstance(value, list): + result = [] + for item in value: + if isinstance(item, dict): + # Support dot notation: "result.status" → item["result"]["status"] + parts = attr.split(".") + v = item + for part in parts: + if isinstance(v, dict): + v = v.get(part) + else: + v = None + break + result.append(v) + else: + result.append(item) + return result + return [] + + +def _filter_contains(value: Any, substring: str) -> bool: + """Check if a string or list contains *substring*.""" + if isinstance(value, str): + return substring in value + if isinstance(value, list): + return substring in value + return False + + +# -- Expression resolution ------------------------------------------------ + +_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") + + +def _resolve_dot_path(obj: Any, path: str) -> Any: + """Resolve a dotted path like ``steps.specify.output.file`` against *obj*. + + Supports dict key access and list indexing (e.g., ``task_list[0]``). + """ + parts = path.split(".") + current = obj + for part in parts: + # Handle list indexing: name[0] + idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part) + if idx_match: + key, idx = idx_match.group(1), int(idx_match.group(2)) + if isinstance(current, dict): + current = current.get(key) + else: + return None + if isinstance(current, list) and 0 <= idx < len(current): + current = current[idx] + else: + return None + elif isinstance(current, dict): + current = current.get(part) + else: + return None + if current is None: + return None + return current + + +def _build_namespace(context: Any) -> dict[str, Any]: + """Build the variable namespace from a StepContext.""" + ns: dict[str, Any] = {} + if hasattr(context, "inputs"): + ns["inputs"] = context.inputs or {} + if hasattr(context, "steps"): + ns["steps"] = context.steps or {} + if hasattr(context, "item"): + ns["item"] = context.item + if hasattr(context, "fan_in"): + ns["fan_in"] = context.fan_in or {} + return ns + + +def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: + """Evaluate a simple expression against the namespace. + + Supports: + - Dot-path access: ``steps.specify.output.file`` + - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` + - Boolean operators: ``and``, ``or``, ``not`` + - ``in``, ``not in`` + - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')`` + - String and numeric literals + """ + expr = expr.strip() + + # String literal — check before pipes and operators so quoted strings + # containing | or operator keywords are not mis-parsed. + if (expr.startswith("'") and expr.endswith("'")) or ( + expr.startswith('"') and expr.endswith('"') + ): + return expr[1:-1] + + # Handle pipe filters + if "|" in expr: + parts = expr.split("|", 1) + value = _evaluate_simple_expression(parts[0].strip(), namespace) + filter_expr = parts[1].strip() + + # Parse filter name and argument + filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) + if filter_match: + fname = filter_match.group(1) + farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) + if fname == "default": + return _filter_default(value, farg) + if fname == "join": + return _filter_join(value, farg) + if fname == "map": + return _filter_map(value, farg) + if fname == "contains": + return _filter_contains(value, farg) + # Filter without args + filter_name = filter_expr.strip() + if filter_name == "default": + return _filter_default(value) + return value + + # Boolean operators — parse 'or' first (lower precedence) so that + # 'a or b and c' is evaluated as 'a or (b and c)'. + if " or " in expr: + parts = expr.split(" or ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) or bool(right) + + if " and " in expr: + parts = expr.split(" and ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) and bool(right) + + if expr.startswith("not "): + inner = _evaluate_simple_expression(expr[4:].strip(), namespace) + return not bool(inner) + + # Comparison operators (order matters — check multi-char ops first) + for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): + if op in expr: + parts = expr.split(op, 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + if op == "==": + return left == right + if op == "!=": + return left != right + if op == ">": + return _safe_compare(left, right, ">") + if op == "<": + return _safe_compare(left, right, "<") + if op == ">=": + return _safe_compare(left, right, ">=") + if op == "<=": + return _safe_compare(left, right, "<=") + if op == " in ": + return left in right if right is not None else False + if op == " not in ": + return left not in right if right is not None else True + + # Numeric literal + try: + if "." in expr: + return float(expr) + return int(expr) + except (ValueError, TypeError): + pass + + # Boolean literal + if expr.lower() == "true": + return True + if expr.lower() == "false": + return False + + # Null + if expr.lower() in ("none", "null"): + return None + + # List literal (simple) + if expr.startswith("[") and expr.endswith("]"): + inner = expr[1:-1].strip() + if not inner: + return [] + items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")] + return items + + # Variable reference (dot-path) + return _resolve_dot_path(namespace, expr) + + +def _safe_compare(left: Any, right: Any, op: str) -> bool: + """Safely compare two values, coercing types when possible.""" + try: + if isinstance(left, str): + left = float(left) if "." in left else int(left) + if isinstance(right, str): + right = float(right) if "." in right else int(right) + except (ValueError, TypeError): + return False + try: + if op == ">": + return left > right # type: ignore[operator] + if op == "<": + return left < right # type: ignore[operator] + if op == ">=": + return left >= right # type: ignore[operator] + if op == "<=": + return left <= right # type: ignore[operator] + except TypeError: + return False + return False + + +def evaluate_expression(template: str, context: Any) -> Any: + """Evaluate a template string with ``{{ ... }}`` expressions. + + If the entire string is a single expression, returns the raw value + (preserving type). Otherwise, substitutes each expression inline + and returns a string. + + Parameters + ---------- + template: + The template string (e.g., ``"{{ steps.plan.output.task_count }}"`` + or ``"Processed {{ inputs.feature_name }}"``. + context: + A ``StepContext`` or compatible object. + + Returns + ------- + The resolved value (any type for single-expression templates, + string for multi-expression or mixed templates). + """ + if not isinstance(template, str): + return template + + namespace = _build_namespace(context) + + # Single expression: return typed value + match = _EXPR_PATTERN.fullmatch(template.strip()) + if match: + return _evaluate_simple_expression(match.group(1).strip(), namespace) + + # Multi-expression: string interpolation + def _replacer(m: re.Match[str]) -> str: + val = _evaluate_simple_expression(m.group(1).strip(), namespace) + return str(val) if val is not None else "" + + return _EXPR_PATTERN.sub(_replacer, template) + + +def evaluate_condition(condition: str, context: Any) -> bool: + """Evaluate a condition expression and return a boolean. + + Convenience wrapper around ``evaluate_expression`` that coerces + the result to bool. + """ + result = evaluate_expression(condition, context) + # Treat plain "false"/"true" strings as booleans so that + # condition: "false" (without {{ }}) behaves as expected. + if isinstance(result, str): + lower = result.lower() + if lower == "false": + return False + if lower == "true": + return True + return bool(result) diff --git a/src/specify_cli/workflows/steps/__init__.py b/src/specify_cli/workflows/steps/__init__.py new file mode 100644 index 0000000000..0aa9182dd0 --- /dev/null +++ b/src/specify_cli/workflows/steps/__init__.py @@ -0,0 +1 @@ +"""Auto-discovery for built-in step types.""" diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py new file mode 100644 index 0000000000..21fd4837d1 --- /dev/null +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -0,0 +1,155 @@ +"""Command step — dispatches a Spec Kit command to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class CommandStep(StepBase): + """Default step type — invokes a Spec Kit command via the integration CLI. + + The command files (skills, markdown, TOML) are already installed in + the integration's directory on disk. This step tells the CLI to + execute the command by name (e.g. ``/speckit.specify`` or + ``/speckit-specify``) rather than reading the file contents. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps (e.g. ``{{ steps.specify.output.exit_code }}``). + Full ``stdout``/``stderr`` capture is a planned enhancement. + """ + + type_key = "command" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + command = config.get("command", "") + input_data = config.get("input", {}) + + # Resolve expressions in input + resolved_input: dict[str, Any] = {} + for key, value in input_data.items(): + resolved_input[key] = evaluate_expression(value, context) + + # Resolve integration (step → workflow default → project default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Merge options (workflow defaults ← step overrides) + options = dict(context.default_options) + step_options = config.get("options", {}) + if step_options: + options.update(step_options) + + # Attempt CLI dispatch + args_str = str(resolved_input.get("args", "")) + dispatch_result = self._try_dispatch( + command, integration, model, args_str, context + ) + + output: dict[str, Any] = { + "command": command, + "integration": integration, + "model": model, + "options": options, + "input": resolved_input, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=dispatch_result["stderr"] or f"Command exited with code {dispatch_result['exit_code']}", + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch command {command!r}: " + f"integration {integration!r} CLI not found or not installed. " + f"Install the CLI tool or check 'specify integration list'." + ), + ) + + @staticmethod + def _try_dispatch( + command: str, + integration_key: str | None, + model: str | None, + args: str, + context: StepContext, + ) -> dict[str, Any] | None: + """Invoke *command* by name through the integration CLI. + + The integration's ``dispatch_command`` builds the native + slash-command invocation (e.g. ``/speckit.specify`` for + markdown agents, ``/speckit-specify`` for skills agents), + then executes the CLI non-interactively. + + Returns the dispatch result dict, or ``None`` if dispatch is + not possible (integration not found, CLI not installed, or + dispatch not supported). + """ + if not integration_key: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + # Check if the integration supports CLI dispatch + if impl.build_exec_args("test") is None: + return None + + # Check if the CLI tool is actually installed + if not shutil.which(impl.key): + return None + + project_root = Path(context.project_root) if context.project_root else None + + try: + return impl.dispatch_command( + command, + args=args, + project_root=project_root, + model=model, + ) + except (NotImplementedError, OSError): + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "command" not in config: + errors.append( + f"Command step {config.get('id', '?')!r} is missing 'command' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py new file mode 100644 index 0000000000..47a4d34437 --- /dev/null +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -0,0 +1,61 @@ +"""Do-While loop step — execute at least once, then repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus + + +class DoWhileStep(StepBase): + """Execute body at least once, then check condition. + + Continues while condition is truthy. ``max_iterations`` is an + optional safety cap (defaults to 10 if omitted). + + The first invocation always returns the nested steps for execution. + The engine re-evaluates ``step_config['condition']`` after each + iteration to decide whether to loop again. + """ + + type_key = "do-while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + condition = config.get("condition", "false") + + # Always execute body at least once; the engine layer evaluates + # `condition` after each iteration to decide whether to loop. + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition": condition, + "max_iterations": max_iterations, + "loop_type": "do-while", + }, + next_steps=nested_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"Do-while step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if not isinstance(max_iter, int) or max_iter < 1: + errors.append( + f"Do-while step {config.get('id', '?')!r}: " + f"'max_iterations' must be an integer >= 1." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_in/__init__.py b/src/specify_cli/workflows/steps/fan_in/__init__.py new file mode 100644 index 0000000000..dec3e3fd4d --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_in/__init__.py @@ -0,0 +1,61 @@ +"""Fan-in step — join point for parallel steps.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanInStep(StepBase): + """Join point that aggregates results from ``wait_for:`` steps. + + Reads completed step outputs from ``context.steps`` and collects + them into ``output.results``. Does not block; relies on the + engine executing steps sequentially. + """ + + type_key = "fan-in" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + wait_for = config.get("wait_for", []) + output_config = config.get("output") or {} + if not isinstance(output_config, dict): + output_config = {} + + # Collect results from referenced steps + results = [] + for step_id in wait_for: + step_data = context.steps.get(step_id, {}) + results.append(step_data.get("output", {})) + + # Resolve output expressions with fan_in in context + prev_fan_in = getattr(context, "fan_in", None) + context.fan_in = {"results": results} + resolved_output: dict[str, Any] = {"results": results} + + try: + for key, expr in output_config.items(): + if isinstance(expr, str) and "{{" in expr: + resolved_output[key] = evaluate_expression(expr, context) + else: + resolved_output[key] = expr + finally: + # Restore previous fan_in state even if evaluation fails + context.fan_in = prev_fan_in + + return StepResult( + status=StepStatus.COMPLETED, + output=resolved_output, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + wait_for = config.get("wait_for", []) + if not isinstance(wait_for, list) or not wait_for: + errors.append( + f"Fan-in step {config.get('id', '?')!r}: " + f"'wait_for' must be a non-empty list of step IDs." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/steps/fan_out/__init__.py new file mode 100644 index 0000000000..c2fff1face --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_out/__init__.py @@ -0,0 +1,58 @@ +"""Fan-out step — dispatch a step template over a collection.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanOutStep(StepBase): + """Dispatch a step template for each item in a collection. + + The engine executes the nested ``step:`` template once per item, + setting ``context.item`` for each iteration. Execution is + currently sequential; ``max_concurrency`` is accepted but not + enforced. + """ + + type_key = "fan-out" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + items_expr = config.get("items", "[]") + items = evaluate_expression(items_expr, context) + if not isinstance(items, list): + items = [] + + max_concurrency = config.get("max_concurrency", 1) + step_template = config.get("step", {}) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "items": items, + "max_concurrency": max_concurrency, + "step_template": step_template, + "item_count": len(items), + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "items" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'items' field." + ) + if "step" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'step' field (nested step template)." + ) + step = config.get("step") + if step is not None and not isinstance(step, dict): + errors.append( + f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping." + ) + return errors diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py new file mode 100644 index 0000000000..d4d32d763c --- /dev/null +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -0,0 +1,121 @@ +"""Gate step — human review gate.""" + +from __future__ import annotations + +import sys +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class GateStep(StepBase): + """Interactive review gate. + + When running in an interactive terminal, prompts the user to choose + an option (e.g. approve / reject). Falls back to ``PAUSED`` when + stdin is not a TTY (CI, piped input) so the run can be resumed + later with ``specify workflow resume``. + + The user's choice is stored in ``output.choice``. ``on_reject`` + controls abort / skip behaviour. + """ + + type_key = "gate" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + message = config.get("message", "Review required.") + if isinstance(message, str) and "{{" in message: + message = evaluate_expression(message, context) + + options = config.get("options", ["approve", "reject"]) + on_reject = config.get("on_reject", "abort") + + show_file = config.get("show_file") + if show_file and isinstance(show_file, str) and "{{" in show_file: + show_file = evaluate_expression(show_file, context) + + output = { + "message": message, + "options": options, + "on_reject": on_reject, + "show_file": show_file, + "choice": None, + } + + # Non-interactive: pause for later resume + if not sys.stdin.isatty(): + return StepResult(status=StepStatus.PAUSED, output=output) + + # Interactive: prompt the user + choice = self._prompt(message, options) + output["choice"] = choice + + if choice in ("reject", "abort"): + if on_reject == "abort": + output["aborted"] = True + return StepResult( + status=StepStatus.FAILED, + output=output, + error=f"Gate rejected by user at step {config.get('id', '?')!r}", + ) + if on_reject == "retry": + # Pause so the next resume re-executes this gate + return StepResult(status=StepStatus.PAUSED, output=output) + # on_reject == "skip" → completed, downstream steps decide + return StepResult(status=StepStatus.COMPLETED, output=output) + + return StepResult(status=StepStatus.COMPLETED, output=output) + + @staticmethod + def _prompt(message: str, options: list[str]) -> str: + """Display gate message and prompt for a choice.""" + print("\n ┌─ Gate ─────────────────────────────────────") + print(f" │ {message}") + print(" │") + for i, opt in enumerate(options, 1): + print(f" │ [{i}] {opt}") + print(" └────────────────────────────────────────────") + + while True: + try: + raw = input(f" Choose [1-{len(options)}]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return options[-1] # default to last (usually reject) + if raw.isdigit() and 1 <= int(raw) <= len(options): + return options[int(raw) - 1] + # Also accept the option name directly + if raw.lower() in [o.lower() for o in options]: + return next(o for o in options if o.lower() == raw.lower()) + print(f" Invalid choice. Enter 1-{len(options)} or an option name.") + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "message" not in config: + errors.append( + f"Gate step {config.get('id', '?')!r} is missing 'message' field." + ) + options = config.get("options", ["approve", "reject"]) + if not isinstance(options, list) or not options: + errors.append( + f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list." + ) + elif not all(isinstance(o, str) for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: all options must be strings." + ) + on_reject = config.get("on_reject", "abort") + if on_reject not in ("abort", "skip", "retry"): + errors.append( + f"Gate step {config.get('id', '?')!r}: 'on_reject' must be " + f"'abort', 'skip', or 'retry'." + ) + if on_reject in ("abort", "retry") and isinstance(options, list): + reject_choices = {"reject", "abort"} + if not any(o.lower() in reject_choices for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} " + f"but options has no 'reject' or 'abort' choice." + ) + return errors diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py new file mode 100644 index 0000000000..5b921a31a5 --- /dev/null +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -0,0 +1,55 @@ +"""If/Then/Else step — conditional branching.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class IfThenStep(StepBase): + """Branch based on a boolean condition expression. + + Both ``then:`` and ``else:`` contain inline step arrays — full step + definitions, not ID references. + """ + + type_key = "if" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + result = evaluate_condition(condition, context) + + if result: + branch = config.get("then", []) + else: + branch = config.get("else", []) + + return StepResult( + status=StepStatus.COMPLETED, + output={"condition_result": result}, + next_steps=branch, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'condition' field." + ) + if "then" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'then' field." + ) + then_branch = config.get("then", []) + if not isinstance(then_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'then' must be a list of steps." + ) + else_branch = config.get("else", []) + if else_branch and not isinstance(else_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'else' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py new file mode 100644 index 0000000000..44fa22508b --- /dev/null +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -0,0 +1,156 @@ +"""Prompt step — sends an arbitrary prompt to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class PromptStep(StepBase): + """Send a free-form prompt to an integration CLI. + + Unlike ``CommandStep`` which invokes an installed Spec Kit command + by name (e.g. ``/speckit.specify`` or ``/speckit-specify``), + ``PromptStep`` sends an arbitrary inline ``prompt:`` string + directly to the CLI. This is useful for ad-hoc instructions + that don't map to a registered command. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps. Full response text capture is a planned + enhancement. + + Example YAML:: + + - id: review-security + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude + """ + + type_key = "prompt" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + prompt_template = config.get("prompt", "") + prompt = evaluate_expression(prompt_template, context) + if not isinstance(prompt, str): + prompt = str(prompt) + + # Resolve integration (step → workflow default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Attempt CLI dispatch + dispatch_result = self._try_dispatch( + prompt, integration, model, context + ) + + output: dict[str, Any] = { + "prompt": prompt, + "integration": integration, + "model": model, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + dispatch_result["stderr"] + or f"Prompt exited with code {dispatch_result['exit_code']}" + ), + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch prompt: " + f"integration {integration!r} " + f"CLI not found or not installed." + ), + ) + + @staticmethod + def _try_dispatch( + prompt: str, + integration_key: str | None, + model: str | None, + context: StepContext, + ) -> dict[str, Any] | None: + """Dispatch *prompt* directly through the integration CLI.""" + if not integration_key or not prompt: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + exec_args = impl.build_exec_args(prompt, model=model, output_json=False) + if exec_args is None: + return None + + if not shutil.which(impl.key): + return None + + import subprocess + + project_root = ( + Path(context.project_root) if context.project_root else Path.cwd() + ) + + try: + result = subprocess.run( + exec_args, + text=True, + cwd=str(project_root), + ) + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + except OSError: + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "prompt" not in config: + errors.append( + f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py new file mode 100644 index 0000000000..73ac99530a --- /dev/null +++ b/src/specify_cli/workflows/steps/shell/__init__.py @@ -0,0 +1,75 @@ +"""Shell step — run a local shell command.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class ShellStep(StepBase): + """Run a local shell command (non-agent). + + Captures exit code and stdout/stderr. + """ + + type_key = "shell" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + run_cmd = config.get("run", "") + if isinstance(run_cmd, str) and "{{" in run_cmd: + run_cmd = evaluate_expression(run_cmd, context) + run_cmd = str(run_cmd) + + cwd = context.project_root or "." + + # NOTE: shell=True is required to support pipes, redirects, and + # multi-command expressions in workflow YAML. Workflow authors + # control commands; catalog-installed workflows should be reviewed + # before use (see PUBLISHING.md for security guidance). + try: + proc = subprocess.run( + run_cmd, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + timeout=300, + ) + output = { + "exit_code": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + } + if proc.returncode != 0: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command exited with code {proc.returncode}.", + output=output, + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + except subprocess.TimeoutExpired: + return StepResult( + status=StepStatus.FAILED, + error="Shell command timed out after 300 seconds.", + output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, + ) + except OSError as exc: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command failed: {exc}", + output={"exit_code": -1, "stdout": "", "stderr": str(exc)}, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "run" not in config: + errors.append( + f"Shell step {config.get('id', '?')!r} is missing 'run' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py new file mode 100644 index 0000000000..e58d3c23c3 --- /dev/null +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -0,0 +1,70 @@ +"""Switch step — multi-branch dispatch.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class SwitchStep(StepBase): + """Multi-branch dispatch on an expression. + + Evaluates ``expression:`` once, matches against ``cases:`` keys + (exact match, string-coerced). Falls through to ``default:`` if + no case matches. + """ + + type_key = "switch" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + expression = config.get("expression", "") + value = evaluate_expression(expression, context) + + # String-coerce for matching + str_value = str(value) if value is not None else "" + + cases = config.get("cases", {}) + for case_key, case_steps in cases.items(): + if str(case_key) == str_value: + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": str(case_key), "expression_value": value}, + next_steps=case_steps, + ) + + # Default fallback + default_steps = config.get("default", []) + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": "__default__", "expression_value": value}, + next_steps=default_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "expression" not in config: + errors.append( + f"Switch step {config.get('id', '?')!r} is missing " + f"'expression' field." + ) + cases = config.get("cases", {}) + if not isinstance(cases, dict): + errors.append( + f"Switch step {config.get('id', '?')!r}: 'cases' must be a mapping." + ) + else: + for key, val in cases.items(): + if not isinstance(val, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"case {key!r} must be a list of steps." + ) + default = config.get("default") + if default is not None and not isinstance(default, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"'default' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py new file mode 100644 index 0000000000..18c2f46050 --- /dev/null +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -0,0 +1,68 @@ +"""While loop step — repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class WhileStep(StepBase): + """Repeat nested steps while condition is truthy. + + Evaluates condition *before* each iteration. If falsy on first + check, the body never runs. ``max_iterations`` is an optional + safety cap (defaults to 10 if omitted). + """ + + type_key = "while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + + result = evaluate_condition(condition, context) + if result: + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": True, + "max_iterations": max_iterations, + "loop_type": "while", + }, + next_steps=nested_steps, + ) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": False, + "max_iterations": max_iterations, + "loop_type": "while", + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"While step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if not isinstance(max_iter, int) or max_iter < 1: + errors.append( + f"While step {config.get('id', '?')!r}: " + f"'max_iterations' must be an integer >= 1." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"While step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index e274b52242..3700d35de5 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -245,6 +245,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f".specify/templates/{name}") files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 007386611c..72d32278ba 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -347,6 +347,11 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ] + # Bundled workflow + files += [ + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ] return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 4d0bfe2cfe..e80f9abc10 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -505,6 +505,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f".specify/templates/{name}") files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index b0f59a627d..e4c31b3c88 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -384,6 +384,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f".specify/templates/{name}") files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 5db0155bdb..34a9d54945 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -199,6 +199,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ".specify/memory/constitution.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -259,6 +261,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ".specify/memory/constitution.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 2815456f21..74034ef105 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -248,6 +248,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/templates/plan-template.md", ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -304,6 +306,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/templates/plan-template.md", ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000000..96893249e2 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,1803 @@ +"""Tests for the workflow engine subsystem. + +Covers: +- Step registry & auto-discovery +- Base classes (StepBase, StepContext, StepResult) +- Expression engine +- All 10 built-in step types +- Workflow definition loading & validation +- Workflow engine execution & state persistence +- Workflow catalog & registry +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project with .specify/ directory.""" + specify_dir = temp_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "workflows").mkdir() + return temp_dir + + +@pytest.fixture +def sample_workflow_yaml(): + """Return a valid minimal workflow YAML string.""" + return """ +schema_version: "1.0" +workflow: + id: "test-workflow" + name: "Test Workflow" + version: "1.0.0" + description: "A test workflow" + +inputs: + feature_name: + type: string + required: true + scope: + type: string + default: "full" + +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: step-two + command: speckit.plan + input: + args: "{{ steps.step-one.output.command }}" +""" + + +@pytest.fixture +def sample_workflow_file(project_dir, sample_workflow_yaml): + """Write a sample workflow YAML to a file and return its path.""" + wf_dir = project_dir / ".specify" / "workflows" / "test-workflow" + wf_dir.mkdir(parents=True, exist_ok=True) + wf_path = wf_dir / "workflow.yml" + wf_path.write_text(sample_workflow_yaml, encoding="utf-8") + return wf_path + + +# ===== Step Registry Tests ===== + +class TestStepRegistry: + """Test STEP_REGISTRY and auto-discovery.""" + + def test_registry_populated(self): + from specify_cli.workflows import STEP_REGISTRY + + assert len(STEP_REGISTRY) >= 10 + + def test_all_step_types_registered(self): + from specify_cli.workflows import STEP_REGISTRY + + expected = { + "command", "shell", "prompt", "gate", "if", "switch", + "while", "do-while", "fan-out", "fan-in", + } + assert expected.issubset(set(STEP_REGISTRY.keys())) + + def test_get_step_type(self): + from specify_cli.workflows import get_step_type + + step = get_step_type("command") + assert step is not None + assert step.type_key == "command" + + def test_get_step_type_missing(self): + from specify_cli.workflows import get_step_type + + assert get_step_type("nonexistent") is None + + def test_register_step_duplicate_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.steps.command import CommandStep + + with pytest.raises(KeyError, match="already registered"): + _register_step(CommandStep()) + + def test_register_step_empty_key_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.base import StepBase, StepResult + + class EmptyStep(StepBase): + type_key = "" + def execute(self, config, context): + return StepResult() + + with pytest.raises(ValueError, match="empty type_key"): + _register_step(EmptyStep()) + + +# ===== Base Classes Tests ===== + +class TestBaseClasses: + """Test StepBase, StepContext, StepResult.""" + + def test_step_context_defaults(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert ctx.inputs == {} + assert ctx.steps == {} + assert ctx.item is None + assert ctx.fan_in == {} + assert ctx.default_integration is None + + def test_step_context_with_data(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + inputs={"name": "test"}, + default_integration="claude", + default_model="sonnet-4", + ) + assert ctx.inputs == {"name": "test"} + assert ctx.default_integration == "claude" + assert ctx.default_model == "sonnet-4" + + def test_step_result_defaults(self): + from specify_cli.workflows.base import StepResult, StepStatus + + result = StepResult() + assert result.status == StepStatus.COMPLETED + assert result.output == {} + assert result.next_steps == [] + assert result.error is None + + def test_step_status_values(self): + from specify_cli.workflows.base import StepStatus + + assert StepStatus.PENDING == "pending" + assert StepStatus.RUNNING == "running" + assert StepStatus.COMPLETED == "completed" + assert StepStatus.FAILED == "failed" + assert StepStatus.SKIPPED == "skipped" + assert StepStatus.PAUSED == "paused" + + def test_run_status_values(self): + from specify_cli.workflows.base import RunStatus + + assert RunStatus.CREATED == "created" + assert RunStatus.RUNNING == "running" + assert RunStatus.PAUSED == "paused" + assert RunStatus.COMPLETED == "completed" + assert RunStatus.FAILED == "failed" + assert RunStatus.ABORTED == "aborted" + + +# ===== Expression Engine Tests ===== + +class TestExpressions: + """Test sandboxed expression evaluator.""" + + def test_simple_variable(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + assert evaluate_expression("{{ inputs.name }}", ctx) == "login" + + def test_step_output_reference(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"specify": {"output": {"file": "spec.md"}}} + ) + assert evaluate_expression("{{ steps.specify.output.file }}", ctx) == "spec.md" + + def test_string_interpolation(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + result = evaluate_expression("Feature: {{ inputs.name }} done", ctx) + assert result == "Feature: login done" + + def test_comparison_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"scope": "full"}) + assert evaluate_expression("{{ inputs.scope == 'full' }}", ctx) is True + assert evaluate_expression("{{ inputs.scope == 'partial' }}", ctx) is False + + def test_comparison_not_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + result = evaluate_expression("{{ steps.run-tests.output.exit_code != 0 }}", ctx) + assert result is True + + def test_numeric_comparison(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"plan": {"output": {"task_count": 7}}} + ) + assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True + assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False + + def test_boolean_and(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": True, "b": True}) + assert evaluate_expression("{{ inputs.a and inputs.b }}", ctx) is True + + def test_boolean_or(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": False, "b": True}) + assert evaluate_expression("{{ inputs.a or inputs.b }}", ctx) is True + + def test_filter_default(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) == "fallback" + + def test_filter_join(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"tags": ["a", "b", "c"]}) + assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c" + + def test_filter_contains(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"text": "hello world"}) + assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True + + def test_condition_evaluation(self): + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"ready": True}) + assert evaluate_condition("{{ inputs.ready }}", ctx) is True + assert evaluate_condition("{{ inputs.missing }}", ctx) is False + + def test_non_string_passthrough(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression(42, ctx) == 42 + assert evaluate_expression(None, ctx) is None + + def test_string_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 'hello' }}", ctx) == "hello" + + def test_numeric_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 42 }}", ctx) == 42 + + def test_boolean_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ true }}", ctx) is True + assert evaluate_expression("{{ false }}", ctx) is False + + def test_list_indexing(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}} + ) + result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) + assert result == "a.md" + + +# ===== Integration Dispatch Tests ===== + +class TestBuildExecArgs: + """Test build_exec_args for CLI-based integrations.""" + + def test_claude_exec_args(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model="sonnet-4") + assert args[0] == "claude" + assert args[1] == "-p" + assert args[2] == "do stuff" + assert "--model" in args + assert "sonnet-4" in args + assert "--output-format" in args + + def test_gemini_exec_args(self): + from specify_cli.integrations.gemini import GeminiIntegration + impl = GeminiIntegration() + args = impl.build_exec_args("do stuff", model="gemini-2.5-pro") + assert args[0] == "gemini" + assert args[1] == "-p" + assert "-m" in args + assert "gemini-2.5-pro" in args + + def test_codex_exec_args(self): + from specify_cli.integrations.codex import CodexIntegration + impl = CodexIntegration() + args = impl.build_exec_args("do stuff") + assert args[0] == "codex" + assert args[1] == "exec" + assert args[2] == "do stuff" + assert "--json" in args + + def test_copilot_exec_args(self): + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") + assert args[0] == "copilot" + assert "-p" in args + assert "--allow-all-tools" in args + assert "--model" in args + + def test_ide_only_returns_none(self): + from specify_cli.integrations.windsurf import WindsurfIntegration + impl = WindsurfIntegration() + assert impl.build_exec_args("test") is None + + def test_no_model_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model=None) + assert "--model" not in args + + def test_no_json_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", output_json=False) + assert "--output-format" not in args + + +# ===== Step Type Tests ===== + +class TestCommandStep: + """Test the command step type.""" + + def test_execute_basic(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["command"] == "speckit.specify" + assert result.output["integration"] == "claude" + assert result.output["input"]["args"] == "login" + + def test_validate_missing_command(self): + from specify_cli.workflows.steps.command import CommandStep + + step = CommandStep() + errors = step.validate({"id": "test"}) + assert any("missing 'command'" in e for e in errors) + + def test_step_override_integration(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "test", + "command": "speckit.plan", + "integration": "gemini", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_step_override_model(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_model="sonnet-4") + config = { + "id": "test", + "command": "speckit.implement", + "model": "opus-4", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_options_merge(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_options={"max-tokens": 8000}) + config = { + "id": "test", + "command": "speckit.plan", + "options": {"thinking-budget": 32768}, + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["options"]["max-tokens"] == 8000 + assert result.output["options"]["thinking-budget"] == 32768 + + def test_dispatch_not_attempted_without_cli(self): + """When the CLI tool is not installed, step should fail.""" + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root="/tmp", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is False + assert result.error is not None + + def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): + """When the CLI is installed, dispatch invokes the command by name.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"result": "done"}' + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result) as mock_run: + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + # Verify the CLI was called with -p and the skill invocation + call_args = mock_run.call_args + assert call_args[0][0][0] == "claude" + assert call_args[0][0][1] == "-p" + # Claude is a SkillsIntegration so uses /speckit-specify + assert "/speckit-specify login" in call_args[0][0][2] + + def test_dispatch_failure_returns_failed_status(self, tmp_path): + """When the CLI exits non-zero, the step should fail.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "test"}, + } + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "API error" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 1 + + +class TestPromptStep: + """Test the prompt step type.""" + + def test_execute_basic(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + inputs={"file": "auth.py"}, + default_integration="claude", + ) + config = { + "id": "review", + "type": "prompt", + "prompt": "Review {{ inputs.file }} for security issues", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["prompt"] == "Review auth.py for security issues" + assert result.output["integration"] == "claude" + assert result.output["dispatched"] is False + + def test_execute_with_step_integration(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "review", + "type": "prompt", + "prompt": "Summarize the codebase", + "integration": "gemini", + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_execute_with_model(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude", default_model="sonnet-4") + config = { + "id": "review", + "type": "prompt", + "prompt": "hello", + "model": "opus-4", + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_dispatch_with_mock_cli(self, tmp_path): + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "ask", + "type": "prompt", + "prompt": "Explain this code", + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Here is the explanation" + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + + def test_validate_missing_prompt(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test"}) + assert any("missing 'prompt'" in e for e in errors) + + def test_validate_valid(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test", "prompt": "do something"}) + assert errors == [] + + +class TestShellStep: + """Test the shell step type.""" + + def test_execute_echo(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "echo hello"} + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["exit_code"] == 0 + assert "hello" in result.output["stdout"] + + def test_execute_failure(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "exit 1"} + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["exit_code"] == 1 + assert result.error is not None + + def test_validate_missing_run(self): + from specify_cli.workflows.steps.shell import ShellStep + + step = ShellStep() + errors = step.validate({"id": "test"}) + assert any("missing 'run'" in e for e in errors) + + +class TestGateStep: + """Test the gate step type.""" + + def test_execute_returns_paused(self): + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext() + config = { + "id": "review", + "message": "Review the spec.", + "options": ["approve", "reject"], + "on_reject": "abort", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.PAUSED + assert result.output["message"] == "Review the spec." + assert result.output["options"] == ["approve", "reject"] + + def test_validate_missing_message(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({"id": "test", "options": ["approve"]}) + assert any("missing 'message'" in e for e in errors) + + def test_validate_invalid_on_reject(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({ + "id": "test", + "message": "Review", + "on_reject": "invalid", + }) + assert any("on_reject" in e for e in errors) + + +class TestIfThenStep: + """Test the if/then/else step type.""" + + def test_execute_then_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "full"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + assert result.next_steps[0]["id"] == "a" + + def test_execute_else_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "backend"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps[0]["id"] == "b" + + def test_validate_missing_condition(self): + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "then": []}) + assert any("missing 'condition'" in e for e in errors) + + +class TestSwitchStep: + """Test the switch step type.""" + + def test_execute_matches_case(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "approve"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + "reject": [{"id": "log", "type": "shell", "run": "echo rejected"}], + }, + "default": [{"id": "abort", "type": "gate", "message": "Unknown"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "approve" + assert result.next_steps[0]["id"] == "plan" + + def test_execute_falls_to_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "unknown"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + "default": [{"id": "fallback", "type": "gate", "message": "Fallback"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps[0]["id"] == "fallback" + + def test_execute_no_default_no_match(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "other"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps == [] + + def test_validate_missing_expression(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({"id": "test", "cases": {}}) + assert any("missing 'expression'" in e for e in errors) + + def test_validate_invalid_cases_and_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({ + "id": "test", + "expression": "{{ x }}", + "cases": {"a": "not-a-list"}, + "default": "also-bad", + }) + assert any("case 'a' must be a list" in e for e in errors) + assert any("'default' must be a list" in e for e in errors) + + +class TestWhileStep: + """Test the while loop step type.""" + + def test_execute_condition_true(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + + def test_execute_condition_false(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 0}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_invalid_max_iterations(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []}) + assert any("must be an integer >= 1" in e for e in errors) + + +class TestDoWhileStep: + """Test the do-while loop step type.""" + + def test_execute_always_runs_once(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ false }}", + "max_iterations": 3, + "steps": [{"id": "refine", "command": "speckit.specify"}], + } + result = step.execute(config, ctx) + assert len(result.next_steps) == 1 + assert result.output["loop_type"] == "do-while" + assert result.output["condition"] == "{{ false }}" + + def test_execute_with_true_condition(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ true }}", + "max_iterations": 5, + "steps": [{"id": "work", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + # Body always executes on first call regardless of condition + assert len(result.next_steps) == 1 + assert result.output["max_iterations"] == 5 + + def test_execute_empty_steps(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "empty", + "condition": "{{ false }}", + "max_iterations": 1, + "steps": [], + } + result = step.execute(config, ctx) + assert result.next_steps == [] + assert result.status.value == "completed" + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_steps_not_list(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({ + "id": "test", + "condition": "{{ true }}", + "max_iterations": 3, + "steps": "not-a-list", + }) + assert any("'steps' must be a list" in e for e in errors) + + +class TestFanOutStep: + """Test the fan-out step type.""" + + def test_execute_with_items(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [ + {"file": "a.md"}, + {"file": "b.md"}, + ]}}} + ) + config = { + "id": "parallel", + "items": "{{ steps.tasks.output.task_list }}", + "max_concurrency": 3, + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 2 + assert result.output["max_concurrency"] == 3 + + def test_execute_non_list_items_resolves_empty(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext() + config = { + "id": "parallel", + "items": "{{ undefined_var }}", + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 0 + assert result.output["items"] == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({"id": "test"}) + assert any("missing 'items'" in e for e in errors) + assert any("missing 'step'" in e for e in errors) + + def test_validate_step_not_mapping(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({ + "id": "test", + "items": "{{ x }}", + "step": "not-a-dict", + }) + assert any("'step' must be a mapping" in e for e in errors) + + +class TestFanInStep: + """Test the fan-in step type.""" + + def test_execute_collects_results(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "parallel": {"output": {"item_count": 2, "status": "done"}} + } + ) + config = { + "id": "collect", + "wait_for": ["parallel"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 1 + assert result.output["results"][0]["item_count"] == 2 + + def test_execute_multiple_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "task-a": {"output": {"file": "a.md"}}, + "task-b": {"output": {"file": "b.md"}}, + } + ) + config = { + "id": "collect", + "wait_for": ["task-a", "task-b"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 2 + assert result.output["results"][0]["file"] == "a.md" + assert result.output["results"][1]["file"] == "b.md" + + def test_execute_missing_wait_for_step(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext(steps={}) + config = { + "id": "collect", + "wait_for": ["nonexistent"], + "output": {}, + } + result = step.execute(config, ctx) + assert result.output["results"] == [{}] + + def test_validate_empty_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": []}) + assert any("non-empty list" in e for e in errors) + + def test_validate_wait_for_not_list(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": "not-a-list"}) + assert any("non-empty list" in e for e in errors) + + +# ===== Workflow Definition Tests ===== + +class TestWorkflowDefinition: + """Test WorkflowDefinition loading and parsing.""" + + def test_from_yaml(self, sample_workflow_file): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(sample_workflow_file) + assert definition.id == "test-workflow" + assert definition.name == "Test Workflow" + assert definition.version == "1.0.0" + assert len(definition.steps) == 2 + + def test_from_string(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert definition.id == "test-workflow" + assert len(definition.inputs) == 2 + + def test_from_string_invalid(self): + from specify_cli.workflows.engine import WorkflowDefinition + + with pytest.raises(ValueError, match="must be a mapping"): + WorkflowDefinition.from_string("- just a list") + + def test_inputs_parsed(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert "feature_name" in definition.inputs + assert definition.inputs["feature_name"]["required"] is True + assert definition.inputs["scope"]["default"] == "full" + + +# ===== Workflow Validation Tests ===== + +class TestWorkflowValidation: + """Test workflow validation.""" + + def test_valid_workflow(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + errors = validate_workflow(definition) + assert errors == [] + + def test_missing_id(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.id" in e for e in errors) + + def test_invalid_id_format(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "Invalid ID!" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("lowercase alphanumeric" in e for e in errors) + + def test_no_steps(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: [] +""") + errors = validate_workflow(definition) + assert any("no steps" in e.lower() for e in errors) + + def test_duplicate_step_ids(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: same-id + command: speckit.specify + - id: same-id + command: speckit.plan +""") + errors = validate_workflow(definition) + assert any("Duplicate" in e for e in errors) + + def test_invalid_step_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: bad + type: nonexistent +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + def test_nested_step_validation(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: branch + type: if + condition: "{{ true }}" + then: + - id: nested-a + command: speckit.specify + else: + - id: nested-b + command: speckit.plan +""") + errors = validate_workflow(definition) + assert errors == [] + + def test_invalid_input_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +inputs: + bad: + type: array +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + +# ===== Workflow Engine Tests ===== + +class TestWorkflowEngine: + """Test WorkflowEngine execution.""" + + def test_load_from_file(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow(str(sample_workflow_file)) + assert definition.id == "test-workflow" + + def test_load_from_installed_id(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow("test-workflow") + assert definition.id == "test-workflow" + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + with pytest.raises(FileNotFoundError): + engine.load_workflow("nonexistent") + + def test_execute_simple_workflow(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "simple" + name: "Simple" + version: "1.0.0" + integration: claude +inputs: + name: + type: string + default: "test" +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, {"name": "login"}) + + assert state.status == RunStatus.FAILED + assert "step-one" in state.step_results + assert state.step_results["step-one"]["output"]["command"] == "speckit.specify" + assert state.step_results["step-one"]["output"]["input"]["args"] == "login" + + def test_execute_with_gate_pauses(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "gated" + name: "Gated" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" + - id: gate + type: gate + message: "Review?" + options: [approve, reject] + on_reject: abort + - id: step-two + type: shell + run: "echo done" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.PAUSED + assert "gate" in state.step_results + assert state.step_results["gate"]["status"] == "paused" + + def test_execute_with_shell_step(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "shell-test" + name: "Shell Test" + version: "1.0.0" +steps: + - id: echo + type: shell + run: "echo workflow-output" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "workflow-output" in state.step_results["echo"]["output"]["stdout"] + + def test_execute_with_if_then(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "branching" + name: "Branching" + version: "1.0.0" +inputs: + scope: + type: string + default: "full" +steps: + - id: check + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-tasks + type: shell + run: "echo full" + else: + - id: partial-tasks + type: shell + run: "echo partial" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, {"scope": "full"}) + + assert state.status == RunStatus.COMPLETED + assert "full-tasks" in state.step_results + assert "partial-tasks" not in state.step_results + + def test_execute_missing_required_input(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "needs-input" + name: "Needs Input" + version: "1.0.0" +inputs: + name: + type: string + required: true +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + + with pytest.raises(ValueError, match="Required input"): + engine.execute(definition, {}) + + +# ===== State Persistence Tests ===== + +class TestRunState: + """Test RunState persistence and loading.""" + + def test_save_and_load(self, project_dir): + from specify_cli.workflows.engine import RunState + from specify_cli.workflows.base import RunStatus + + state = RunState( + run_id="test-run", + workflow_id="test-workflow", + project_root=project_dir, + ) + state.status = RunStatus.RUNNING + state.inputs = {"name": "login"} + state.step_results = { + "step-one": { + "output": {"file": "spec.md"}, + "status": "completed", + } + } + state.save() + + loaded = RunState.load("test-run", project_dir) + assert loaded.run_id == "test-run" + assert loaded.workflow_id == "test-workflow" + assert loaded.status == RunStatus.RUNNING + assert loaded.inputs == {"name": "login"} + assert "step-one" in loaded.step_results + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import RunState + + with pytest.raises(FileNotFoundError): + RunState.load("nonexistent", project_dir) + + def test_append_log(self, project_dir): + from specify_cli.workflows.engine import RunState + + state = RunState( + run_id="log-test", + workflow_id="test", + project_root=project_dir, + ) + state.append_log({"event": "test_event", "data": "hello"}) + + log_file = state.runs_dir / "log.jsonl" + assert log_file.exists() + lines = log_file.read_text().strip().split("\n") + entry = json.loads(lines[0]) + assert entry["event"] == "test_event" + assert "timestamp" in entry + + +class TestListRuns: + """Test listing workflow runs.""" + + def test_list_empty(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + assert engine.list_runs() == [] + + def test_list_after_execution(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "list-test" + name: "List Test" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + engine.execute(definition) + + runs = engine.list_runs() + assert len(runs) == 1 + assert runs[0]["workflow_id"] == "list-test" + + +# ===== Workflow Registry Tests ===== + +class TestWorkflowRegistry: + """Test WorkflowRegistry operations.""" + + def test_add_and_get(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + + entry = registry.get("test-wf") + assert entry is not None + assert entry["name"] == "Test" + assert "installed_at" in entry + + def test_remove(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test"}) + assert registry.is_installed("test-wf") + + registry.remove("test-wf") + assert not registry.is_installed("test-wf") + + def test_list(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("wf-a", {"name": "A"}) + registry.add("wf-b", {"name": "B"}) + + installed = registry.list() + assert "wf-a" in installed + assert "wf-b" in installed + + def test_is_installed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + assert not registry.is_installed("missing") + + registry.add("exists", {"name": "Exists"}) + assert registry.is_installed("exists") + + def test_persistence(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry1 = WorkflowRegistry(project_dir) + registry1.add("test-wf", {"name": "Test"}) + + # Load fresh + registry2 = WorkflowRegistry(project_dir) + assert registry2.is_installed("test-wf") + + +# ===== Workflow Catalog Tests ===== + +class TestWorkflowCatalog: + """Test WorkflowCatalog catalog resolution.""" + + def test_default_catalogs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 2 + assert entries[0].name == "default" + assert entries[1].name == "community" + + def test_env_var_override(self, project_dir, monkeypatch): + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.setenv("SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json") + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "env-override" + assert entries[0].url == "https://example.com/catalog.json" + + def test_project_level_config(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [{ + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": 1, + "install_allowed": True, + }] + })) + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "custom" + + def test_validate_url_http_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + with pytest.raises(WorkflowValidationError, match="HTTPS"): + catalog._validate_catalog_url("http://evil.com/catalog.json") + + def test_validate_url_localhost_http_allowed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + # Should not raise + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + + def test_add_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/new-catalog.json", "my-catalog") + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + assert config_path.exists() + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" + + def test_add_catalog_duplicate_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/catalog.json") + + with pytest.raises(WorkflowValidationError, match="already configured"): + catalog.add_catalog("https://example.com/catalog.json") + + def test_remove_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json", "first") + catalog.add_catalog("https://example.com/c2.json", "second") + + removed = catalog.remove_catalog(0) + assert removed == "first" + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + + def test_remove_catalog_invalid_index(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json") + + with pytest.raises(WorkflowValidationError, match="out of range"): + catalog.remove_catalog(5) + + def test_get_catalog_configs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + configs = catalog.get_catalog_configs() + assert len(configs) == 2 + assert configs[0]["name"] == "default" + assert isinstance(configs[0]["install_allowed"], bool) + + +# ===== Integration Test ===== + +class TestWorkflowIntegration: + """End-to-end workflow execution tests.""" + + def test_full_sequential_workflow(self, project_dir): + """Execute a multi-step sequential workflow end to end.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "e2e-test" + name: "E2E Test" + version: "1.0.0" + integration: claude +inputs: + feature: + type: string + default: "login" +steps: + - id: specify + type: shell + run: "echo speckit.specify {{ inputs.feature }}" + + - id: check-scope + type: if + condition: "{{ inputs.feature == 'login' }}" + then: + - id: echo-full + type: shell + run: "echo full scope" + else: + - id: echo-partial + type: shell + run: "echo partial scope" + + - id: plan + type: shell + run: "echo speckit.plan" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "specify" in state.step_results + assert "check-scope" in state.step_results + assert "echo-full" in state.step_results + assert "echo-partial" not in state.step_results + assert "plan" in state.step_results + + def test_switch_workflow(self, project_dir): + """Test switch step type in a workflow.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "switch-test" + name: "Switch Test" + version: "1.0.0" +inputs: + action: + type: string + default: "plan" +steps: + - id: route + type: switch + expression: "{{ inputs.action }}" + cases: + specify: + - id: do-specify + type: shell + run: "echo specify" + plan: + - id: do-plan + type: shell + run: "echo plan" + default: + - id: do-default + type: shell + run: "echo default" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "do-plan" in state.step_results + assert "do-specify" not in state.step_results diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md new file mode 100644 index 0000000000..892333473c --- /dev/null +++ b/workflows/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# Workflow System Architecture + +This document describes the internal architecture of the workflow engine — how definitions are parsed, steps are dispatched, state is persisted, and catalogs are resolved. + +For usage instructions, see [README.md](README.md). + +## Execution Model + +When `specify workflow run` is invoked, the engine loads a YAML definition, resolves inputs, and dispatches steps sequentially through the step registry: + +```mermaid +flowchart TD + A["specify workflow run my-workflow"] --> B["WorkflowEngine.load_workflow()"] + B --> C["WorkflowDefinition.from_yaml()"] + C --> D["_resolve_inputs()"] + D --> E["validate_workflow()"] + E --> F["RunState.create()"] + F --> G["_execute_steps()"] + G --> H{Step type?} + H -- command --> I["CommandStep.execute()"] + H -- shell --> J["ShellStep.execute()"] + H -- gate --> K["GateStep.execute()"] + H -- "if" --> L["IfThenStep.execute()"] + H -- switch --> M["SwitchStep.execute()"] + H -- "while/do-while" --> N["Loop steps"] + H -- "fan-out/fan-in" --> O["Fan-out/fan-in"] + + I --> P{Result status?} + J --> P + K --> P + L --> P + M --> P + N --> P + O --> P + P -- COMPLETED --> Q{Has next_steps?} + P -- PAUSED --> R["Save state → exit"] + P -- FAILED --> S["Log error → exit"] + Q -- Yes --> G + Q -- No --> T{More steps?} + T -- Yes --> G + T -- No --> U["Status = COMPLETED"] + + style R fill:#ff9800,color:#fff + style S fill:#f44336,color:#fff + style U fill:#4caf50,color:#fff +``` + +### Sequential Execution + +Steps execute sequentially. Each step receives a `StepContext` containing resolved inputs, accumulated step results, and workflow-level defaults. After execution, the step's output is stored in `context.steps[step_id]` and made available to subsequent steps via expressions like `{{ steps.specify.output.file }}`. + +### Nested Steps (Control Flow) + +Steps like `if`, `switch`, `while`, and `do-while` return `next_steps` — inline step definitions that the engine executes recursively via `_execute_steps()`. Nested steps share the same `StepContext` and `RunState`, so their outputs are visible to later top-level steps. + +### State Persistence and Resume + +The engine saves `RunState` to disk after each step, enabling resume from the exact point of interruption: + +```mermaid +flowchart LR + A["CREATED"] --> B["RUNNING"] + B --> C["COMPLETED"] + B --> D["PAUSED"] + B --> E["FAILED"] + B --> F["ABORTED"] + D -- "resume()" --> B + E -- "resume()" --> B +``` + +When a `gate` step pauses execution, the engine persists `current_step_index` and all accumulated `step_results`. On `specify workflow resume `, the engine restores the context and continues from the paused step. + +> **Note:** Resume tracking is at the top-level step index only. If a +> nested step (inside `if`/`switch`/`while`) pauses, resume re-runs +> the parent control-flow step and its nested body. A nested step-path +> stack for exact resume is a planned enhancement. + +## Step Types + +The engine ships with 10 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`: + +| Type Key | Class | Purpose | Returns `next_steps`? | +|----------|-------|---------|-----------------------| +| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No | +| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No | +| `shell` | `ShellStep` | Run a shell command, capture output | No | +| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) | +| `if` | `IfThenStep` | Conditional branching (then/else) | Yes | +| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes | +| `while` | `WhileStep` | Loop while condition is truthy | Yes (if true) | +| `do-while` | `DoWhileStep` | Loop, always runs body at least once | Yes (always) | +| `fan-out` | `FanOutStep` | Dispatch per item over a collection | No (engine expands) | +| `fan-in` | `FanInStep` | Aggregate results from fan-out | No | + +## Step Registry + +All step types register into `STEP_REGISTRY` via `_register_builtin_steps()` in `src/specify_cli/workflows/__init__.py`. The registry maps `type_key` strings to step instances: + +```python +STEP_REGISTRY: dict[str, StepBase] # e.g., {"command": CommandStep(), "gate": GateStep(), ...} +``` + +Registration is explicit — each step class is imported and instantiated. New step types follow the same pattern: subclass `StepBase`, set `type_key`, implement `execute()` and optionally `validate()`. + +## Expression Engine + +Workflow definitions use Jinja2-like `{{ expression }}` syntax for dynamic values. The expression engine in `src/specify_cli/workflows/expressions.py` supports: + +| Feature | Syntax | Example | +|---------|--------|---------| +| Variable access | `{{ inputs.name }}` | Dot-path traversal into context | +| Step outputs | `{{ steps.plan.output.file }}` | Access previous step results | +| Comparisons | `==`, `!=`, `>`, `<`, `>=`, `<=` | `{{ count > 5 }}` | +| Boolean logic | `and`, `or`, `not` | `{{ items and status == 'ok' }}` | +| Membership | `in`, `not in` | `{{ 'error' not in status }}` | +| Literals | strings, numbers, booleans, lists | `{{ true }}`, `{{ [1, 2] }}` | +| Filter: `default` | `{{ val \| default('fallback') }}` | Fallback for None/empty | +| Filter: `join` | `{{ list \| join(', ') }}` | Join list elements | +| Filter: `contains` | `{{ text \| contains('sub') }}` | Substring/membership check | +| Filter: `map` | `{{ list \| map('attr') }}` | Extract attribute from each item | + +**Single expressions** (`{{ expr }}` only) return typed values. **Mixed templates** (`"text {{ expr }} more"`) return interpolated strings. + +### Namespace + +The expression evaluator builds a namespace from the `StepContext`: + +| Key | Source | Available when | +|-----|--------|----------------| +| `inputs` | Resolved workflow inputs | Always | +| `steps` | Accumulated step results | After first step | +| `item` | Current iteration item | Inside fan-out | +| `fan_in` | Aggregated results | Inside fan-in | + +## Input Resolution + +When a workflow is executed, `_resolve_inputs()` validates and coerces provided values against the `inputs:` schema: + +| Declared Type | Coercion | Example | +|---------------|----------|---------| +| `string` | None (pass-through) | `"my-feature"` | +| `number` | `float()` → `int()` if whole | `"42"` → `42` | +| `boolean` | `"true"/"1"/"yes"` → `True` | `"false"` → `False` | +| `enum` | Validates against allowed values | `["full", "backend-only"]` | + +Missing required inputs raise `ValueError`. Inputs with `default` values use the default when not provided. + +## Catalog System + +```mermaid +flowchart TD + A["specify workflow search"] --> B["WorkflowCatalog.get_active_catalogs()"] + B --> C{SPECKIT_WORKFLOW_CATALOG_URL set?} + C -- Yes --> D["Single custom catalog"] + C -- No --> E{.specify/workflow-catalogs.yml exists?} + E -- Yes --> F["Project-level catalog stack"] + E -- No --> G{"~/.specify/workflow-catalogs.yml exists?"} + G -- Yes --> H["User-level catalog stack"] + G -- No --> I["Built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files in `.specify/workflows/.cache/`). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. + +When `specify workflow add ` installs from catalog, it downloads the workflow YAML from the catalog entry's `url` field into `.specify/workflows//workflow.yml`. + +## State and Configuration Locations + +| Component | Location | Format | Purpose | +|-----------|----------|--------|---------| +| Workflow definitions | `.specify/workflows/{id}/workflow.yml` | YAML | Installed workflow definitions | +| Workflow registry | `.specify/workflows/workflow-registry.json` | JSON | Installed workflows metadata | +| Run state | `.specify/workflows/runs/{run_id}/state.json` | JSON | Persisted execution state | +| Run inputs | `.specify/workflows/runs/{run_id}/inputs.json` | JSON | Resolved input values | +| Run log | `.specify/workflows/runs/{run_id}/log.jsonl` | JSONL | Append-only event log | +| Catalog cache | `.specify/workflows/.cache/*.json` | JSON | Cached catalog entries (1hr TTL) | +| Project catalogs | `.specify/workflow-catalogs.yml` | YAML | Project-level catalog sources | +| User catalogs | `~/.specify/workflow-catalogs.yml` | YAML | User-level catalog sources | + +## Module Structure + +``` +src/specify_cli/ +├── workflows/ +│ ├── __init__.py # STEP_REGISTRY + _register_builtin_steps() +│ ├── base.py # StepBase, StepContext, StepResult, StepStatus, RunStatus +│ ├── catalog.py # WorkflowCatalog, WorkflowCatalogEntry, WorkflowRegistry +│ ├── engine.py # WorkflowDefinition, WorkflowEngine, RunState, validate_workflow() +│ ├── expressions.py # evaluate_expression(), evaluate_condition(), filters +│ └── steps/ +│ ├── command/ # Dispatch command to AI integration +│ ├── shell/ # Run shell command +│ ├── gate/ # Human review checkpoint +│ ├── if_then/ # Conditional branching +│ ├── prompt/ # Arbitrary inline prompts +│ ├── switch/ # Multi-branch dispatch +│ ├── while_loop/ # While loop +│ ├── do_while/ # Do-while loop +│ ├── fan_out/ # Sequential per-item dispatch +│ └── fan_in/ # Result aggregation +└── __init__.py # CLI commands: specify workflow run/resume/status/ + # list/add/remove/search/info, + # specify workflow catalog list/add/remove +``` diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md new file mode 100644 index 0000000000..857aaf7d11 --- /dev/null +++ b/workflows/PUBLISHING.md @@ -0,0 +1,285 @@ +# Workflow Publishing Guide + +This guide explains how to publish your workflow to the Spec Kit workflow catalog, making it discoverable by `specify workflow search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Workflow](#prepare-your-workflow) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing a workflow, ensure you have: + +1. **Valid Workflow**: A working `workflow.yml` that passes `specify workflow run` validation +2. **Git Repository**: Workflow hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description, inputs, and step graph +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning in the `workflow.version` field +6. **Testing**: Workflow tested on real projects + +--- + +## Prepare Your Workflow + +### 1. Workflow Structure + +Host your workflow in a repository with this structure: + +```text +your-workflow/ +├── workflow.yml # Required: Workflow definition +├── README.md # Required: Documentation +├── LICENSE # Required: License file +└── CHANGELOG.md # Recommended: Version history +``` + +### 2. workflow.yml Validation + +Verify your definition is valid: + +```yaml +schema_version: "1.0" + +workflow: + id: "your-workflow" # Unique lowercase-hyphenated ID + name: "Your Workflow Name" # Human-readable name + version: "1.0.0" # Semantic version + author: "Your Name or Organization" + description: "Brief description (one sentence)" + integration: claude # Default integration (optional) + model: "claude-sonnet-4-20250514" # Default model (optional) + +requires: + speckit_version: ">=0.6.1" + integrations: + any: ["claude", "gemini"] # At least one required + +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: review + type: gate + message: "Review the output." + options: [approve, reject] + on_reject: abort +``` + +**Validation Checklist**: + +- ✅ `id` is lowercase alphanumeric with hyphens (single-character IDs are allowed) +- ✅ `version` follows semantic versioning (X.Y.Z) +- ✅ `description` is concise +- ✅ All step IDs are unique +- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in` +- ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`) +- ✅ Input types are valid: `string`, `number`, `boolean` +- ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`) + +### 3. Test Locally + +```bash +# Run with required inputs +specify workflow run ./workflow.yml --input feature_name="user-auth" + +# Check validation +specify workflow info ./workflow.yml + +# Resume after a gate pause +specify workflow resume + +# Check run status +specify workflow status +``` + +### 4. Create GitHub Release + +Create a GitHub release for your workflow version: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +The raw YAML URL will be: + +```text +https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml +``` + +### 5. Test Installation from URL + +```bash +specify workflow add your-workflow +# (once published to catalog) +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified workflows (install allowed by default) +- **`catalog.community.json`** — Community-contributed workflows (discovery only by default) + +All community workflows should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Workflow to Community Catalog + +Edit `workflows/catalog.community.json` and add your workflow. + +> **⚠️ Entries must be sorted alphabetically by workflow ID.** Insert your workflow in the correct position within the `"workflows"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": { + "your-workflow": { + "id": "your-workflow", + "name": "Your Workflow Name", + "description": "Brief description of what your workflow automates", + "author": "Your Name", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml", + "repository": "https://github.com/your-org/spec-kit-workflow-your-workflow", + "license": "MIT", + "requires": { + "speckit_version": ">=0.15.0" + }, + "tags": [ + "category", + "automation" + ], + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + } + } +} +``` + +### 3. Submit Pull Request + +```bash +git checkout -b add-your-workflow +git add workflows/catalog.community.json +git commit -m "Add your-workflow to community catalog + +- Workflow ID: your-workflow +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-workflow +``` + +**Pull Request Checklist**: + +```markdown +## Workflow Submission + +**Workflow Name**: Your Workflow Name +**Workflow ID**: your-workflow +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-workflow-your-workflow + +### Checklist +- [ ] Valid workflow.yml (passes `specify workflow info`) +- [ ] README.md with description, inputs, and step graph +- [ ] LICENSE file included +- [ ] GitHub release created with raw YAML URL +- [ ] Workflow tested end-to-end with `specify workflow run` +- [ ] All gate steps have clear review messages +- [ ] Input prompts are descriptive +- [ ] Added to workflows/catalog.community.json (alphabetical order) +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Definition validation** — valid `workflow.yml`, correct schema +2. **Step correctness** — all step types used correctly, no dangling references +3. **Input design** — clear prompts, sensible defaults and enums +4. **Security** — no malicious shell commands, safe operations +5. **Documentation** — clear README explaining what the workflow does and when to use it + +Once verified, the workflow appears in `specify workflow search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `workflow.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `url` in `workflows/catalog.community.json` + +--- + +## Best Practices + +### Step Design + +- **Use gates at decision points** — place `gate` steps after each major output so users can review before proceeding +- **Keep steps focused** — each step should do one thing; prefer more steps over complex single steps +- **Provide clear gate messages** — explain what to review and what approve/reject means + +### Inputs + +- **Use descriptive prompts** — the `prompt` field is shown to users when running the workflow +- **Set sensible defaults** — optional inputs should have defaults that work for the common case +- **Constrain with enums** — when there's a fixed set of valid values, use `enum` for validation +- **Type appropriately** — use `number` for counts, `boolean` for flags, `string` for names + +### Shell Steps + +- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate +- **Quote variables** — use proper quoting in shell commands to handle spaces +- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust + +### Integration Flexibility + +- **Set `integration` at workflow level** — use the `workflow.integration` field as the default +- **Allow per-step overrides** — let individual steps specify a different integration if needed +- **Document required integrations** — list which integrations must be installed in `requires.integrations` + +### Expression References + +- **Only reference prior steps** — expressions like `{{ steps.plan.output.file }}` only work if `plan` ran before the current step +- **Use `default` filter** — `{{ val | default('fallback') }}` prevents failures from missing values +- **Keep expressions simple** — complex logic should be in shell steps, not expressions diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000000..3ece00b6b0 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,339 @@ +# Workflows + +Workflows are multi-step, resumable automation pipelines defined in YAML. They orchestrate Spec Kit commands across integrations, evaluate control flow, and pause at human review gates — enabling end-to-end Spec-Driven Development cycles without manual step-by-step invocation. + +## How It Works + +A workflow definition declares a sequence of steps. The engine executes them in order, dispatching commands to AI integrations, running shell commands, evaluating conditions for branching, and pausing at gates for human review. State is persisted after each step, so workflows can be resumed after interruption. + +```yaml +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: review + type: gate + message: "Review the spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan +``` + +For detailed architecture and internals, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Quick Start + +```bash +# Search available workflows +specify workflow search + +# Install the built-in SDD workflow +specify workflow add speckit + +# Or run directly from a local YAML file +specify workflow run ./workflow.yml --input feature_name="user-auth" + +# Run an installed workflow with inputs +specify workflow run speckit --input feature_name="user-auth" + +# Check run status +specify workflow status + +# Resume after a gate pause +specify workflow resume + +# Get detailed workflow info +specify workflow info speckit + +# Remove a workflow +specify workflow remove speckit +``` + +## Running Workflows + +### From an Installed Workflow + +```bash +specify workflow add speckit +specify workflow run speckit --input feature_name="user-auth" +``` + +### From a Local YAML File + +```bash +specify workflow run ./my-workflow.yml --input feature_name="user-auth" +``` + +### Multiple Inputs + +```bash +specify workflow run speckit \ + --input feature_name="user-auth" \ + --input scope="backend-only" +``` + +## Step Types + +Workflows support 10 built-in step types: + +### Command Steps (default) + +Invoke an installed Spec Kit command by name via the integration CLI: + +```yaml +- id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + integration: claude # Optional: override workflow default + model: "claude-sonnet-4-20250514" # Optional: override model +``` + +### Prompt Steps + +Send an arbitrary inline prompt to an integration CLI (no command file needed): + +```yaml +- id: security-review + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude +``` + +### Shell Steps + +Run a shell command and capture output: + +```yaml +- id: run-tests + type: shell + run: "cd {{ inputs.project_dir }} && npm test" +``` + +### Gate Steps + +Pause for human review. The workflow resumes when `specify workflow resume` is called: + +```yaml +- id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, edit, reject] + on_reject: abort +``` + +### If/Then/Else Steps + +Conditional branching based on an expression: + +```yaml +- id: check-scope + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-plan + command: speckit.plan + else: + - id: quick-plan + command: speckit.plan + options: + quick: true +``` + +### Switch Steps + +Multi-branch dispatch on an expression value: + +```yaml +- id: route + type: switch + expression: "{{ steps.review.output.choice }}" + cases: + approve: + - id: plan + command: speckit.plan + reject: + - id: log + type: shell + run: "echo 'Rejected'" + default: + - id: fallback + type: gate + message: "Unexpected choice" +``` + +### While Loop Steps + +Repeat steps while a condition is truthy: + +```yaml +- id: retry + type: while + condition: "{{ steps.run-tests.output.exit_code != 0 }}" + max_iterations: 5 + steps: + - id: fix + command: speckit.implement +``` + +### Do-While Loop Steps + +Execute steps at least once, then repeat while condition holds: + +```yaml +- id: refine + type: do-while + condition: "{{ steps.review.output.choice == 'edit' }}" + max_iterations: 3 + steps: + - id: revise + command: speckit.specify +``` + +### Fan-Out Steps + +Dispatch a step template for each item in a collection (sequential): + +```yaml +- id: parallel-impl + type: fan-out + items: "{{ steps.tasks.output.task_list }}" + max_concurrency: 3 + step: + id: impl + command: speckit.implement +``` + +### Fan-In Steps + +Aggregate results from fan-out steps: + +```yaml +- id: collect + type: fan-in + wait_for: [parallel-impl] + output: {} +``` + +## Expressions + +Workflow definitions use `{{ expression }}` syntax for dynamic values: + +```yaml +# Access inputs +args: "{{ inputs.feature_name }}" + +# Access previous step outputs +args: "{{ steps.specify.output.file }}" + +# Comparisons +condition: "{{ steps.run-tests.output.exit_code != 0 }}" + +# Filters +message: "{{ status | default('pending') }}" +``` + +Supported filters: `default`, `join`, `contains`, `map`. + +## Input Types + +Workflow inputs are type-checked and coerced from CLI string values: + +```yaml +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + task_count: + type: number + default: 5 + dry_run: + type: boolean + default: false + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] +``` + +| Type | Accepts | Example | +|------|---------|---------| +| `string` | Any string | `"user-auth"` | +| `number` | Numeric strings → int/float | `"42"` → `42` | +| `boolean` | `true`/`1`/`yes` → `True`, `false`/`0`/`no` → `False` | `"true"` → `True` | + +## State and Resume + +Every workflow run persists state to `.specify/workflows/runs//`: + +```bash +# List all runs with status +specify workflow status + +# Check a specific run +specify workflow status + +# Resume a paused run (after approving a gate) +specify workflow resume + +# Resume a failed run (retries from the failed step) +specify workflow resume +``` + +Run states: `created` → `running` → `completed` | `paused` | `failed` | `aborted` + +## Catalog Management + +Workflows are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +> [!NOTE] +> Community workflows are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting and structure, but they do **not review, audit, endorse, or support the workflow definitions themselves**. Review workflow source before installation and use at your own discretion. + +```bash +# List active catalogs +specify workflow catalog list + +# Add a custom catalog +specify workflow catalog add https://example.com/catalog.json --name my-org + +# Remove a catalog +specify workflow catalog remove +``` + +## Creating a Workflow + +1. Create a `workflow.yml` following the schema above +2. Test locally with `specify workflow run ./workflow.yml --input key=value` +3. Verify with `specify workflow info ./workflow.yml` +4. See [PUBLISHING.md](PUBLISHING.md) to submit to the catalog + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/workflow-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/workflow-catalogs.yml` | User | Custom catalog stack for all projects | + +## Repository Layout + +``` +workflows/ +├── ARCHITECTURE.md # Internal architecture documentation +├── PUBLISHING.md # Guide for submitting workflows to the catalog +├── README.md # This file +├── catalog.json # Official workflow catalog +├── catalog.community.json # Community workflow catalog +└── speckit/ # Built-in SDD cycle workflow + └── workflow.yml +``` diff --git a/workflows/catalog.community.json b/workflows/catalog.community.json new file mode 100644 index 0000000000..c654f5ed22 --- /dev/null +++ b/workflows/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": {} +} diff --git a/workflows/catalog.json b/workflows/catalog.json new file mode 100644 index 0000000000..967120afb0 --- /dev/null +++ b/workflows/catalog.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-13T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", + "workflows": { + "speckit": { + "id": "speckit", + "name": "Full SDD Cycle", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", + "tags": ["sdd", "full-cycle"] + } + } +} diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml new file mode 100644 index 0000000000..a440c5c507 --- /dev/null +++ b/workflows/speckit/workflow.yml @@ -0,0 +1,63 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.6.1" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" From 3467d26b1c1b2ed5d6aeb84fdbdac732808d522b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:06:06 -0500 Subject: [PATCH 250/321] chore: release 0.7.0, begin 0.7.1.dev0 development (#2217) * chore: bump version to 0.7.0 * chore: begin 0.7.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928bc74b9b..fe587098f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ +## [0.7.0] - 2026-04-14 + +### Changed + +- Add workflow engine with catalog system (#2158) +- docs(catalog): add claude-ask-questions to community preset catalog (#2191) +- Add SFSpeckit — Salesforce SDD Extension (#2208) +- feat(scripts): optional single-segment branch prefix for gitflow (#2202) +- chore: release 0.6.2, begin 0.6.3.dev0 development (#2205) +- Add Worktrees extension to community catalog (#2207) +- feat: Update catalog.community.json for preset-fiction-book-writing (#2199) + ## [0.6.2] - 2026-04-13 ### Changed diff --git a/pyproject.toml b/pyproject.toml index db53f2cb58..e7ea248214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.3.dev0" +version = "0.7.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 39c7b04e5eb366a90f1e867f2d2c5196bd3a2004 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:12:27 -0500 Subject: [PATCH 251/321] chore: deprecate --ai flag in favor of --integration on specify init (#2218) * chore: deprecate --ai flag in favor of --integration on specify init - Adds deprecation warning when --ai is used - Shows equivalent --integration command replacement - Handles generic integration with --commands-dir mapping - Adds comprehensive test coverage for deprecation behavior - Warning displays as prominent red panel above Next Steps - --ai flag continues to function (non-breaking change) Fixes #2169 * Address PR review feedback for issue #2169 - Use existing strip_ansi helper from conftest instead of duplicating ANSI escape pattern - Properly escape ai_commands_dir with shlex.quote() to handle paths with spaces - Add shlex import to support proper command-line argument escaping --- src/specify_cli/__init__.py | 46 +++++++++++++++++++++++++ tests/integrations/test_cli.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c33281e2b4..eb4c306bf4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -33,6 +33,7 @@ import json import json5 import stat +import shlex import yaml from pathlib import Path from typing import Any, Optional @@ -92,6 +93,36 @@ def _build_ai_assistant_help() -> str: return base_help + " Use " + aliases_text + "." AI_ASSISTANT_HELP = _build_ai_assistant_help() + +def _build_integration_equivalent( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the modern --integration equivalent for legacy --ai usage.""" + + parts = [f"--integration {integration_key}"] + if integration_key == "generic" and ai_commands_dir: + parts.append( + f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' + ) + return " ".join(parts) + + +def _build_ai_deprecation_warning( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the legacy --ai deprecation warning message.""" + + replacement = _build_integration_equivalent( + integration_key, + ai_commands_dir=ai_commands_dir, + ) + return ( + "[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -957,6 +988,7 @@ def init( """ show_banner() + ai_deprecation_warning: str | None = None # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): @@ -995,6 +1027,10 @@ def init( if not resolved_integration: console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") raise typer.Exit(1) + ai_deprecation_warning = _build_ai_deprecation_warning( + resolved_integration.key, + ai_commands_dir=ai_commands_dir, + ) # Deprecation warnings for --ai-skills and --ai-commands-dir (only when # an integration has been resolved from --ai or --integration) @@ -1428,6 +1464,16 @@ def init( console.print() console.print(security_notice) + if ai_deprecation_warning: + deprecation_notice = Panel( + ai_deprecation_warning, + title="[bold red]Deprecation Warning[/bold red]", + border_style="red", + padding=(1, 2), + ) + console.print() + console.print(deprecation_notice) + steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 1e23e35a7d..bd73ccd664 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -5,6 +5,14 @@ import yaml +from tests.conftest import strip_ansi + + +def _normalize_cli_output(output: str) -> str: + output = strip_ansi(output) + output = " ".join(output.split()) + return output.strip() + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): @@ -77,6 +85,59 @@ def test_ai_copilot_auto_promotes(self, tmp_path): assert result.exit_code == 0 assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-ai" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--ai" in normalized_output + assert "deprecated" in normalized_output + assert "no longer be available" in normalized_output + assert "1.0.0" in normalized_output + assert "--integration copilot" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--integration generic" in normalized_output + assert "--integration-options" in normalized_output + assert ".myagent/commands" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".myagent" / "commands" / "speckit.plan.md").exists() + def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): from typer.testing import CliRunner from specify_cli import app From f0886bd089030fc94e75899a9829cc744d766164 Mon Sep 17 00:00:00 2001 From: Umm e Habiba <161445850+UmmeHabiba1312@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:08:28 +0500 Subject: [PATCH 252/321] feat: register architect-preview in community catalog (#2214) * Add Architect Impact Previewer to catalog Added a new architect impact previewer with metadata. * Fix description formatting in architect-preview * Add Architect Impact Previewer extension details * Update catalog.community.json * Add Architect Impact Previewer extension details Added 'Architect Impact Previewer' extension with details including name, description, author, version, and URLs. * Add Architect Impact Previewer extension to README --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c729fe02f1..a8a8d3a3ed 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | +| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 61731b22d9..dd7f4f3a16 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T23:01:30Z", + "updated_at": "2026-04-14T21:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -36,6 +36,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "architect-preview": { + "name": "Architect Impact Previewer", + "id": "architect-preview", + "description": "Predicts architectural impact, complexity, and risks of proposed changes before implementation.", + "author": "Umme Habiba", + "version": "1.0.0", + "download_url": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "homepage": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "documentation": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/README.md", + "changelog": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "architecture", + "analysis", + "risk-assessment", + "planning", + "preview" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-14T00:00:00Z", + "updated_at": "2026-04-14T00:00:00Z" + }, "archive": { "name": "Archive Extension", "id": "archive", From 33a28ec8f7f87b3facce51a6226581683aaa05e1 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Wed, 15 Apr 2026 14:35:49 +0200 Subject: [PATCH 253/321] fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: warn about unofficial PyPI packages and recommend version verification (#1982) Clarify that only packages from github/spec-kit are official, and add `specify version` as a post-install verification step to help users catch accidental installation of an unrelated package with the same name. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): auto-correct legacy command names instead of hard-failing (#2017) Community extensions that predate the strict naming requirement use two common legacy formats ('speckit.command' and 'extension.command'). Instead of rejecting them outright, auto-correct to the required 'speckit.{extension}.{command}' pattern and emit a compatibility warning so authors know they need to update their manifest. Names that cannot be safely corrected (e.g. single-segment names) still raise ValidationError. Co-Authored-By: Claude Sonnet 4.6 * fix(tests): isolate preset catalog search test from community catalog network calls test_search_with_cached_data asserted exactly 2 results but was getting 4 because _get_merged_packs() queries the full built-in catalog stack (default + community). The community catalog had no local cache and hit the network, returning real presets. Writing a project-level preset-catalogs.yml that pins the test to the default URL only makes the count assertions deterministic. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): extend auto-correction to aliases (#2017) The upstream #1994 added alias validation in _collect_manifest_command_names, which also rejected legacy 2-part alias names (e.g. 'speckit.verify'). Extend the same auto-correction logic from _validate() to cover aliases, so both 'speckit.command' and 'extension.command' alias formats are corrected to 'speckit.{ext_id}.{command}' with a compatibility warning instead of hard-failing. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): address PR review feedback (#2017) - _try_correct_command_name: only correct 'X.Y' to 'speckit.ext_id.Y' when X matches ext_id, preventing misleading warnings followed by install failure due to namespace mismatch - _validate: add aliases type/string guards matching _collect_manifest _command_names defensive checks - _validate: track command renames and rewrite any hook.*.command references that pointed at a renamed command, emitting a warning - test: fix test_command_name_autocorrect_no_speckit_prefix to use ext_id matching the legacy namespace; add namespace-mismatch test - test: replace redundant preset-catalogs.yml isolation with monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL") so the env var cannot bypass catalog restriction in CI environments Co-Authored-By: Claude Sonnet 4.6 * Update docs/installation.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(extensions): warn when hook command refs are silently canonicalized; fix grammar - Hook rewrites (alias-form or rename-map) now always emit a warning so extension authors know to update their manifests. Previously only rename-map rewrites produced a warning; pure alias-form lifts were silent. - Pluralize "command/commands" in the uninstall confirmation message so single-command extensions no longer print "1 commands". Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): raise ValidationError for non-dict hook entries Silently skipping non-dict hook entries left them in manifest.hooks, causing HookExecutor.register_hooks() to crash with AttributeError when it called hook_config.get() on a non-mapping value. Also updates PR description to accurately reflect the implementation (no separate _try_correct_alias_name helper; aliases use the same _try_correct_command_name path). Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): derive remove cmd_count from registry, fix wording Previously cmd_count used len(ext_manifest.commands) which only counted primary commands and missed aliases. The registry's registered_commands already tracks every command name (primaries + aliases) per agent, so max(len(v) for v in registered_commands.values()) gives the correct total. Also changes "from AI agent" → "across AI agents" since remove() unregisters commands from all detected agents. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): distinguish missing vs empty registered_commands in remove prompt Using get() without a default lets us tell apart: - key missing (legacy registry entry) → fall back to manifest count - key present but empty dict (installed with no agent dirs) → show 0 Previously the truthiness check `if registered_commands and ...` treated both cases the same, so an empty dict fell back to len(manifest.commands) and overcounted commands that would actually be removed. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): clarify removal prompt wording to 'per agent' 'across AI agents' implied a total count, but cmd_count uses max() across agents (per-agent count). Using sum() would double-count since users think in logical commands, not per-agent files. 'per agent' accurately describes what the number represents. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): clarify cmd_count comment — per-agent max, not total The comment said 'covers all agents' implying a total, but cmd_count uses max() across agents (per-agent count). Updated comment to explain the max() choice and why sum() would double-count. Co-Authored-By: Claude Sonnet 4.6 * test(extensions): add CLI tests for remove confirmation pluralization Adds TestExtensionRemoveCLI with two CliRunner tests: - singular: 1 registered command → '1 command per agent' - plural: 2 registered commands → '2 commands per agent' These prevent regressions on the cmd_count pluralization logic and the 'per agent' wording introduced in this PR. Co-Authored-By: Claude Sonnet 4.6 * fix(agents): remove orphaned SKILL.md parent dirs on unregister For SKILL.md-based agents (codex, kimi), each command lives in its own subdirectory (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). The previous unregister_commands() only unlinked the file, leaving an empty parent dir. Now attempts rmdir() on the parent when it differs from the agent commands dir. OSError is silenced so non-empty dirs (e.g. user files) are safely left. Adds test_unregister_skill_removes_parent_directory to cover this. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): drop alias pattern enforcement from _validate() Aliases are intentionally free-form to preserve community extension compatibility (e.g. 'speckit.verify' short aliases used by spec-kit-verify and other existing extensions). This aligns _validate() with the intent of upstream commit 4deb90f (fix: restore alias compatibility, #2110/#2125). Only type and None-normalization checks remain for aliases. Pattern enforcement continues for primary command names only. Updated tests to verify free-form aliases pass through unchanged with no warnings instead of being auto-corrected. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): guard against non-dict command entries in _validate() If provides.commands contains a non-mapping entry (e.g. an int or string), 'name' not in cmd raises TypeError instead of a user-facing ValidationError. Added isinstance(cmd, dict) check at the top of the loop. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 10 +- docs/installation.md | 10 ++ src/specify_cli/__init__.py | 21 +++- src/specify_cli/agents.py | 9 ++ src/specify_cli/extensions.py | 90 ++++++++++++++- tests/test_extensions.py | 205 +++++++++++++++++++++++++++++++++- tests/test_presets.py | 3 +- 7 files changed, 334 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a8a8d3a3ed..54e245d63b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Spec-Driven Development **flips the script** on traditional software development Choose your preferred installation method: +> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. + #### Option 1: Persistent Installation (Recommended) Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -62,7 +64,13 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX uv tool install specify-cli --from git+https://github.com/github/spec-kit.git ``` -Then use the tool directly: +Then verify the correct version is installed: + +```bash +specify version +``` + +And use the tool directly: ```bash # Create new project diff --git a/docs/installation.md b/docs/installation.md index 5d560b6e33..ed253902af 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,6 +10,8 @@ ## Installation +> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid. + ### Initialize a New Project The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -69,6 +71,14 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init Optional[str]: + """Try to auto-correct a non-conforming command name to the required pattern. + + Handles the two legacy formats used by community extensions: + - 'speckit.command' → 'speckit.{ext_id}.command' + - '{ext_id}.command' → 'speckit.{ext_id}.command' + + The 'X.Y' form is only corrected when X matches ext_id to ensure the + result passes the install-time namespace check. Any other prefix is + uncorrectable and will produce a ValidationError at the call site. + + Returns the corrected name, or None if no safe correction is possible. + """ + parts = name.split('.') + if len(parts) == 2: + if parts[0] == 'speckit' or parts[0] == ext_id: + candidate = f"speckit.{ext_id}.{parts[1]}" + if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): + return candidate + return None @property def id(self) -> str: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a6ddff8e1a..bec939702f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -243,7 +243,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data): ExtensionManifest(manifest_path) def test_invalid_command_name(self, temp_dir, valid_manifest_data): - """Test manifest with invalid command name format.""" + """Test manifest with command name that cannot be auto-corrected raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" @@ -255,6 +255,83 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) + def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + assert len(manifest.warnings) == 1 + assert "speckit.hello" in manifest.warnings[0] + assert "speckit.test-ext.hello" in manifest.warnings[0] + + def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data): + """Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + # Set ext_id to match the legacy namespace so correction is valid + valid_manifest_data["extension"]["id"] = "docguard" + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.docguard.guard" + assert len(manifest.warnings) == 1 + assert "docguard.guard" in manifest.warnings[0] + assert "speckit.docguard.guard" in manifest.warnings[0] + + def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data): + """Test that 'X.command' is NOT corrected when X doesn't match ext_id.""" + import yaml + + # ext_id is "test-ext" but command uses a different namespace + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command name"): + ExtensionManifest(manifest_path) + + def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data): + """Aliases are free-form — a 'speckit.command' alias must be accepted unchanged.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["aliases"] == ["speckit.hello"] + assert manifest.warnings == [] + + def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): + """Test that a correctly-named command produces no warnings.""" + import yaml + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.warnings == [] + def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): """Test manifest with no commands and no hooks provided.""" import yaml @@ -317,6 +394,19 @@ def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid hooks"): ExtensionManifest(manifest_path) + def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data): + """Non-mapping hook entries must raise ValidationError, not silently skip.""" + import yaml + + valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"): + ExtensionManifest(manifest_path) + def test_manifest_hash(self, extension_dir): """Test manifest hash calculation.""" manifest_path = extension_dir / "extension.yml" @@ -686,8 +776,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_accepts_short_alias(self, temp_dir, project_dir): - """Install should accept legacy short aliases for community extension compat.""" + def test_install_accepts_free_form_alias(self, temp_dir, project_dir): + """Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -718,8 +808,10 @@ def test_install_accepts_short_alias(self, temp_dir, project_dir): (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - # Should not raise — short aliases are allowed - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + assert manifest.commands[0]["aliases"] == ["speckit.shortcut"] + assert manifest.warnings == [] def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" @@ -1619,6 +1711,54 @@ def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): prompts_dir = project_dir / ".github" / "prompts" assert not prompts_dir.exists() + def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): + """Unregistering a SKILL.md command should remove the empty parent subdirectory.""" + import yaml + + ext_dir = temp_dir / "cleanup-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "cleanup-ext", + "name": "Cleanup Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.cleanup-ext.run", + "file": "commands/run.md", + "description": "Run", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + (ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody") + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest(ext_dir / "extension.yml") + registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + skill_subdir = skills_dir / "speckit-cleanup-ext-run" + assert skill_subdir.exists(), "Skill subdirectory should exist after registration" + assert (skill_subdir / "SKILL.md").exists() + + registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir) + + assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed" + assert not skill_subdir.exists(), "Empty parent subdirectory should be removed" + # ===== Utility Function Tests ===== @@ -3853,3 +3993,58 @@ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): assert "Executing: `/`" in message assert "EXECUTE_COMMAND: " in message assert "EXECUTE_COMMAND_INVOCATION: /" in message + + +class TestExtensionRemoveCLI: + """CLI tests for `specify extension remove` confirmation prompt wording.""" + + def _install_ext(self, project_dir, ext_dir): + """Install extension and return the manager.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + return manager + + def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): + """Confirmation prompt should say '1 command' (singular) when one command registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 1 entry so cmd_count == 1 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "1 command" in result.output + assert "1 commands" not in result.output + + def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): + """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 2 entries so cmd_count == 2 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "2 commands" in result.output diff --git a/tests/test_presets.py b/tests/test_presets.py index 95af7a900f..c7383a1f49 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1175,8 +1175,7 @@ def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch - # Only use the default catalog to prevent fetching the community catalog from the network - monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL) + monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) From 2f5417f0addba343b07c8f805b6b30159cab1dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8E=E5=AD=90=E5=90=8C=E8=AA=AC?= <541959443@qq.com> Date: Wed, 15 Apr 2026 21:26:46 +0800 Subject: [PATCH 254/321] Add agent-assign extension to community catalog (#2030) * Add agent-assign extension to community catalog * Fix author name to xuyang in catalog entry * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update updated_at date in catalog.community.json * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: xuyang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 54e245d63b..031d663476 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| +| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index dd7f4f3a16..6589093444 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -36,6 +36,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "agent-assign": { + "name": "Agent Assign", + "id": "agent-assign", + "description": "Assign specialized Claude Code agents to spec-kit tasks for targeted execution", + "author": "xuyang", + "version": "1.0.0", + "download_url": "https://github.com/xymelon/spec-kit-agent-assign/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/xymelon/spec-kit-agent-assign", + "homepage": "https://github.com/xymelon/spec-kit-agent-assign", + "documentation": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/README.md", + "changelog": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "agent", + "automation", + "implementation", + "multi-agent", + "task-routing" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }, "architect-preview": { "name": "Architect Impact Previewer", "id": "architect-preview", From b78a3cdd88f3e43061f7e26800fe81dc5ac833bf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:57:06 -0500 Subject: [PATCH 255/321] docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md Merge relevant testing content (automated checks, manual testing process, reporting template, test-selection prompt) into CONTRIBUTING.md. Remove obsolete content referencing deleted zip bundles and the non-existent test_core_pack_scaffold.py file. Update DEVELOPMENT.md to remove the TESTING.md entry. Closes #2226 * docs: address review — narrow automated checks intro, use cross-platform temp path --- CONTRIBUTING.md | 104 +++++++++++++++++++++++++++++-------- DEVELOPMENT.md | 3 +- TESTING.md | 133 ------------------------------------------------ 3 files changed, 85 insertions(+), 155 deletions(-) delete mode 100644 TESTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9044ef5ff9..4ce19657ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,8 +44,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler 1. Push to your fork and submit a pull request 1. Wait for your pull request to be reviewed and merged. -For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md). -Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. +Activate the project virtual environment (see [Testing setup](#testing-setup) below), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. Here are a few things you can do that will increase the likelihood of your pull request being accepted: @@ -69,34 +68,99 @@ When working on spec-kit: For the smoothest review experience, validate changes in this order: -1. **Run focused automated checks first** — use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early. -2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR. -3. **Use local release packages when debugging packaged output** — if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below. +1. **Run focused automated checks first** — use the quick verification commands [below](#automated-checks) to catch scaffolding and configuration regressions early. +2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow the [manual testing](#manual-testing) section to choose the right commands, run them in an agent, and capture results for your PR. -### Testing template and command changes locally +### Automated checks -Running `uv run specify init` pulls released packages, which won’t include your local changes. -To test your templates, commands, and other changes locally, follow these steps: +#### Agent configuration and wiring consistency -1. **Create release packages** +```bash +uv run python -m pytest tests/test_agent_config_consistency.py -q +``` - Run the following command to generate the local packages: +Run this when you change agent metadata, context update scripts, or integration wiring. - ```bash - ./.github/workflows/scripts/create-release-packages.sh v1.0.0 - ``` +### Manual testing -2. **Copy the relevant package to your test project** +#### Testing setup - ```bash - cp -r .genreleases/sdd-copilot-package-sh/. / - ``` +```bash +# Install the project and test dependencies from your local branch +cd +uv sync --extra test +source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 +uv pip install -e . +# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. -3. **Open and test the agent** +# Initialize a test project using your local changes +uv run specify init /speckit-test --ai --offline +cd /speckit-test - Navigate to your test project folder and open the agent to verify your implementation. +# Open in your agent +``` -If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally. +#### Manual testing process + +Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. + +1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. +2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)). +3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). +4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. +5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. + +#### Reporting results + +Paste this into your PR: + +~~~markdown +## Manual test results + +**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] + +| Command tested | Notes | +|----------------|-------| +| `/speckit.command` | | +~~~ + +#### Determining which tests to run + +Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. + +~~~text +Read CONTRIBUTING.md, then run `git diff --name-only main` to get my changed files. +For each changed file, determine which slash commands it affects by reading +the command templates in templates/commands/ to understand what each command +invokes. Use these mapping rules: + +- templates/commands/X.md → the command it defines +- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected +- templates/Z-template.md → every command that consumes that template during execution +- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify +- extensions/X/commands/* → the extension command it defines +- extensions/X/scripts/* → every extension command that invokes that script +- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected +- presets/*/* → test preset scaffolding via `specify init` with the preset +- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets + +Include prerequisite tests (e.g., T5 requires T3 requires T1). + +Output in this format: + +### Test selection reasoning + +| Changed file | Affects | Test | Why | +|---|---|---|---| +| (path) | (command) | T# | (reason) | + +### Required tests + +Number each test sequentially (T1, T2, ...). List prerequisite tests first. + +- T1: /speckit.command — (reason) +- T2: /speckit.command — (reason) +~~~ ## AI contributions in Spec Kit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dc35bc6fe0..946e071e31 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,8 +11,7 @@ Spec Kit is a toolkit for spec-driven development. At its core, it is a coordina | [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. | | [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. | | [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, and required development practices. | -| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. | **Main repository components:** diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 1fa6b1c881..0000000000 --- a/TESTING.md +++ /dev/null @@ -1,133 +0,0 @@ -# Testing Guide - -This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -Use it for three things: - -1. running quick automated checks before manual testing, -2. manually testing affected slash commands through an AI agent, and -3. capturing the results in a PR-friendly format. - -Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. - -## Recommended order - -1. **Sync your environment** — install the project and test dependencies. -2. **Run focused automated checks** — especially for packaging, scaffolding, agent config, and generated-file changes. -3. **Run manual agent tests** — for any affected slash commands. -4. **Paste results into your PR** — include both command-selection reasoning and manual test results. - -## Quick automated checks - -Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring. - -### Environment setup - -```bash -cd -uv sync --extra test -source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 -``` - -### Generated package structure and content - -```bash -uv run python -m pytest tests/test_core_pack_scaffold.py -q -``` - -This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`. - -### Agent configuration and release wiring consistency - -```bash -uv run python -m pytest tests/test_agent_config_consistency.py -q -``` - -Run this when you change agent metadata, release scripts, context update scripts, or artifact naming. - -### Optional single-agent packaging spot check - -```bash -AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0 -``` - -Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination. - -## Manual testing process - -1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. -2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)). -3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). -4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. -5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. - -## Setup - -```bash -# Install the project and test dependencies from your local branch -cd -uv sync --extra test -source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 -uv pip install -e . -# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. - -# Initialize a test project using your local changes -uv run specify init /tmp/speckit-test --ai --offline -cd /tmp/speckit-test - -# Open in your agent -``` - -If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -## Reporting results - -Paste this into your PR: - -~~~markdown -## Manual test results - -**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] - -| Command tested | Notes | -|----------------|-------| -| `/speckit.command` | | -~~~ - -## Determining which tests to run - -Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. - -~~~text -Read TESTING.md, then run `git diff --name-only main` to get my changed files. -For each changed file, determine which slash commands it affects by reading -the command templates in templates/commands/ to understand what each command -invokes. Use these mapping rules: - -- templates/commands/X.md → the command it defines -- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected -- templates/Z-template.md → every command that consumes that template during execution -- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify -- extensions/X/commands/* → the extension command it defines -- extensions/X/scripts/* → every extension command that invokes that script -- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected -- presets/*/* → test preset scaffolding via `specify init` with the preset -- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets - -Include prerequisite tests (e.g., T5 requires T3 requires T1). - -Output in this format: - -### Test selection reasoning - -| Changed file | Affects | Test | Why | -|---|---|---|---| -| (path) | (command) | T# | (reason) | - -### Required tests - -Number each test sequentially (T1, T2, ...). List prerequisite tests first. - -- T1: /speckit.command — (reason) -- T2: /speckit.command — (reason) -~~~ From 8fc2bd3489c86ee38110baa1198ae27850f74c96 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:35:05 -0500 Subject: [PATCH 256/321] fix: allow Claude to chain skills for hook execution (#2227) * fix: allow Claude to chain skills for hook execution (#2178) - Set disable-model-invocation to false so Claude can invoke extension skills (e.g. speckit-git-feature) from within workflow skills - Inject dot-to-hyphen normalization note into Claude SKILL.md hook sections so the model maps extension.yml command names to skill names - Replace Unicode checkmark with ASCII [OK] in auto-commit scripts to fix PowerShell encoding errors on Windows - Move Claude-specific frontmatter injection to ClaudeIntegration via post_process_skill_content() hook on SkillsIntegration, wired through presets and extensions managers - Add positive and negative tests for all changes Fixes #2178 * refactor: address PR review feedback - Preserve line-ending style (CRLF/LF) in _inject_hook_command_note instead of always inserting \n, matching the convention used by other injection helpers in the same module. - Extract duplicated _post_process_skill() from extensions.py and presets.py into a shared post_process_skill() function in agents.py. Both modules now import and call the shared helper. * fix: match full hook instruction line in regex The regex in _inject_hook_command_note only matched lines ending immediately after 'output the following', but the actual template lines continue with 'based on its `optional` flag:'. Use [^\r\n]* to capture the rest of the line before the EOL. * refactor: use integration object directly for post_process_skill_content Instead of a free function in agents.py that re-resolves the integration by key, callers in extensions.py and presets.py now resolve the integration once via get_integration() and call integration.post_process_skill_content() directly. The base identity method lives on SkillsIntegration. --- extensions/git/scripts/bash/auto-commit.sh | 2 +- .../git/scripts/powershell/auto-commit.ps1 | 2 +- src/specify_cli/agents.py | 5 - src/specify_cli/extensions.py | 6 + src/specify_cli/integrations/base.py | 10 ++ .../integrations/claude/__init__.py | 55 +++++++- src/specify_cli/presets.py | 16 +++ tests/extensions/git/test_git_extension.py | 56 +++++++++ tests/integrations/test_integration_claude.py | 118 +++++++++++++++++- tests/test_extension_skills.py | 2 +- tests/test_presets.py | 4 +- 11 files changed, 257 insertions(+), 19 deletions(-) diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index 49c32fe634..f0b423187b 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -137,4 +137,4 @@ fi _git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } _git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } -echo "✓ Changes committed ${_phase} ${_command_name}" >&2 +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index e9777ff9be..2229ed2b8d 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -146,4 +146,4 @@ try { exit 1 } -Write-Host "✓ Changes committed $phase $commandName" +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c177e2a27c..32fc6cdbf0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -317,11 +317,6 @@ def build_skill_frontmatter( "source": source, }, } - if agent_name == "claude": - # Claude skills should be user-invocable (accessible via /command) - # and only run when explicitly invoked (not auto-triggered by the model). - skill_frontmatter["user-invocable"] = True - skill_frontmatter["disable-model-invocation"] = True return skill_frontmatter @staticmethod diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 852699c39b..d5543cd0b4 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -850,6 +850,7 @@ def _register_extension_skills( from . import load_init_options from .agents import CommandRegistrar + from .integrations import get_integration import yaml written: List[str] = [] @@ -860,6 +861,7 @@ def _register_extension_skills( if not isinstance(selected_ai, str) or not selected_ai: return [] registrar = CommandRegistrar() + integration = get_integration(selected_ai) for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -939,6 +941,10 @@ def _register_extension_skills( f"# {title_name} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") written.append(skill_name) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 26501e623f..2c01e25b0e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1102,6 +1102,16 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: invocation = f"{invocation} {args}" return invocation + def post_process_skill_content(self, content: str) -> str: + """Post-process a SKILL.md file's content after generation. + + Called by external skill generators (presets, extensions) to let + the integration inject agent-specific frontmatter or body + transformations. The default implementation returns *content* + unchanged. Subclasses may override — see ``ClaudeIntegration``. + """ + return content + def setup( self, project_root: Path, diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0e..3e39db717e 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,11 +5,21 @@ from pathlib import Path from typing import Any +import re + import yaml from ..base import SkillsIntegration from ..manifest import IntegrationManifest +# Note injected into hook sections so Claude maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + # Mapping of command template stem → argument-hint text shown inline # when a user invokes the slash command in Claude Code. ARGUMENT_HINTS: dict[str, str] = { @@ -148,6 +158,43 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags and hook notes.""" + updated = self._inject_frontmatter_flag(content, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + updated = self._inject_hook_command_note(updated) + return updated + def setup( self, project_root: Path, @@ -155,7 +202,7 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint.""" + """Install Claude skills, then inject Claude-specific flags and argument-hints.""" created = super().setup(project_root, manifest, parsed_options, **opts) # Post-process generated skill files @@ -173,11 +220,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - # Inject user-invocable: true (Claude skills are accessible via /command) - updated = self._inject_frontmatter_flag(content, "user-invocable") - - # Inject disable-model-invocation: true (Claude skills run only when invoked) - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation") + updated = self.post_process_skill_content(content) # Inject argument-hint if available for this skill skill_dir_name = path.parent.name # e.g. "speckit-plan" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 3a0f469a77..d5513c8323 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -707,6 +707,7 @@ def _register_skills( from . import SKILL_DESCRIPTIONS, load_init_options from .agents import CommandRegistrar + from .integrations import get_integration init_opts = load_init_options(self.project_root) if not isinstance(init_opts, dict): @@ -716,6 +717,7 @@ def _register_skills( return [] ai_skills_enabled = bool(init_opts.get("ai_skills")) registrar = CommandRegistrar() + integration = get_integration(selected_ai) agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new # preset skills in _register_commands() because their detected agent @@ -789,6 +791,10 @@ def _register_skills( f"# Speckit {skill_title} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file = skill_subdir / "SKILL.md" skill_file.write_text(skill_content, encoding="utf-8") @@ -816,6 +822,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: from . import SKILL_DESCRIPTIONS, load_init_options from .agents import CommandRegistrar + from .integrations import get_integration # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" @@ -824,6 +831,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: init_opts = {} selected_ai = init_opts.get("ai") registrar = CommandRegistrar() + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None extension_restore_index = self._build_extension_skill_restore_index() for skill_name in skill_names: @@ -877,6 +885,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: f"# Speckit {skill_title} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") continue @@ -906,6 +918,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: f"# {title_name} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") else: # No core or extension template — remove the skill entirely diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 50ab9c7b6b..a04acba10b 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -491,6 +491,34 @@ def test_requires_event_name_argument(self, tmp_path: Path): result = _run_bash("auto-commit.sh", project) assert result.returncode != 0 + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.sh success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stderr + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.sh must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stderr, "Must not use Unicode checkmark" + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestAutoCommitPowerShell: @@ -523,6 +551,34 @@ def test_enabled_per_command(self, tmp_path: Path): ) assert "ps commit" in log.stdout + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.ps1 success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stdout + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.ps1 must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stdout, "Must not use Unicode checkmark" + # ── git-common.sh Tests ────────────────────────────────────────────────────── diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df176..d3b01097fc 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -59,7 +59,7 @@ def test_setup_creates_skill_files(self, tmp_path): parsed = yaml.safe_load(parts[1]) assert parsed["name"] == "speckit-plan" assert parsed["user-invocable"] is True - assert parsed["disable-model-invocation"] is True + assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" def test_setup_installs_update_context_scripts(self, tmp_path): @@ -179,7 +179,7 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): assert skill_file.exists() skill_content = skill_file.read_text(encoding="utf-8") assert "user-invocable: true" in skill_content - assert "disable-model-invocation: true" in skill_content + assert "disable-model-invocation: false" in skill_content init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") @@ -280,7 +280,7 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): assert "preset:claude-skill-command" in content assert "name: speckit-research" in content assert "user-invocable: true" in content - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content metadata = manager.registry.get("claude-skill-command") assert "speckit-research" in metadata.get("registered_skills", []) @@ -400,3 +400,115 @@ def test_inject_argument_hint_skips_if_already_present(self): lines = result.splitlines() hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + + +class TestClaudeDisableModelInvocation: + """Verify disable-model-invocation is false for Claude skills.""" + + def test_setup_sets_disable_model_invocation_false(self, tmp_path): + """Generated SKILL.md files must have disable-model-invocation: false.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["disable-model-invocation"] is False, ( + f"{f.parent.name}: expected disable-model-invocation: false" + ) + + def test_disable_model_invocation_not_true(self, tmp_path): + """No Claude skill should have disable-model-invocation: true.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + for f in created: + if f.name != "SKILL.md": + continue + content = f.read_text(encoding="utf-8") + assert "disable-model-invocation: true" not in content, ( + f"{f.parent.name}: must not have disable-model-invocation: true" + ) + + def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): + """Non-Claude skill agents should not get disable-model-invocation.""" + from specify_cli.agents import CommandRegistrar + + fm = CommandRegistrar.build_skill_frontmatter( + "codex", "speckit-plan", "desc", "templates/commands/plan.md" + ) + assert "disable-model-invocation" not in fm + assert "user-invocable" not in fm + + def test_non_claude_post_process_is_identity(self, tmp_path): + """Non-Claude integrations should not modify skill content.""" + codex = get_integration("codex") + if codex is None: + return # codex not registered in this build + content = "---\nname: test\n---\nBody" + assert codex.post_process_skill_content(content) == content + + +class TestClaudeHookCommandNote: + """Verify dot-to-hyphen normalization note is injected in hook sections.""" + + def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): + """Skills that have hook sections should get the normalization note.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" + assert specify_skill.exists() + content = specify_skill.read_text(encoding="utf-8") + # specify.md has hook sections + assert "replace dots" in content, ( + "speckit-specify should have dot-to-hyphen hook note" + ) + + def test_hook_note_not_in_skills_without_hooks(self, tmp_path): + """Skills without hook sections should not get the note.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" + result = ClaudeIntegration._inject_hook_command_note(content) + assert "replace dots" not in result + + def test_hook_note_idempotent(self, tmp_path): + """Injecting the note twice should not duplicate it.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\nname: test\n---\n\n" + "- For each executable hook, output the following based on its flag:\n" + ) + once = ClaudeIntegration._inject_hook_command_note(content) + twice = ClaudeIntegration._inject_hook_command_note(once) + assert once == twice, "Hook note injection should be idempotent" + + def test_hook_note_preserves_indentation(self, tmp_path): + """The injected note should match the indentation of the target line.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\nname: test\n---\n\n" + " - For each executable hook, output the following\n" + ) + result = ClaudeIntegration._inject_hook_command_note(content) + lines = result.splitlines() + note_line = [l for l in lines if "replace dots" in l][0] + assert note_line.startswith(" "), "Note should preserve indentation" + + def test_post_process_injects_all_claude_flags(self): + """post_process_skill_content should inject all Claude-specific fields.""" + i = get_integration("claude") + content = ( + "---\nname: test\ndescription: test\n---\n\n" + "- For each executable hook, output the following\n" + ) + result = i.post_process_skill_content(content) + assert "user-invocable: true" in result + assert "disable-model-invocation: false" in result + assert "replace dots" in result diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a9f19e74e..c9d13382ab 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -269,7 +269,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): assert isinstance(parsed, dict) assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed - assert parsed["disable-model-invocation"] is True + assert parsed["disable-model-invocation"] is False def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): """No skills should be created when ai_skills is false.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index c7383a1f49..b883d554b0 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1975,7 +1975,7 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content # Verify it was recorded in registry metadata = manager.registry.get("self-test") @@ -2057,7 +2057,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" From 27b4fd2e328efb4b10d1c8936738cabcd958be83 Mon Sep 17 00:00:00 2001 From: Ayesha Aziz <163914368+ayesha-aziz123@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:16:21 +0500 Subject: [PATCH 257/321] docs: remove deprecated --skip-tls references from local-development guide (#2231) * docs: remove deprecated --skip-tls references from local-development guide * docs: refine wording and fix formatting for deprecated --skip-tls * docs: polish TLS guidance wording --- docs/local-development.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/local-development.md b/docs/local-development.md index 7fac06adf4..a23ea1d88f 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -128,16 +128,14 @@ python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script Or copy only the modified CLI portion if you want a lighter sandbox. -## 9. Debug Network / TLS Skips +## 9. Debug Network / TLS Issues -If you need to bypass TLS validation while experimenting: - -```bash -specify check --skip-tls -specify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps -``` - -(Use only for local experimentation.) +> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect. +> It was previously used to bypass TLS validation during local testing. +> If you encounter TLS errors (e.g., on a corporate network), configure your +> environment's certificate store or proxy instead. +> +> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`. ## 10. Rapid Edit Loop Summary @@ -166,7 +164,7 @@ rm -rf .venv dist build *.egg-info | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Git step skipped | You passed `--no-git` or Git not installed | | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | -| TLS errors on corporate network | Try `--skip-tls` (not for production) | +| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | ## 13. Next Steps From 9988a46d96f7b3fe5e006327fb70d4de74eed217 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:37:27 -0500 Subject: [PATCH 258/321] ci: add windows-latest to test matrix (#2233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add windows-latest to test matrix Add windows-latest to the pytest job OS matrix so tests run on both Ubuntu and Windows for all Python versions. Closes #2232 * test: skip bash-specific tests on Windows Add sys.platform skip markers to all test classes and methods that execute bash scripts via subprocess, so they are skipped on Windows where bash is not available. Mixed classes with both bash and pwsh tests have markers on individual bash methods only. * test: fix 3 Windows-specific test failures - test_manifest: use platform-appropriate absolute path (C:\ on Windows vs /tmp on POSIX) since /tmp is not absolute on Windows - test_extensions: add agent_scripts.ps entry and platform-conditional assertions for codex skill fallback variant test - test_timestamp_branches: use json.dumps() instead of f-string to properly escape Windows backslash paths in feature.json * test: extract requires_bash marker and fix PS test skip Address PR review feedback: - Define a reusable requires_bash marker in conftest.py and use it across all 3 test files instead of repeating the skipif inline - Move test_powershell_scanner_uses_long_tryparse_for_large_prefixes into its own TestSequentialBranchPowerShell class so it is not incorrectly skipped on Windows by the class-level bash marker * test: use runtime bash check instead of platform check Replace sys.platform == 'win32' with an actual bash invocation test to handle environments where bash exists but is non-functional (e.g., WSL stub on Windows without an installed distro). * test: reject WSL bash, accept only MSYS/MINGW on Windows On Windows, verify uname -s reports MSYS, MINGW, or CYGWIN so the WSL launcher (System32\bash.exe) is rejected — it cannot handle native Windows paths used by test fixtures. Add SPECKIT_TEST_BASH=1 env var escape hatch to force-enable bash tests in non-standard setups. * ci: add comment explaining Windows bash test behavior * test: early-reject WSL launcher, fix remaining f-string JSON - Check resolved bash path for System32 before spawning any subprocess to avoid WSL init prompts and timeout during test collection - Convert remaining feature_json f-string writes to json.dumps() so paths with backslashes produce valid JSON on Windows * test: use bare 'bash' for detection to match test invocation On Windows, subprocess.run(['bash', ...]) uses CreateProcess which searches System32 before PATH — finding WSL bash even when shutil.which('bash') returns Git-for-Windows. Probe with bare 'bash' (same as test helpers) so the detection matches actual test behavior. --- .github/workflows/test.yml | 7 ++- tests/conftest.py | 58 ++++++++++++++++++++++ tests/extensions/git/test_git_extension.py | 6 +++ tests/integrations/test_manifest.py | 4 +- tests/test_cursor_frontmatter.py | 3 ++ tests/test_extensions.py | 10 +++- tests/test_timestamp_branches.py | 25 ++++++++-- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b039f02b..44b0269887 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,9 +27,10 @@ jobs: run: uvx ruff check src/ pytest: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest] python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout @@ -46,5 +47,9 @@ jobs: - name: Install dependencies run: uv sync --extra test + # On windows-latest, bash tests auto-skip unless Git-for-Windows + # bash (MSYS2/MINGW) is detected. The WSL launcher is rejected + # because it cannot handle native Windows paths in test fixtures. + # See tests/conftest.py::_has_working_bash() for details. - name: Run tests run: uv run pytest diff --git a/tests/conftest.py b/tests/conftest.py index 4387c9ac8f..9e8ffaae59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,68 @@ """Shared test helpers for the Spec Kit test suite.""" +import os import re +import shutil +import subprocess +import sys + +import pytest _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +def _has_working_bash() -> bool: + """Check whether a functional native bash is available. + + On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess, + which searches System32 *before* PATH — so it may find the WSL + launcher even when Git-for-Windows bash appears first in PATH via + ``shutil.which``. We therefore probe with bare ``"bash"`` (the + same way test helpers invoke it) to get an accurate result. + + On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted. + The WSL launcher is rejected because it runs in a separate Linux + filesystem and cannot handle native Windows paths used by the + test fixtures. + + Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless. + """ + if os.environ.get("SPECKIT_TEST_BASH") == "1": + return True + if shutil.which("bash") is None: + return False + # Probe with bare "bash" — same as the test helpers — so that + # Windows CreateProcess resolution order is respected. + try: + r = subprocess.run( + ["bash", "-c", "echo ok"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode != 0 or "ok" not in r.stdout: + return False + except (OSError, subprocess.TimeoutExpired): + return False + # On Windows, verify we have MSYS/MINGW bash (Git for Windows), + # not the WSL launcher which can't handle native paths. + if sys.platform == "win32": + try: + u = subprocess.run( + ["bash", "-c", "uname -s"], + capture_output=True, text=True, timeout=5, + ) + kernel = u.stdout.strip().upper() + if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")): + return False + except (OSError, subprocess.TimeoutExpired): + return False + return True + + +requires_bash = pytest.mark.skipif( + not _has_working_bash(), reason="working bash not available" +) + + def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index a04acba10b..30694fc9d8 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -18,6 +18,8 @@ import pytest +from tests.conftest import requires_bash + PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "git" EXT_BASH = EXT_DIR / "scripts" / "bash" @@ -211,6 +213,7 @@ def test_bundled_extension_locator(self): # ── initialize-repo.sh Tests ───────────────────────────────────────────────── +@requires_bash class TestInitializeRepoBash: def test_initializes_git_repo(self, tmp_path: Path): """initialize-repo.sh creates a git repo with initial commit.""" @@ -269,6 +272,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path): # ── create-new-feature.sh Tests ────────────────────────────────────────────── +@requires_bash class TestCreateFeatureBash: def test_creates_branch_sequential(self, tmp_path: Path): """Extension create-new-feature.sh creates sequential branch.""" @@ -376,6 +380,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): # ── auto-commit.sh Tests ───────────────────────────────────────────────────── +@requires_bash class TestAutoCommitBash: def test_disabled_by_default(self, tmp_path: Path): """auto-commit.sh exits silently when config is all false.""" @@ -583,6 +588,7 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path): # ── git-common.sh Tests ────────────────────────────────────────────────────── +@requires_bash class TestGitCommonBash: def test_has_git_true(self, tmp_path: Path): """has_git returns 0 in a git repo.""" diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index b5d5bc39f5..596397d4f7 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -2,6 +2,7 @@ import hashlib import json +import sys import pytest @@ -41,8 +42,9 @@ def test_record_file_rejects_parent_traversal(self, tmp_path): def test_record_file_rejects_absolute_path(self, tmp_path): m = IntegrationManifest("test", tmp_path) + abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt" with pytest.raises(ValueError, match="Absolute paths"): - m.record_file("/tmp/escape.txt", "bad") + m.record_file(abs_path, "bad") def test_record_existing_rejects_parent_traversal(self, tmp_path): escape = tmp_path.parent / "escape.txt" diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py index d9d0e34237..9f8c31ce10 100644 --- a/tests/test_cursor_frontmatter.py +++ b/tests/test_cursor_frontmatter.py @@ -12,6 +12,8 @@ import pytest +from tests.conftest import requires_bash + SCRIPT_PATH = os.path.join( os.path.dirname(__file__), os.pardir, @@ -73,6 +75,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self): @requires_git +@requires_bash class TestCursorFrontmatterIntegration: """Integration tests using a real git repo.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bec939702f..460404d597 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ import pytest import json +import platform import tempfile import shutil import tomllib @@ -1452,6 +1453,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti ps: ../../scripts/powershell/setup-plan.ps1 -Json agent_scripts: sh: ../../scripts/bash/update-agent-context.sh __AGENT__ + ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__ --- Run {SCRIPT} @@ -1473,8 +1475,12 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content assert "{AGENT_SCRIPT}" not in content - assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content + if platform.system().lower().startswith("win"): + assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content + assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content + else: + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index b258fa98d1..39228d9455 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -13,6 +13,8 @@ import pytest +from tests.conftest import requires_bash + PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" @@ -149,6 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl # ── Timestamp Branch Tests ─────────────────────────────────────────────────── +@requires_bash class TestTimestampBranch: def test_timestamp_creates_branch(self, git_repo: Path): """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" @@ -194,6 +197,7 @@ def test_long_name_truncation(self, git_repo: Path): # ── Sequential Branch Tests ────────────────────────────────────────────────── +@requires_bash class TestSequentialBranch: def test_sequential_default_with_existing_specs(self, git_repo: Path): """Test 2: Sequential default with existing specs.""" @@ -232,6 +236,8 @@ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path): branch = line.split(":", 1)[1].strip() assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}" + +class TestSequentialBranchPowerShell: def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): """PowerShell scanner should parse large prefixes without [int] casts.""" content = CREATE_FEATURE_PS.read_text(encoding="utf-8") @@ -242,6 +248,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): # ── check_feature_branch Tests ─────────────────────────────────────────────── +@requires_bash class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -306,6 +313,7 @@ def test_rejects_malformed_timestamp_with_prefix(self): # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── +@requires_bash class TestFindFeatureDirByPrefix: def test_timestamp_branch(self, tmp_path: Path): """Test 10: find_feature_dir_by_prefix with timestamp branch.""" @@ -356,6 +364,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): class TestGetFeaturePathsSinglePrefix: + @requires_bash def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" (tmp_path / ".specify").mkdir() @@ -399,6 +408,7 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): # ── get_current_branch Tests ───────────────────────────────────────────────── +@requires_bash class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -409,6 +419,7 @@ def test_env_var(self): # ── No-git Tests ───────────────────────────────────────────────────────────── +@requires_bash class TestNoGitTimestamp: def test_no_git_timestamp(self, no_git_dir: Path): """Test 13: No-git repo + timestamp creates spec dir with warning.""" @@ -422,6 +433,7 @@ def test_no_git_timestamp(self, no_git_dir: Path): # ── E2E Flow Tests ─────────────────────────────────────────────────────────── +@requires_bash class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -455,6 +467,7 @@ def test_e2e_sequential(self, git_repo: Path): # ── Allow Existing Branch Tests ────────────────────────────────────────────── +@requires_bash class TestAllowExistingBranch: def test_allow_existing_switches_to_branch(self, git_repo: Path): """T006: Pre-create branch, verify script switches to it.""" @@ -655,6 +668,7 @@ def test_powershell_extension_surfaces_checkout_errors(self): # ── Dry-Run Tests ──────────────────────────────────────────────────────────── +@requires_bash class TestDryRun: def test_dry_run_sequential_outputs_name(self, git_repo: Path): """T009: Dry-run computes correct branch name with existing specs.""" @@ -984,6 +998,7 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── +@requires_bash class TestGitBranchNameOverrideBash: """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" @@ -1088,6 +1103,7 @@ def test_overlong_name_rejected(self, ext_ps_git_repo: Path): class TestFeatureDirectoryResolution: """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + @requires_bash def test_env_var_overrides_branch_lookup(self, git_repo: Path): """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" custom_dir = git_repo / "my-custom-specs" / "my-feature" @@ -1110,6 +1126,7 @@ def test_env_var_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_feature_json_overrides_branch_lookup(self, git_repo: Path): """feature.json feature_directory takes priority over branch-based lookup.""" custom_dir = git_repo / "specs" / "custom-feature" @@ -1117,7 +1134,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{custom_dir}"}}\n', + json.dumps({"feature_directory": str(custom_dir)}) + "\n", encoding="utf-8", ) @@ -1136,6 +1153,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): """Env var wins over feature.json.""" env_dir = git_repo / "specs" / "env-feature" @@ -1145,7 +1163,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{json_dir}"}}\n', + json.dumps({"feature_directory": str(json_dir)}) + "\n", encoding="utf-8", ) @@ -1165,6 +1183,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_fallback_to_branch_lookup(self, git_repo: Path): """Without env var or feature.json, falls back to branch-based lookup.""" subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True) @@ -1219,7 +1238,7 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{custom_dir}"}}\n', + json.dumps({"feature_directory": str(custom_dir)}) + "\n", encoding="utf-8", ) From 752683d347ead385a4d94bf2415eef5ddf1939b7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:41:23 -0500 Subject: [PATCH 259/321] chore: release 0.7.1, begin 0.7.2.dev0 development (#2235) * chore: bump version to 0.7.1 * chore: begin 0.7.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe587098f2..c362aee259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ +## [0.7.1] - 2026-04-15 + +### Changed + +- ci: add windows-latest to test matrix (#2233) +- docs: remove deprecated --skip-tls references from local-development guide (#2231) +- fix: allow Claude to chain skills for hook execution (#2227) +- docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228) +- Add agent-assign extension to community catalog (#2030) +- fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027) +- feat: register architect-preview in community catalog (#2214) +- chore: deprecate --ai flag in favor of --integration on specify init (#2218) +- chore: release 0.7.0, begin 0.7.1.dev0 development (#2217) + ## [0.7.0] - 2026-04-14 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e7ea248214..d7208fa389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.1.dev0" +version = "0.7.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From db8131441eb70371950acea6f9157028bccd1ccb Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Thu, 16 Apr 2026 05:49:15 -0700 Subject: [PATCH 260/321] Added issues extension (#2194) * Added issues extension * Removed duplicate extension * Renamed extension * Addressed Copilot comments --------- Co-authored-by: Aaron Sun Co-authored-by: Aaron Sun Co-authored-by: Aaron Sun --- README.md | 3 ++- extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 031d663476..9985212ff2 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,8 @@ The following community-contributed extensions are available in [`catalog.commun | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | -| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | +| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | +| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6589093444..f82747a744 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -714,7 +714,7 @@ "updated_at": "2026-03-31T00:00:00Z" }, "github-issues": { - "name": "GitHub Issues Integration", + "name": "GitHub Issues Integration 1", "id": "github-issues", "description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability", "author": "Fatima367", @@ -753,6 +753,38 @@ "created_at": "2026-04-12T15:30:00Z", "updated_at": "2026-04-13T14:39:00Z" }, + "issue": { + "name": "GitHub Issues Integration 2", + "id": "issue", + "description": "Creates and syncs local specs based on an existing issue in GitHub", + "author": "aaronrsun", + "version": "1.0.0", + "download_url": "https://github.com/aaronrsun/spec-kit-issue/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-issue", + "homepage": "https://github.com/aaronrsun/spec-kit-issue", + "documentation": "https://github.com/aaronrsun/spec-kit-issue/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-issue/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "issue", + "integration", + "github", + "issues", + "sync" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-04T00:00:00Z", + "updated_at": "2026-04-04T00:00:00Z" + }, "iterate": { "name": "Iterate", "id": "iterate", From e0fd355dad45a0b63a376d85d210f4a5d8ca973b Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 16 Apr 2026 18:57:25 +0500 Subject: [PATCH 261/321] Add Catalog CI extension to community catalog (#2239) - Adds catalog-ci entry to catalog.community.json (between canon and ci-guard) - Adds Catalog CI row to community extensions table in README.md - Bumps top-level updated_at --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9985212ff2..b48c829491 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun | Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | +| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f82747a744..9e06b8e255 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-14T21:30:00Z", + "updated_at": "2026-04-16T18:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -301,6 +301,38 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "catalog-ci": { + "name": "Catalog CI", + "id": "catalog-ci", + "description": "Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "ci", + "validation", + "catalog", + "quality", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, "ci-guard": { "name": "CI Guard", "id": "ci-guard", From 282dd3da569b95053cafe4573b5ce6c24dc610fa Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:59:32 -0500 Subject: [PATCH 262/321] =?UTF-8?q?feat:=20Integration=20catalog=20?= =?UTF-8?q?=E2=80=94=20discovery,=20versioning,=20and=20community=20distri?= =?UTF-8?q?bution=20(#2130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add integration catalog system with catalog files, IntegrationCatalog class, list --catalog flag, upgrade command, integration.yml descriptor, and tests Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bbcd44e8-c69c-4735-adc1-bdf1ce109184 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: address PR review feedback - Replace empty except with cache cleanup in _fetch_single_catalog - Log teardown failure warning instead of silent pass in upgrade - Validate catalog_data and integrations are dicts before use - Catch OSError/UnicodeError in IntegrationDescriptor._load - Add isinstance checks for integration/requires/provides/commands - Enforce semver (X.Y.Z) instead of PEP 440 for descriptor versions - Fix docstring and CONTRIBUTING.md to match actual block-on-modified behavior - Restore old manifest on upgrade failure for transactional safety * refactor: address second round of PR review feedback - Remove dead cache_file/cache_metadata_file attributes from IntegrationCatalog - Deduplicate non-default catalog warning (show once per process) - Anchor version regex to reject partial matches like 1.0.0beta - Fix 'Preserved modified' message to 'Skipped' for accuracy - Make upgrade transactional: install new files first, then remove stale old-only files, so a failed setup leaves old integration intact - Update CONTRIBUTING.md: speckit_version validates presence only * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address third round of PR review feedback - Fix CONTRIBUTING.md JSON examples to show full catalog structure with schema_version and integrations wrapper - Wrap cache writes in try/except OSError for read-only project dirs - Validate _load_catalog_config YAML root is a dict - Skip non-dict integ_data entries in merged catalog - Normalize tags to list-of-strings before filtering/searching - Add path traversal containment check for stale file deletion - Clarify docstring: lower numeric priority = higher precedence * fix: address fourth round of PR review feedback - Remove unused _write_catalog helper from test file - Fix comment: tests use monkeypatched urlopen, not file:// URLs - Wrap cache unlink calls in OSError handler - Add explicit encoding='utf-8' to all cache read_text/write_text calls - Restore packaging.version.Version for descriptor version validation to align with extension/preset validators - Add missing goose entry to integrations/catalog.json * fix: remove unused Path import, add comment to empty except * fix: validate descriptor root is dict, add shared infra to upgrade - Add isinstance(self.data, dict) check at start of _validate() so non-mapping YAML roots raise IntegrationDescriptorError - Run _install_shared_infra() and ensure_executable_scripts() in upgrade command to match install/switch behavior * fix: address sixth round of PR review feedback - Validate integration.id/name/version/description are strings - Catch TypeError in pkg_version.Version() for non-string versions - Swap validation order: check catalogs type before emptiness - Isolate TestActiveCatalogs from user ~/.specify/ via monkeypatch * fix: address seventh round of PR review feedback - Update docs: version field uses PEP 440, not semver - Harden search() against non-string author/name/description fields - Validate requires.speckit_version is a non-empty string - Validate command name/file are non-empty strings, file is safe relative path - Handle stale symlinks in upgrade cleanup - Document catalog configuration stack in README.md * fix: validate script entries, remove destructive teardown from upgrade rollback - Validate provides.scripts entries are non-empty strings with safe relative paths - Remove teardown from upgrade rollback since setup overwrites in-place — teardown would delete files that were working before the upgrade * fix: use consistent resolved root for stale-file cleanup paths * fix: validate redirect URL and reject drive-qualified paths - Validate final URL after redirects with _validate_catalog_url() - Reject paths with Path.drive or Path.anchor for Windows safety - Update FakeResponse mocks with geturl() method * fix: fix docstring backticks, assert file modification in upgrade tests * docs: clarify directory naming convention for hyphenated integration keys * fix: correct key type hint, isolate all catalog tests from env - Fix key parameter type to str | None (defaults to None) - Add HOME/USERPROFILE monkeypatch and clear SPECKIT_INTEGRATION_CATALOG_URL in all TestCatalogFetch tests for full environment isolation * fix: neutralize catalog table title, handle non-dict cache metadata * fix: validate requires.tools entries in descriptor * fix: show discovery-only status, clear metadata files in clear_cache * fix: catch OSError/UnicodeError in cache read path * refactor: reuse IntegrationManifest.uninstall for stale-file cleanup * fix: normalize null tools to empty list in descriptor accessor --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- integrations/CONTRIBUTING.md | 142 ++++ integrations/README.md | 129 ++++ integrations/catalog.community.json | 6 + integrations/catalog.json | 259 +++++++ src/specify_cli/__init__.py | 162 ++++- src/specify_cli/integrations/catalog.py | 626 +++++++++++++++++ .../integrations/test_integration_catalog.py | 656 ++++++++++++++++++ 7 files changed, 1979 insertions(+), 1 deletion(-) create mode 100644 integrations/CONTRIBUTING.md create mode 100644 integrations/README.md create mode 100644 integrations/catalog.community.json create mode 100644 integrations/catalog.json create mode 100644 src/specify_cli/integrations/catalog.py create mode 100644 tests/integrations/test_integration_catalog.py diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md new file mode 100644 index 0000000000..77a50d4d98 --- /dev/null +++ b/integrations/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# Contributing to the Integration Catalog + +This guide covers adding integrations to both the **built-in** and **community** catalogs. + +## Adding a Built-In Integration + +Built-in integrations are maintained by the Spec Kit core team and ship with the CLI. + +### Checklist + +1. **Create the integration subpackage** under `src/specify_cli/integrations//` + — `` matches the integration key when it contains no hyphens (e.g., `gemini`), or replaces hyphens with underscores when it does (e.g., key `cursor-agent` → directory `cursor_agent/`, key `kiro-cli` → directory `kiro_cli/`). Python package names cannot use hyphens. +2. **Implement the integration class** extending `MarkdownIntegration`, `TomlIntegration`, or `SkillsIntegration` +3. **Register the integration** in `src/specify_cli/integrations/__init__.py` +4. **Add tests** under `tests/integrations/test_integration_.py` +5. **Add a catalog entry** in `integrations/catalog.json` +6. **Update documentation** in `AGENTS.md` and `README.md` + +### Catalog Entry Format + +Add your integration under the top-level `integrations` key in `integrations/catalog.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} +``` + +## Adding a Community Integration + +Community integrations are contributed by external developers and listed in `integrations/catalog.community.json` for discovery. + +### Prerequisites + +1. **Working integration** — tested with `specify integration install` +2. **Public repository** — hosted on GitHub or similar +3. **`integration.yml` descriptor** — valid descriptor file (see below) +4. **Documentation** — README with usage instructions +5. **License** — open source license file + +### `integration.yml` Descriptor + +Every community integration must include an `integration.yml`: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "your-name" + repository: "https://github.com/your-name/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + scripts: + - update-context.sh +``` + +### Descriptor Validation Rules + +| Field | Rule | +|-------|------| +| `schema_version` | Must be `"1.0"` | +| `integration.id` | Lowercase alphanumeric + hyphens (`^[a-z0-9-]+$`) | +| `integration.version` | Valid PEP 440 version (parsed with `packaging.version.Version()`) | +| `requires.speckit_version` | Required field; specify a version constraint such as `>=0.6.0` (current validation checks presence only) | +| `provides` | Must include at least one command or script | +| `provides.commands[].name` | String identifier | +| `provides.commands[].file` | Relative path to template file | + +### Submitting to the Community Catalog + +1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit) +2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "your-name", + "repository": "https://github.com/your-name/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +3. **Open a pull request** with: + - Your catalog entry + - Link to your integration repository + - Confirmation that `integration.yml` is valid + +### Version Updates + +To update your integration version in the catalog: + +1. Release a new version of your integration +2. Open a PR updating the `version` field in `catalog.community.json` +3. Ensure backward compatibility or document breaking changes + +## Upgrade Workflow + +The `specify integration upgrade` command supports diff-aware upgrades: + +1. **Hash comparison** — the manifest records SHA-256 hashes of all installed files +2. **Modified file detection** — files changed since installation are flagged +3. **Safe default** — the upgrade blocks if any installed files were modified since installation +4. **Forced reinstall** — passing `--force` overwrites modified files with the latest version + +```bash +# Upgrade current integration (blocks if files are modified) +specify integration upgrade + +# Force upgrade (overwrites modified files) +specify integration upgrade --force +``` diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 0000000000..b755e0416d --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,129 @@ +# Spec Kit Integration Catalog + +The integration catalog enables discovery, versioning, and distribution of AI agent integrations for Spec Kit. + +## Catalog Files + +### Built-In Catalog (`catalog.json`) + +Contains integrations that ship with Spec Kit. These are maintained by the core team and always installable. + +### Community Catalog (`catalog.community.json`) + +Community-contributed integrations. Listed for discovery only — users install from the source repositories. + +## Catalog Configuration + +The catalog stack is resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs with a single URL +2. **Project config** — `.specify/integration-catalogs.yml` in the project root +3. **User config** — `~/.specify/integration-catalogs.yml` in the user home directory +4. **Built-in defaults** — `catalog.json` + `catalog.community.json` + +Example `integration-catalogs.yml`: + +```yaml +catalogs: + - url: "https://example.com/my-catalog.json" + name: "my-catalog" + priority: 1 + install_allowed: true +``` + +## CLI Commands + +```bash +# List built-in integrations (default) +specify integration list + +# Browse full catalog (built-in + community) +specify integration list --catalog + +# Install an integration +specify integration install copilot + +# Upgrade the current integration (diff-aware) +specify integration upgrade + +# Upgrade with force (overwrite modified files) +specify integration upgrade --force +``` + +## Integration Descriptor (`integration.yml`) + +Each integration can include an `integration.yml` descriptor that documents its metadata, requirements, and provided commands/scripts: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + repository: "https://github.com/my-org/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + - name: "speckit.plan" + file: "templates/speckit.plan.md" + scripts: + - update-context.sh + - update-context.ps1 +``` + +## Catalog Schema + +Both catalog files follow the same JSON schema: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://...", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + "repository": "https://github.com/my-org/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema_version` | string | Must be `"1.0"` | +| `updated_at` | string | ISO 8601 timestamp | +| `integrations` | object | Map of integration ID → metadata | + +### Integration Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Unique ID (lowercase alphanumeric + hyphens) | +| `name` | string | Yes | Human-readable display name | +| `version` | string | Yes | PEP 440 version (e.g., `1.0.0`, `1.0.0a1`) | +| `description` | string | Yes | One-line description | +| `author` | string | No | Author name or organization | +| `repository` | string | No | Source repository URL | +| `tags` | array | No | Searchable tags (e.g., `["cli", "ide"]`) | + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add integrations to the community catalog. diff --git a/integrations/catalog.community.json b/integrations/catalog.community.json new file mode 100644 index 0000000000..47eb6d550d --- /dev/null +++ b/integrations/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json", + "integrations": {} +} diff --git a/integrations/catalog.json b/integrations/catalog.json new file mode 100644 index 0000000000..3df96b8789 --- /dev/null +++ b/integrations/catalog.json @@ -0,0 +1,259 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", + "integrations": { + "claude": { + "id": "claude", + "name": "Claude Code", + "version": "1.0.0", + "description": "Anthropic Claude Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "anthropic"] + }, + "copilot": { + "id": "copilot", + "name": "GitHub Copilot", + "version": "1.0.0", + "description": "GitHub Copilot IDE integration with agent commands and prompt files", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "github"] + }, + "gemini": { + "id": "gemini", + "name": "Gemini CLI", + "version": "1.0.0", + "description": "Google Gemini CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "google"] + }, + "cursor-agent": { + "id": "cursor-agent", + "name": "Cursor", + "version": "1.0.0", + "description": "Cursor IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "windsurf": { + "id": "windsurf", + "name": "Windsurf", + "version": "1.0.0", + "description": "Windsurf IDE workflow integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "amp": { + "id": "amp", + "name": "Amp", + "version": "1.0.0", + "description": "Amp CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "codex": { + "id": "codex", + "name": "Codex CLI", + "version": "1.0.0", + "description": "Codex CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "qwen": { + "id": "qwen", + "name": "Qwen Code", + "version": "1.0.0", + "description": "Alibaba Qwen Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "alibaba"] + }, + "opencode": { + "id": "opencode", + "name": "opencode", + "version": "1.0.0", + "description": "opencode CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "forge": { + "id": "forge", + "name": "Forge", + "version": "1.0.0", + "description": "Forge CLI integration with parameter-based commands", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kiro-cli": { + "id": "kiro-cli", + "name": "Kiro CLI", + "version": "1.0.0", + "description": "Kiro CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "junie": { + "id": "junie", + "name": "Junie", + "version": "1.0.0", + "description": "Junie by JetBrains CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "jetbrains"] + }, + "auggie": { + "id": "auggie", + "name": "Auggie CLI", + "version": "1.0.0", + "description": "Auggie CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "shai": { + "id": "shai", + "name": "SHAI", + "version": "1.0.0", + "description": "SHAI CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "tabnine": { + "id": "tabnine", + "name": "Tabnine CLI", + "version": "1.0.0", + "description": "Tabnine CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kilocode": { + "id": "kilocode", + "name": "Kilo Code", + "version": "1.0.0", + "description": "Kilo Code IDE workflow integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "roo": { + "id": "roo", + "name": "Roo Code", + "version": "1.0.0", + "description": "Roo Code IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "bob": { + "id": "bob", + "name": "IBM Bob", + "version": "1.0.0", + "description": "IBM Bob IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "ibm"] + }, + "trae": { + "id": "trae", + "name": "Trae", + "version": "1.0.0", + "description": "Trae IDE rules-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "codebuddy": { + "id": "codebuddy", + "name": "CodeBuddy", + "version": "1.0.0", + "description": "CodeBuddy CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "qodercli": { + "id": "qodercli", + "name": "Qoder CLI", + "version": "1.0.0", + "description": "Qoder CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kimi": { + "id": "kimi", + "name": "Kimi Code", + "version": "1.0.0", + "description": "Kimi Code CLI skills-based integration by Moonshot AI", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "pi": { + "id": "pi", + "name": "Pi Coding Agent", + "version": "1.0.0", + "description": "Pi terminal coding agent prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "iflow": { + "id": "iflow", + "name": "iFlow CLI", + "version": "1.0.0", + "description": "iFlow CLI integration by iflow-ai", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "vibe": { + "id": "vibe", + "name": "Mistral Vibe", + "version": "1.0.0", + "description": "Mistral Vibe CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "mistral"] + }, + "agy": { + "id": "agy", + "name": "Antigravity", + "version": "1.0.0", + "description": "Antigravity IDE skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "skills"] + }, + "generic": { + "id": "generic", + "name": "Generic (bring your own agent)", + "version": "1.0.0", + "description": "Generic integration for any agent via --ai-commands-dir", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["generic"] + }, + "goose": { + "id": "goose", + "name": "Goose", + "version": "1.0.0", + "description": "Goose CLI integration with YAML recipe format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b7b0c909a5..9f895cb3b9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1775,7 +1775,9 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: @integration_app.command("list") -def integration_list(): +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY @@ -1790,6 +1792,50 @@ def integration_list(): current = _read_integration_json(project_root) installed_key = current.get("integration") + if catalog: + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + + for entry in sorted(entries, key=lambda e: e["id"]): + eid = entry["id"] + cat_name = entry.get("_catalog_name", "") + install_allowed = entry.get("_install_allowed", True) + if eid == installed_key: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + ) + + console.print(table) + return + table = Table(title="AI Agent Integrations") table.add_column("Key", style="cyan") table.add_column("Name") @@ -2176,6 +2222,120 @@ def integration_switch( console.print(f"\n[green]✓[/green] Switched to integration '{name}'") +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if installed_key and installed_key != key: + console.print( + f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." + ) + console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + new_manifest.save() + _write_integration_json(project_root, key, selected_script) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + + # ===== Preset Commands ===== diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py new file mode 100644 index 0000000000..2faa69ae96 --- /dev/null +++ b/src/specify_cli/integrations/catalog.py @@ -0,0 +1,626 @@ +"""Integration catalog — discovery, validation, and upgrade support. + +Provides: +- ``IntegrationCatalogEntry`` — single catalog source metadata. +- ``IntegrationCatalog`` — fetches, caches, and searches integration + catalogs (built-in + community). +- ``IntegrationDescriptor`` — loads and validates ``integration.yml``. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from packaging import version as pkg_version + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class IntegrationCatalogError(Exception): + """Raised when a catalog operation fails.""" + + +class IntegrationDescriptorError(Exception): + """Raised when an integration.yml descriptor is invalid.""" + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + +@dataclass +class IntegrationCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog +# --------------------------------------------------------------------------- + +class IntegrationCatalog: + """Manages integration catalog fetching, caching, and searching.""" + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.cache_dir = project_root / ".specify" / "integrations" / ".cache" + + # -- URL validation --------------------------------------------------- + + @staticmethod + def _validate_catalog_url(url: str) -> None: + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise IntegrationCatalogError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise IntegrationCatalogError( + "Catalog URL must be a valid URL with a host." + ) + + # -- Catalog stack ---------------------------------------------------- + + def _load_catalog_config( + self, config_path: Path + ) -> Optional[List[IntegrationCatalogEntry]]: + """Load catalog stack from a YAML file. + + Returns None when the file does not exist. + + Raises: + IntegrationCatalogError: on invalid content + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationCatalogError( + f"Failed to read catalog config {config_path}: {exc}" + ) + if not isinstance(data, dict): + raise IntegrationCatalogError( + f"Invalid catalog config {config_path}: expected a YAML mapping at the root" + ) + catalogs_data = data.get("catalogs", []) + if not isinstance(catalogs_data, list): + raise IntegrationCatalogError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + raise IntegrationCatalogError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) + entries: List[IntegrationCatalogEntry] = [] + skipped: List[int] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise IntegrationCatalogError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + skipped.append(idx) + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise IntegrationCatalogError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append( + IntegrationCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise IntegrationCatalogError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs (entries at indices {skipped} " + f"were skipped). Each catalog entry must have a 'url' field." + ) + return entries + + def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: + """Return the ordered list of active integration catalogs. + + Resolution: + 1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var + 2. Project ``.specify/integration-catalogs.yml`` + 3. User ``~/.specify/integration-catalogs.yml`` + 4. Built-in defaults (built-in + community) + """ + import sys + + env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + if env_value: + self._validate_catalog_url(env_value) + if env_value != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default integration catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [ + IntegrationCatalogEntry( + url=env_value, + name="custom", + priority=1, + install_allowed=True, + description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL", + ) + ] + + project_cfg = self.project_root / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(project_cfg) + if catalogs is not None: + return catalogs + + user_cfg = Path.home() / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(user_cfg) + if catalogs is not None: + return catalogs + + return [ + IntegrationCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable integrations", + ), + IntegrationCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed integrations (discovery only)", + ), + ] + + # -- Fetching --------------------------------------------------------- + + def _fetch_single_catalog( + self, + entry: IntegrationCatalogEntry, + force_refresh: bool = False, + ) -> Dict[str, Any]: + """Fetch one catalog, with per-URL caching.""" + import urllib.error + import urllib.request + + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json" + + if not force_refresh and cache_file.exists() and cache_meta.exists(): + try: + meta = json.loads(cache_meta.read_text(encoding="utf-8")) + cached_at = datetime.fromisoformat(meta.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + if age < self.CACHE_DURATION: + return json.loads(cache_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError): + # Cache is invalid or stale metadata; delete and refetch from source. + try: + cache_file.unlink(missing_ok=True) + cache_meta.unlink(missing_ok=True) + except OSError: + pass # Cache cleanup is best-effort; ignore deletion failures. + + try: + with urllib.request.urlopen(entry.url, timeout=10) as resp: + # Validate final URL after redirects + final_url = resp.geturl() + if final_url != entry.url: + self._validate_catalog_url(final_url) + catalog_data = json.loads(resp.read()) + + if not isinstance(catalog_data, dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: expected a JSON object" + ) + if ( + "schema_version" not in catalog_data + or "integrations" not in catalog_data + ): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}" + ) + if not isinstance(catalog_data.get("integrations"), dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object" + ) + + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8") + cache_meta.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, + indent=2, + ), + encoding="utf-8", + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + return catalog_data + + except urllib.error.URLError as exc: + raise IntegrationCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) + except json.JSONDecodeError as exc: + raise IntegrationCatalogError( + f"Invalid JSON in catalog from {entry.url}: {exc}" + ) + + def _get_merged_integrations( + self, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Fetch and merge integrations from all active catalogs. + + Catalogs are processed in the order returned by + :meth:`get_active_catalogs`. On conflicts, the first catalog in that + order wins (lower numeric priority = higher precedence). Each dict is + annotated with ``_catalog_name`` and ``_install_allowed``. + """ + import sys + + active = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for entry in active: + try: + data = self._fetch_single_catalog(entry, force_refresh) + any_success = True + except IntegrationCatalogError as exc: + print( + f"Warning: Could not fetch catalog '{entry.name}': {exc}", + file=sys.stderr, + ) + continue + + for integ_id, integ_data in data.get("integrations", {}).items(): + if not isinstance(integ_data, dict): + continue + if integ_id not in merged: + merged[integ_id] = { + **integ_data, + "id": integ_id, + "_catalog_name": entry.name, + "_install_allowed": entry.install_allowed, + } + + if not any_success and active: + raise IntegrationCatalogError( + "Failed to fetch any integration catalog" + ) + + return list(merged.values()) + + # -- Search / info ---------------------------------------------------- + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalogs for integrations matching the given filters.""" + results: List[Dict[str, Any]] = [] + for item in self._get_merged_integrations(): + author_val = item.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author and author_val.lower() != author.lower(): + continue + if tag: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]: + continue + if query: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = item.get("name", "") + desc_val = item.get("description", "") + id_val = item.get("id", "") + haystack = " ".join( + [ + str(name_val) if name_val else "", + str(desc_val) if desc_val else "", + str(id_val) if id_val else "", + ] + + [t for t in tags_list if isinstance(t, str)] + ).lower() + if query.lower() not in haystack: + continue + results.append(item) + return results + + def get_integration_info( + self, integration_id: str + ) -> Optional[Dict[str, Any]]: + """Return catalog metadata for a single integration, or None.""" + for item in self._get_merged_integrations(): + if item["id"] == integration_id: + return item + return None + + # -- Cache management ------------------------------------------------- + + def clear_cache(self) -> None: + """Remove all cached catalog files.""" + if self.cache_dir.exists(): + for pattern in ("catalog-*.json", "catalog-*-metadata.json"): + for f in self.cache_dir.glob(pattern): + f.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +class IntegrationDescriptor: + """Loads and validates an ``integration.yml`` descriptor. + + The descriptor mirrors ``extension.yml`` and ``preset.yml``:: + + schema_version: "1.0" + integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + requires: + speckit_version: ">=0.6.0" + tools: [...] + provides: + commands: [...] + scripts: [...] + """ + + SCHEMA_VERSION = "1.0" + REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] + + def __init__(self, descriptor_path: Path) -> None: + self.path = descriptor_path + self.data = self._load(descriptor_path) + self._validate() + + # -- Loading ---------------------------------------------------------- + + @staticmethod + def _load(path: Path) -> dict: + try: + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + except FileNotFoundError: + raise IntegrationDescriptorError(f"Descriptor not found: {path}") + except (OSError, UnicodeError) as exc: + raise IntegrationDescriptorError( + f"Unable to read descriptor {path}: {exc}" + ) + + # -- Validation ------------------------------------------------------- + + def _validate(self) -> None: + if not isinstance(self.data, dict): + raise IntegrationDescriptorError( + f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}" + ) + for field in self.REQUIRED_TOP_LEVEL: + if field not in self.data: + raise IntegrationDescriptorError( + f"Missing required field: {field}" + ) + + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise IntegrationDescriptorError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + integ = self.data["integration"] + if not isinstance(integ, dict): + raise IntegrationDescriptorError( + "'integration' must be a mapping" + ) + for field in ("id", "name", "version", "description"): + if field not in integ: + raise IntegrationDescriptorError( + f"Missing integration.{field}" + ) + if not isinstance(integ[field], str): + raise IntegrationDescriptorError( + f"integration.{field} must be a string, got {type(integ[field]).__name__}" + ) + + if not re.match(r"^[a-z0-9-]+$", integ["id"]): + raise IntegrationDescriptorError( + f"Invalid integration ID '{integ['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + try: + pkg_version.Version(integ["version"]) + except (pkg_version.InvalidVersion, TypeError): + raise IntegrationDescriptorError( + f"Invalid version '{integ['version']}'" + ) + + requires = self.data["requires"] + if not isinstance(requires, dict): + raise IntegrationDescriptorError( + "'requires' must be a mapping" + ) + if "speckit_version" not in requires: + raise IntegrationDescriptorError( + "Missing requires.speckit_version" + ) + if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip(): + raise IntegrationDescriptorError( + "requires.speckit_version must be a non-empty string" + ) + tools = requires.get("tools") + if tools is not None: + if not isinstance(tools, list): + raise IntegrationDescriptorError( + "requires.tools must be a list" + ) + for tool in tools: + if not isinstance(tool, dict): + raise IntegrationDescriptorError( + "Each requires.tools entry must be a mapping" + ) + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name.strip(): + raise IntegrationDescriptorError( + "requires.tools entry 'name' must be a non-empty string" + ) + + provides = self.data["provides"] + if not isinstance(provides, dict): + raise IntegrationDescriptorError( + "'provides' must be a mapping" + ) + commands = provides.get("commands", []) + scripts = provides.get("scripts", []) + if "commands" in provides and not isinstance(commands, list): + raise IntegrationDescriptorError( + "Invalid provides.commands: expected a list" + ) + if "scripts" in provides and not isinstance(scripts, list): + raise IntegrationDescriptorError( + "Invalid provides.scripts: expected a list" + ) + if not commands and not scripts: + raise IntegrationDescriptorError( + "Integration must provide at least one command or script" + ) + for cmd in commands: + if not isinstance(cmd, dict): + raise IntegrationDescriptorError( + "Each command entry must be a mapping" + ) + if "name" not in cmd or "file" not in cmd: + raise IntegrationDescriptorError( + "Command entry missing 'name' or 'file'" + ) + cmd_name = cmd["name"] + cmd_file = cmd["file"] + if not isinstance(cmd_name, str) or not cmd_name.strip(): + raise IntegrationDescriptorError( + "Command entry 'name' must be a non-empty string" + ) + if not isinstance(cmd_file, str) or not cmd_file.strip(): + raise IntegrationDescriptorError( + "Command entry 'file' must be a non-empty string" + ) + if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor: + raise IntegrationDescriptorError( + f"Command entry 'file' must be a relative path without '..': {cmd_file}" + ) + for script_entry in scripts: + if not isinstance(script_entry, str) or not script_entry.strip(): + raise IntegrationDescriptorError( + "Script entry must be a non-empty string" + ) + if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor: + raise IntegrationDescriptorError( + f"Script entry must be a relative path without '..': {script_entry}" + ) + + # -- Property accessors ----------------------------------------------- + + @property + def id(self) -> str: + return self.data["integration"]["id"] + + @property + def name(self) -> str: + return self.data["integration"]["name"] + + @property + def version(self) -> str: + return self.data["integration"]["version"] + + @property + def description(self) -> str: + return self.data["integration"]["description"] + + @property + def requires_speckit_version(self) -> str: + return self.data["requires"]["speckit_version"] + + @property + def commands(self) -> List[Dict[str, Any]]: + return self.data.get("provides", {}).get("commands", []) + + @property + def scripts(self) -> List[str]: + return self.data.get("provides", {}).get("scripts", []) + + @property + def tools(self) -> List[Dict[str, Any]]: + return self.data.get("requires", {}).get("tools") or [] + + def get_hash(self) -> str: + """SHA-256 hash of the descriptor file.""" + with open(self.path, "rb") as fh: + return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}" diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py new file mode 100644 index 0000000000..3d0a14acdc --- /dev/null +++ b/tests/integrations/test_integration_catalog.py @@ -0,0 +1,656 @@ +"""Tests for the integration catalog system (catalog.py).""" + +import json +import os + +import pytest +import yaml + +from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + IntegrationDescriptor, + IntegrationDescriptorError, +) + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + + +class TestIntegrationCatalogEntry: + def test_create_entry(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — URL validation +# --------------------------------------------------------------------------- + + +class TestCatalogURLValidation: + def test_https_allowed(self): + IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json") + + def test_http_rejected(self): + with pytest.raises(IntegrationCatalogError, match="HTTPS"): + IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json") + + def test_http_localhost_allowed(self): + IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json") + IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json") + + def test_missing_host_rejected(self): + with pytest.raises(IntegrationCatalogError, match="valid URL"): + IntegrationCatalog._validate_catalog_url("https:///no-host") + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — active catalogs +# --------------------------------------------------------------------------- + + +class TestActiveCatalogs: + def test_defaults_when_no_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[1].name == "community" + + def test_env_var_override(self, tmp_path, monkeypatch): + (tmp_path / ".specify").mkdir() + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + + def test_project_config_overrides_defaults(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({ + "catalogs": [ + {"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True}, + ] + })) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "mine" + + def test_empty_config_raises(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({"catalogs": []})) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"): + cat.get_active_catalogs() + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses) +# --------------------------------------------------------------------------- + + +class TestCatalogFetch: + """Tests that use a local HTTP server stub via monkeypatch.""" + + def _patch_urlopen(self, monkeypatch, catalog_data): + """Patch urllib.request.urlopen to return *catalog_data*.""" + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def fake_urlopen(url, timeout=10): + return FakeResponse(catalog_data, url) + + import urllib.request + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + def test_fetch_and_search_all(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "acme-coder": { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli"], + }, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search() + assert len(results) >= 1 + ids = [r["id"] for r in results] + assert "acme-coder" in ids + + def test_search_by_tag(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]}, + "b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(tag="cli") + assert all("cli" in r.get("tags", []) for r in results) + + def test_search_by_query(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []}, + "gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(query="claude") + assert len(results) == 1 + assert results[0]["id"] == "claude" + + def test_get_integration_info(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + info = cat.get_integration_info("claude") + assert info is not None + assert info["name"] == "Claude Code" + + assert cat.get_integration_info("nonexistent") is None + + def test_invalid_catalog_format(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations" + + with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"): + cat.search() + + def test_clear_cache(self, tmp_path): + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + cat.cache_dir.mkdir(parents=True, exist_ok=True) + (cat.cache_dir / "catalog-abc123.json").write_text("{}") + cat.clear_cache() + assert not list(cat.cache_dir.glob("catalog-*.json")) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +VALID_DESCRIPTOR = { + "schema_version": "1.0", + "integration": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + }, + "requires": { + "speckit_version": ">=0.6.0", + }, + "provides": { + "commands": [ + {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, + ], + "scripts": ["update-context.sh"], + }, +} + + +class TestIntegrationDescriptor: + def _write(self, tmp_path, data): + p = tmp_path / "integration.yml" + p.write_text(yaml.dump(data)) + return p + + def test_valid_descriptor(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + assert desc.id == "my-agent" + assert desc.name == "My Agent" + assert desc.version == "1.0.0" + assert desc.description == "Integration for My Agent" + assert desc.requires_speckit_version == ">=0.6.0" + assert len(desc.commands) == 1 + assert desc.scripts == ["update-context.sh"] + + def test_missing_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR} + del data["schema_version"] + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): + IntegrationDescriptor(p) + + def test_unsupported_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "schema_version": "99.0"} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"): + IntegrationDescriptor(p) + + def test_missing_integration_id(self, tmp_path): + data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"): + IntegrationDescriptor(p) + + def test_invalid_id_format(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"): + IntegrationDescriptor(p) + + def test_invalid_version(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid version"): + IntegrationDescriptor(p) + + def test_missing_speckit_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"): + IntegrationDescriptor(p) + + def test_no_commands_or_scripts(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="at least one command or script"): + IntegrationDescriptor(p) + + def test_command_missing_name(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"): + IntegrationDescriptor(p) + + def test_commands_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_scripts_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_file_not_found(self, tmp_path): + with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"): + IntegrationDescriptor(tmp_path / "nonexistent.yml") + + def test_invalid_yaml(self, tmp_path): + p = tmp_path / "integration.yml" + p.write_text(": : :") + with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"): + IntegrationDescriptor(p) + + def test_get_hash(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + h = desc.get_hash() + assert h.startswith("sha256:") + + def test_tools_accessor(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": { + "speckit_version": ">=0.6.0", + "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}], + }} + p = self._write(tmp_path, data) + desc = IntegrationDescriptor(p) + assert len(desc.tools) == 1 + assert desc.tools[0]["name"] == "my-agent" + + +# --------------------------------------------------------------------------- +# CLI: integration list --catalog +# --------------------------------------------------------------------------- + + +class TestIntegrationListCatalog: + """Test ``specify integration list --catalog``.""" + + def _init_project(self, tmp_path): + """Create a minimal spec-kit project.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_list_catalog_flag(self, tmp_path, monkeypatch): + """--catalog should show catalog entries.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "test-agent": { + "id": "test-agent", + "name": "Test Agent", + "version": "1.0.0", + "description": "A test agent", + "tags": ["cli"], + }, + }, + } + + import urllib.request + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + def read(self): + return self._data + def geturl(self): + return self._url + def __enter__(self): + return self + def __exit__(self, *a): + pass + + monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url)) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list", "--catalog"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "test-agent" in result.output + assert "Test Agent" in result.output + + def test_list_without_catalog_still_works(self, tmp_path): + """Default list (no --catalog) works as before.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + +# --------------------------------------------------------------------------- +# CLI: integration upgrade +# --------------------------------------------------------------------------- + + +class TestIntegrationUpgrade: + """Test ``specify integration upgrade``.""" + + def _init_project(self, tmp_path, integration="copilot"): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_upgrade_requires_speckit_project(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_upgrade_no_integration_installed(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "No integration is currently installed" in result.output + + def test_upgrade_succeeds(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_blocks_on_modified_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file so the manifest hash won't match + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + assert manifest_path.exists(), "Manifest should exist after init" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "modified" in result.output.lower() + + def test_upgrade_force_overwrites_modified(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_wrong_integration_key(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "not the currently installed integration" in result.output + + def test_upgrade_no_manifest(self, tmp_path): + """Upgrade with missing manifest suggests fresh install.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Remove manifest + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + if manifest_path.exists(): + manifest_path.unlink() + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "Nothing to upgrade" in result.output From c717cbb42d4f9dbc07c2afdf24b7126602916bad Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 16 Apr 2026 23:31:56 +0800 Subject: [PATCH 263/321] feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240) --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9e06b8e255..17bf6f70e2 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1140,8 +1140,8 @@ "id": "memorylint", "description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.", "author": "RbBtSn0w", - "version": "1.0.0", - "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/archive/refs/tags/v1.0.0.zip", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip", "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint", "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md", @@ -1165,7 +1165,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-09T00:00:00Z", - "updated_at": "2026-04-09T00:00:00Z" + "updated_at": "2026-04-16T13:10:26Z" }, "onboard": { "name": "Onboard", @@ -1893,8 +1893,8 @@ "id": "superb", "description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.", "author": "rbbtsn0w", - "version": "1.0.0", - "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip", "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions", "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md", @@ -1929,7 +1929,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-30T00:00:00Z", - "updated_at": "2026-03-30T00:00:00Z" + "updated_at": "2026-04-16T14:08:23Z" }, "sync": { "name": "Spec Sync", From 530d1ce514be55d10f7d79a9b7772c7234dae134 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:29:46 -0500 Subject: [PATCH 264/321] docs: consolidate integration documentation into docs/integrations.md (#2241) - New docs/integrations.md: canonical reference for supported agents table (with keys), list/install/uninstall/switch/upgrade commands, file preservation behavior, and integration-specific options - README.md: replace inline agents table with summary + link to new page; normalize heading to 'Supported AI Coding Agent Integrations' - docs/toc.yml: add top-level 'Reference' section with Integrations page - docs/upgrade.md: fix broken cross-reference, update terminology - CONTRIBUTING.md: update anchor link to new heading --- CONTRIBUTING.md | 2 +- README.md | 41 +++------------ docs/integrations.md | 118 +++++++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 6 +++ docs/upgrade.md | 4 +- 5 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 docs/integrations.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ce19657ea..c44063b16f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ These are one time installations required to be able to test your changes locall 1. Install [Python 3.11+](https://www.python.org/downloads/) 1. Install [uv](https://docs.astral.sh/uv/) for package management 1. Install [Git](https://git-scm.com/downloads) -1. Have an [AI coding agent available](README.md#-supported-ai-agents) +1. Have an [AI coding agent available](README.md#-supported-ai-coding-agent-integrations)
💡 Hint if you are using VSCode or GitHub Codespaces as your IDE diff --git a/README.md b/README.md index b48c829491..101ffe5553 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - [🎨 Community Presets](#-community-presets) - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🛠️ Community Friends](#️-community-friends) -- [🤖 Supported AI Agents](#-supported-ai-agents) +- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations) - [🔧 Specify CLI Reference](#-specify-cli-reference) - [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) - [📚 Core Philosophy](#-core-philosophy) @@ -314,38 +314,11 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. -## 🤖 Supported AI Agents -| Agent | Support | Notes | -| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [Qoder CLI](https://qoder.com/cli) | ✅ | | -| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | -| [Amp](https://ampcode.com/) | ✅ | | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | -| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. | -| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | -| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | -| [Cursor](https://cursor.sh/) | ✅ | | -| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | -| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support | -| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | -| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | -| [Jules](https://jules.google.com/) | ✅ | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | | -| [opencode](https://opencode.ai/) | ✅ | | -| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | -| [Roo Code](https://roocode.com/) | ✅ | | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | -| [Kimi Code](https://code.kimi.com/) | ✅ | | -| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | | -| [Windsurf](https://windsurf.com/) | ✅ | | -| [Junie](https://junie.jetbrains.com/) | ✅ | | -| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | -| [Trae](https://www.trae.ai/) | ✅ | | -| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | +## 🤖 Supported AI Coding Agent Integrations + +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](docs/integrations.md) guide. + +Run `specify integration list` to see all available integrations in your installed version. ## Available Slash Commands @@ -611,7 +584,7 @@ Our research and experimentation focus on: ## 🔧 Prerequisites - **Linux/macOS/Windows** -- [Supported](#-supported-ai-agents) AI coding agent. +- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. - [uv](https://docs.astral.sh/uv/) for package management - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000000..35a0b09e19 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,118 @@ +# Supported AI Coding Agent Integrations + +The Specify CLI supports a wide range of AI coding agents. When you run `specify init`, the CLI sets up the appropriate command files, context rules, and directory structures for your chosen AI coding agent — so you can start using Spec-Driven Development immediately, regardless of which tool you prefer. + +## Supported AI Coding Agents + +| Agent | Key | Notes | +| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [Amp](https://ampcode.com/) | `amp` | | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | +| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| [Roo Code](https://roocode.com/) | `roo` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Windsurf](https://windsurf.com/) | `windsurf` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | + +## List Available Integrations + +```bash +specify integration list +``` + +Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based. + +## Install an Integration + +```bash +specify integration install +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | + +Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state. + +> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. + +## Uninstall an Integration + +```bash +specify integration uninstall [] +``` + +| Option | Description | +| --------- | --------------------------------------------------- | +| `--force` | Remove files even if they have been modified | + +Uninstalls the current integration (or the specified one). Spec Kit tracks every file created during install along with a SHA-256 hash of the original content: + +- **Unmodified files** are removed automatically. +- **Modified files** (where you've made manual edits) are preserved so your customizations are not lost. +- Use `--force` to remove all integration files regardless of modifications. + +## Switch to a Different Integration + +```bash +specify integration switch +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--force` | Force removal of modified files during uninstall | +| `--integration-options` | Options for the target integration | + +Equivalent to running `uninstall` followed by `install` in a single step. + +## Upgrade an Integration + +```bash +specify integration upgrade [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--force` | Overwrite files even if they have been modified | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Options for the integration | + +Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. + +## Integration-Specific Options + +Some integrations accept additional options via `--integration-options`: + +| Integration | Option | Description | +| ----------- | ------------------- | -------------------------------------------------------------- | +| `generic` | `--commands-dir` | Required. Directory for command files | +| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | + +Example: + +```bash +specify integration install generic --integration-options="--commands-dir .myagent/cmds" +``` diff --git a/docs/toc.yml b/docs/toc.yml index 18650cb571..53d3c10884 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -12,6 +12,12 @@ - name: Upgrade href: upgrade.md +# Reference +- name: Reference + items: + - name: Integrations + href: integrations.md + # Development workflows - name: Development items: diff --git a/docs/upgrade.md b/docs/upgrade.md index aecbb7879b..e08c0b93ed 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,7 +76,7 @@ Run this inside your project directory: specify init --here --force --ai ``` -Replace `` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents) +Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](integrations.md) **Example:** @@ -401,7 +401,7 @@ The `specify` CLI tool is used for: - **Upgrades:** `specify init --here --force` to update templates and commands - **Diagnostics:** `specify check` to verify tool installation -Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. +Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again. **If your agent isn't recognizing slash commands:** From 076bb40f2ec618ab81439845b74ef4ea485d8a45 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:07:36 -0500 Subject: [PATCH 265/321] docs: add extensions reference page and integrations FAQ (#2242) - New docs/extensions.md: command reference for all 9 specify extension commands and 3 specify extension catalog commands, plus catalog resolution order, extension configuration, and FAQ - docs/integrations.md: add FAQ section covering single-integration limit, file preservation, key discovery, CLI vs IDE requirements, upgrade vs switch - docs/toc.yml: add Extensions under Reference section - README.md: update integration and extension links to published docs site --- README.md | 4 +- docs/extensions.md | 201 +++++++++++++++++++++++++++++++++++++++++++ docs/integrations.md | 22 +++++ docs/toc.yml | 2 + 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 docs/extensions.md diff --git a/README.md b/README.md index 101ffe5553..38d5fb3028 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Community projects that extend, visualize, or build on Spec Kit: ## 🤖 Supported AI Coding Agent Integrations -Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](docs/integrations.md) guide. +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/integrations.html) guide. Run `specify integration list` to see all available integrations in your installed version. @@ -510,7 +510,7 @@ specify extension add For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. -See the [Extensions README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available. +See the [Extensions reference](https://github.github.io/spec-kit/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. ### Presets — Customize Existing Workflows diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000000..923d0b9b82 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,201 @@ +# Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They introduce new commands and templates that go beyond the built-in Spec-Driven Development workflow. + +## Search Available Extensions + +```bash +specify extension search [query] +``` + +| Option | Description | +| ------------ | ------------------------------------ | +| `--tag` | Filter by tag | +| `--author` | Filter by author | +| `--verified` | Show only verified extensions | + +Searches all active catalogs for extensions matching the query. Without a query, lists all available extensions. + +## Install an Extension + +```bash +specify extension add +``` + +| Option | Description | +| --------------- | -------------------------------------------------------- | +| `--dev` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority `| Resolution priority (default: 10; lower = higher precedence) | + +Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All extension commands require a project already initialized with `specify init`. + +## Remove an Extension + +```bash +specify extension remove +``` + +| Option | Description | +| --------------- | ---------------------------------------------- | +| `--keep-config` | Preserve configuration files during removal | +| `--force` | Skip confirmation prompt | + +Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. + +## List Installed Extensions + +```bash +specify extension list +``` + +| Option | Description | +| ------------- | -------------------------------------------------- | +| `--available` | Show available (uninstalled) extensions | +| `--all` | Show both installed and available extensions | + +Lists installed extensions with their status, version, and command counts. + +## Extension Info + +```bash +specify extension info +``` + +Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. + +## Update Extensions + +```bash +specify extension update [] +``` + +Updates a specific extension, or all installed extensions if no name is given. + +## Enable / Disable an Extension + +```bash +specify extension enable +specify extension disable +``` + +Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Re-enable with `enable`. + +## Set Extension Priority + +```bash +specify extension set-priority +``` + +Changes the resolution priority of an extension. When multiple extensions provide a command with the same name, the extension with the lowest priority number takes precedence. + +## Catalog Management + +Extension catalogs control where `search` and `add` look for extensions. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify extension catalog list +``` + +Shows all active catalogs in the stack with their priorities and install permissions. + +### Add a Catalog + +```bash +specify extension catalog add +``` + +| Option | Description | +| ------------------------------------ | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/extension-catalogs.yml`. + +### Remove a Catalog + +```bash +specify extension catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/extension-catalogs.yml` +3. **User config** — `~/.specify/extension-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/extension-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-catalog" + url: "https://example.com/catalog.json" + priority: 5 + install_allowed: true + description: "Our approved extensions" +``` + +## Extension Configuration + +Most extensions include configuration files in their install directory: + +```text +.specify/extensions// +├── -config.yml # Project config (version controlled) +├── -config.local.yml # Local overrides (gitignored) +└── -config.template.yml # Template reference +``` + +Configuration is merged in this order (highest priority last): + +1. **Extension defaults** (from `extension.yml`) +2. **Project config** (`-config.yml`) +3. **Local overrides** (`-config.local.yml`) +4. **Environment variables** (`SPECKIT__*`) + +To set up configuration for a newly installed extension, copy the template: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +## FAQ + +### Why can't I find an extension with `search`? + +Check the spelling of the extension name. The extension may not be published yet, or it may be in a catalog you haven't added. Use `specify extension catalog list` to see which catalogs are active. + +### Why doesn't the extension command appear in my AI coding agent? + +Verify the extension is installed and enabled with `specify extension list`. If it shows as installed, restart your AI coding agent — it may need to reload for it to take effect. + +### How do I set up extension configuration? + +Copy the config template that ships with the extension: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +See [Extension Configuration](#extension-configuration) for details on config layers and overrides. + +### How do I resolve an incompatible version error? + +Update Spec Kit to the version required by the extension. + +### Who maintains extensions? + +Most extensions are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support extension code. Review an extension's source code before installing and use at your own discretion. For issues with a specific extension, contact its author or file an issue on the extension's repository. diff --git a/docs/integrations.md b/docs/integrations.md index 35a0b09e19..dcb9a2b354 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -116,3 +116,25 @@ Example: ```bash specify integration install generic --integration-options="--commands-dir .myagent/cmds" ``` + +## FAQ + +### Can I use multiple integrations at the same time? + +No. Only one AI coding agent integration can be installed per project. Use `specify integration switch ` to change to a different AI coding agent. + +### What happens to my changes when I uninstall or switch? + +Files you've modified are preserved automatically. Only unmodified files (matching their original SHA-256 hash) are removed. Use `--force` to override this. + +### How do I know which key to use? + +Run `specify integration list` to see all available integrations with their keys, or check the [Supported AI Coding Agents](#supported-ai-coding-agents) table above. + +### Do I need the AI coding agent installed to use an integration? + +CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Windsurf, Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is. + +### When should I use `upgrade` vs `switch`? + +Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent. diff --git a/docs/toc.yml b/docs/toc.yml index 53d3c10884..b65fcac9c8 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -17,6 +17,8 @@ items: - name: Integrations href: integrations.md + - name: Extensions + href: extensions.md # Development workflows - name: Development From 8d2797dc03943d103e00ebf09a170781edc206c8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:41:07 -0500 Subject: [PATCH 266/321] docs: add presets reference page and rename pack_id to preset_id (#2243) - New docs/presets.md: command reference for all 9 specify preset commands and 3 specify preset catalog commands, file resolution stack with Mermaid diagrams, catalog resolution order, and FAQ - src/specify_cli/__init__.py: rename pack_id to preset_id across all preset CLI commands so --help shows PRESET_ID matching the docs - docs/toc.yml: add Presets under Reference section - README.md: update presets link to published docs site --- README.md | 2 +- docs/presets.md | 224 ++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 + src/specify_cli/__init__.py | 96 ++++++++-------- 4 files changed, 275 insertions(+), 49 deletions(-) create mode 100644 docs/presets.md diff --git a/README.md b/README.md index 38d5fb3028..b8be28b66c 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ specify preset add For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. -See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own. +See the [Presets reference](https://github.github.io/spec-kit/presets.html) for the full command guide, including resolution order and priority stacking. ### When to Use Which diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000000..4a613ffc00 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,224 @@ +# Presets + +Presets customize how Spec Kit works — overriding templates, commands, and terminology without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering. + +## Search Available Presets + +```bash +specify preset search [query] +``` + +| Option | Description | +| ---------- | -------------------- | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches all active catalogs for presets matching the query. Without a query, lists all available presets. + +## Install a Preset + +```bash +specify preset add [] +``` + +| Option | Description | +| ---------------- | -------------------------------------------------------- | +| `--dev ` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority ` | Resolution priority (default: 10; lower = higher precedence) | + +Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All preset commands require a project already initialized with `specify init`. + +## Remove a Preset + +```bash +specify preset remove +``` + +Removes an installed preset and cleans up its registered commands. + +## List Installed Presets + +```bash +specify preset list +``` + +Lists installed presets with their versions, descriptions, template counts, and current status. + +## Preset Info + +```bash +specify preset info +``` + +Shows detailed information about an installed or available preset, including its templates, metadata, and tags. + +## Resolve a File + +```bash +specify preset resolve +``` + +Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. + +## Enable / Disable a Preset + +```bash +specify preset enable +specify preset disable +``` + +Disable a preset without removing it. Disabled presets are skipped during file resolution but their commands remain registered. Re-enable with `enable`. + +## Set Preset Priority + +```bash +specify preset set-priority +``` + +Changes the resolution priority of an installed preset. Lower numbers take precedence. When multiple presets provide the same file, the one with the lowest priority number wins. + +## Catalog Management + +Preset catalogs control where `search` and `add` look for presets. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify preset catalog list +``` + +Shows all active catalogs with their priorities and install permissions. + +### Add a Catalog + +```bash +specify preset catalog add +``` + +| Option | Description | +| -------------------------------------------- | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether presets can be installed from this catalog (default: discovery only) | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/preset-catalogs.yml`. + +### Remove a Catalog + +```bash +specify preset catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_PRESET_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/preset-catalogs.yml` +3. **User config** — `~/.specify/preset-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/preset-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-presets" + url: "https://example.com/preset-catalog.json" + priority: 5 + install_allowed: true + description: "Our approved presets" +``` + +## File Resolution + +Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers. + +> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. + +The resolution stack, from highest to lowest precedence: + +1. **Project-local overrides** — `.specify/templates/overrides/` +2. **Installed presets** — sorted by priority (lower = checked first) +3. **Installed extensions** — sorted by priority +4. **Spec Kit core** — `.specify/templates/` + +Commands are registered at install time (not resolved through the stack at runtime). + +### Resolution Stack + +```mermaid +flowchart TB + subgraph stack [" "] + direction TB + A["⬆ Highest precedence

1. Project-local overrides
.specify/templates/overrides/"] + B["2. Presets — by priority
.specify/presets/‹id›/"] + C["3. Extensions — by priority
.specify/extensions/‹id›/"] + D["4. Spec Kit core
.specify/templates/

⬇ Lowest precedence"] + end + + A --> B --> C --> D + + style A fill:#4a9,color:#fff + style B fill:#49a,color:#fff + style C fill:#a94,color:#fff + style D fill:#999,color:#fff +``` + +Within each layer, files are organized by type: + +| Type | Subdirectory | Override path | +| --------- | -------------- | ------------------------------------------ | +| Templates | `templates/` | `.specify/templates/overrides/` | +| Commands | `commands/` | `.specify/templates/overrides/` | +| Scripts | `scripts/` | `.specify/templates/overrides/scripts/` | + +### Resolution in Action + +```mermaid +flowchart TB + A["File requested:
plan-template.md"] --> B{"Project-local override?"} + B -- Found --> Z["✓ Use this file"] + B -- Not found --> C{"Preset: compliance
(priority 5)"} + C -- Found --> Z + C -- Not found --> D{"Preset: team-workflow
(priority 10)"} + D -- Found --> Z + D -- Not found --> E{"Extension files?"} + E -- Found --> Z + E -- Not found --> F["Spec Kit core"] + F --> Z +``` + +### Example + +```bash +specify preset add compliance --priority 5 +specify preset add team-workflow --priority 10 +``` + +For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. + +## FAQ + +### Can I use multiple presets at the same time? + +Yes. Presets stack by priority — each file is resolved independently from the highest-priority source that provides it. Use `specify preset set-priority` to control the order. + +### How do I see which file is actually being used? + +Run `specify preset resolve ` to trace the resolution stack and see which file wins. + +### What's the difference between disabling and removing a preset? + +**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`. + +**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry. + +### Who maintains presets? + +Most presets are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support preset code. Review a preset's source code before installing and use at your own discretion. For issues with a specific preset, contact its author or file an issue on the preset's repository. diff --git a/docs/toc.yml b/docs/toc.yml index b65fcac9c8..3f53367075 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -19,6 +19,8 @@ href: integrations.md - name: Extensions href: extensions.md + - name: Presets + href: presets.md # Development workflows - name: Development diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9f895cb3b9..5c079ece89 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2376,7 +2376,7 @@ def preset_list(): @preset_app.command("add") def preset_add( - pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), @@ -2444,19 +2444,19 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - elif pack_id: + elif preset_id: # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(pack_id) + bundled_path = _locate_bundled_preset(preset_id) if bundled_path: - console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...") + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") manifest = manager.install_from_directory(bundled_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") else: catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") raise typer.Exit(1) # Bundled presets should have been caught above; if we reach @@ -2464,7 +2464,7 @@ def preset_add( if pack_info.get("bundled") and not pack_info.get("download_url"): from .extensions import REINSTALL_COMMAND console.print( - f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit " + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " f"but could not be found in the installed package." ) console.print( @@ -2476,14 +2476,14 @@ def preset_add( if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") raise typer.Exit(1) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") try: - zip_path = catalog.download_pack(pack_id) + zip_path = catalog.download_pack(preset_id) manifest = manager.install_from_zip(zip_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") finally: @@ -2506,7 +2506,7 @@ def preset_add( @preset_app.command("remove") def preset_remove( - pack_id: str = typer.Argument(..., help="Preset ID to remove"), + preset_id: str = typer.Argument(..., help="Preset ID to remove"), ): """Remove an installed preset.""" from .presets import PresetManager @@ -2521,14 +2521,14 @@ def preset_remove( manager = PresetManager(project_root) - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) - if manager.remove(pack_id): - console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") else: - console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'") + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") raise typer.Exit(1) @@ -2599,7 +2599,7 @@ def preset_resolve( @preset_app.command("info") def preset_info( - pack_id: str = typer.Argument(..., help="Preset ID to get info about"), + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), ): """Show detailed information about a preset.""" from .extensions import normalize_priority @@ -2615,7 +2615,7 @@ def preset_info( # Check if installed locally first manager = PresetManager(project_root) - local_pack = manager.get_pack(pack_id) + local_pack = manager.get_pack(preset_id) if local_pack: console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") @@ -2637,7 +2637,7 @@ def preset_info( console.print(f" License: {license_val}") console.print("\n [green]Status: installed[/green]") # Get priority from registry - pack_metadata = manager.registry.get(pack_id) + pack_metadata = manager.registry.get(preset_id) priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) console.print(f" [dim]Priority:[/dim] {priority}") console.print() @@ -2646,15 +2646,15 @@ def preset_info( # Fall back to catalog catalog = PresetCatalog(project_root) try: - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) except PresetError: pack_info = None if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") raise typer.Exit(1) - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") console.print(f" ID: {pack_info['id']}") console.print(f" Version: {pack_info.get('version', '?')}") console.print(f" Description: {pack_info.get('description', '')}") @@ -2667,13 +2667,13 @@ def preset_info( if pack_info.get("license"): console.print(f" License: {pack_info['license']}") console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") console.print() @preset_app.command("set-priority") def preset_set_priority( - pack_id: str = typer.Argument(help="Preset ID"), + preset_id: str = typer.Argument(help="Preset ID"), priority: int = typer.Argument(help="New priority (lower = higher precedence)"), ): """Set the resolution priority of an installed preset.""" @@ -2696,14 +2696,14 @@ def preset_set_priority( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) from .extensions import normalize_priority @@ -2711,21 +2711,21 @@ def preset_set_priority( # Only skip if the stored value is already a valid int equal to requested priority # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) # Update priority - manager.registry.update(pack_id, {"priority": priority}) + manager.registry.update(preset_id, {"priority": priority}) - console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}") + console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") @preset_app.command("enable") def preset_enable( - pack_id: str = typer.Argument(help="Preset ID to enable"), + preset_id: str = typer.Argument(help="Preset ID to enable"), ): """Enable a disabled preset.""" from .presets import PresetManager @@ -2742,31 +2742,31 @@ def preset_enable( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") raise typer.Exit(0) # Enable the preset - manager.registry.update(pack_id, {"enabled": True}) + manager.registry.update(preset_id, {"enabled": True}) - console.print(f"[green]✓[/green] Preset '{pack_id}' enabled") + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") console.print("\nTemplates from this preset will now be included in resolution.") console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") @preset_app.command("disable") def preset_disable( - pack_id: str = typer.Argument(help="Preset ID to disable"), + preset_id: str = typer.Argument(help="Preset ID to disable"), ): """Disable a preset without removing it.""" from .presets import PresetManager @@ -2783,27 +2783,27 @@ def preset_disable( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") raise typer.Exit(0) # Disable the preset - manager.registry.update(pack_id, {"enabled": False}) + manager.registry.update(preset_id, {"enabled": False}) - console.print(f"[green]✓[/green] Preset '{pack_id}' disabled") + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") console.print("\nTemplates from this preset will be skipped during resolution.") console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {pack_id}") + console.print(f"To re-enable: specify preset enable {preset_id}") # ===== Preset Catalog Commands ===== From 02a1d610dffec7541bdd251f321b913ba01f6c27 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:34:08 -0500 Subject: [PATCH 267/321] docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244) * docs: add workflows reference, reorganize into docs/reference/, and add --version flag - Move integrations.md, extensions.md, presets.md into docs/reference/ - New docs/reference/workflows.md: command reference for all workflow commands, built-in SDD Cycle workflow with Mermaid diagram, step types, expressions, input types, state/resume, and FAQ - Rename workflow input feature_name to spec with prompt 'Describe what you want to build' to match speckit.specify command terminology - Add --version / -V flag to root specify command with tests - Update docs/toc.yml, README.md links, and docs/upgrade.md cross-reference to use reference/ paths - Add workflow command to README CLI reference table * docs: update speckit_version requirement to >=0.7.2 in workflow example --- README.md | 7 +- docs/{ => reference}/extensions.md | 0 docs/{ => reference}/integrations.md | 0 docs/{ => reference}/presets.md | 0 docs/reference/workflows.md | 289 +++++++++++++++++++++++ docs/toc.yml | 8 +- docs/upgrade.md | 2 +- src/specify_cli/__init__.py | 10 +- src/specify_cli/workflows/expressions.py | 2 +- tests/test_cli_version.py | 35 +++ tests/test_workflows.py | 8 +- workflows/PUBLISHING.md | 8 +- workflows/README.md | 20 +- workflows/speckit/workflow.yml | 14 +- 14 files changed, 369 insertions(+), 34 deletions(-) rename docs/{ => reference}/extensions.md (100%) rename docs/{ => reference}/integrations.md (100%) rename docs/{ => reference}/presets.md (100%) create mode 100644 docs/reference/workflows.md create mode 100644 tests/test_cli_version.py diff --git a/README.md b/README.md index b8be28b66c..a12c6cbb75 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Community projects that extend, visualize, or build on Spec Kit: ## 🤖 Supported AI Coding Agent Integrations -Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/integrations.html) guide. +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide. Run `specify integration list` to see all available integrations in your installed version. @@ -367,6 +367,7 @@ and supports the following commands: | `extension` | Manage extensions | | `preset` | Manage presets | | `integration` | Manage integrations | +| `workflow` | Run, manage, and search workflows. See the [Workflows reference](https://github.github.io/spec-kit/reference/workflows.html) | ### `specify init` Arguments & Options @@ -510,7 +511,7 @@ specify extension add For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. -See the [Extensions reference](https://github.github.io/spec-kit/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. +See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. ### Presets — Customize Existing Workflows @@ -526,7 +527,7 @@ specify preset add For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. -See the [Presets reference](https://github.github.io/spec-kit/presets.html) for the full command guide, including resolution order and priority stacking. +See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking. ### When to Use Which diff --git a/docs/extensions.md b/docs/reference/extensions.md similarity index 100% rename from docs/extensions.md rename to docs/reference/extensions.md diff --git a/docs/integrations.md b/docs/reference/integrations.md similarity index 100% rename from docs/integrations.md rename to docs/reference/integrations.md diff --git a/docs/presets.md b/docs/reference/presets.md similarity index 100% rename from docs/presets.md rename to docs/reference/presets.md diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md new file mode 100644 index 0000000000..e7e921e1e9 --- /dev/null +++ b/docs/reference/workflows.md @@ -0,0 +1,289 @@ +# Workflows + +Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption. + +## Run a Workflow + +```bash +specify workflow run +``` + +| Option | Description | +| ------------------- | -------------------------------------------------------- | +| `-i` / `--input` | Pass input values as `key=value` (repeatable) | + +Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively. + +Example: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full +``` + +> **Note:** All workflow commands require a project already initialized with `specify init`. + +## Resume a Workflow + +```bash +specify workflow resume +``` + +Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure. + +## Workflow Status + +```bash +specify workflow status [] +``` + +Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`. + +## List Installed Workflows + +```bash +specify workflow list +``` + +Lists workflows installed in the current project. + +## Install a Workflow + +```bash +specify workflow add +``` + +Installs a workflow from the catalog, a URL (HTTPS required), or a local file path. + +## Remove a Workflow + +```bash +specify workflow remove +``` + +Removes an installed workflow from the project. + +## Search Available Workflows + +```bash +specify workflow search [query] +``` + +| Option | Description | +| ------- | --------------- | +| `--tag` | Filter by tag | + +Searches all active catalogs for workflows matching the query. + +## Workflow Info + +```bash +specify workflow info +``` + +Shows detailed information about a workflow, including its steps, inputs, and requirements. + +## Catalog Management + +Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. + +### List Catalogs + +```bash +specify workflow catalog list +``` + +Shows all active catalog sources. + +### Add a Catalog + +```bash +specify workflow catalog add +``` + +| Option | Description | +| --------------- | -------------------------------- | +| `--name ` | Optional name for the catalog | + +Adds a custom catalog URL to the project's `.specify/workflow-catalogs.yml`. + +### Remove a Catalog + +```bash +specify workflow catalog remove +``` + +Removes a catalog by its index in the catalog list. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_WORKFLOW_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/workflow-catalogs.yml` +3. **User config** — `~/.specify/workflow-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +## Workflow Definition + +Workflows are defined in YAML files. Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit: + +```yaml +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" +``` + +This produces the following execution flow: + +```mermaid +flowchart TB + A["specify
(command)"] --> B{"review-spec
(gate)"} + B -- approve --> C["plan
(command)"] + B -- reject --> X1["⏹ Abort"] + C --> D{"review-plan
(gate)"} + D -- approve --> E["tasks
(command)"] + D -- reject --> X2["⏹ Abort"] + E --> F["implement
(command)"] + + style A fill:#49a,color:#fff + style B fill:#a94,color:#fff + style C fill:#49a,color:#fff + style D fill:#a94,color:#fff + style E fill:#49a,color:#fff + style F fill:#49a,color:#fff + style X1 fill:#999,color:#fff + style X2 fill:#999,color:#fff +``` + +Run it with: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" +``` + +## Step Types + +| Type | Purpose | +| ------------ | ------------------------------------------------ | +| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | +| `prompt` | Send an arbitrary prompt to the AI coding agent | +| `shell` | Execute a shell command and capture output | +| `gate` | Pause for human approval before continuing | +| `if` | Conditional branching (then/else) | +| `switch` | Multi-branch dispatch on an expression | +| `while` | Loop while a condition is true | +| `do-while` | Execute at least once, then loop on condition | +| `fan-out` | Dispatch a step for each item in a list | +| `fan-in` | Aggregate results from a fan-out step | + +## Expressions + +Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: + +| Namespace | Description | +| ------------------------------ | ------------------------------------ | +| `inputs.spec` | Workflow input values | +| `steps.specify.output.file` | Output from a previous step | +| `item` | Current item in a fan-out iteration | + +Available filters: `default`, `join`, `contains`, `map`. + +Example: + +```yaml +condition: "{{ steps.test.output.exit_code == 0 }}" +args: "{{ inputs.spec }}" +message: "{{ status | default('pending') }}" +``` + +## Input Types + +| Type | Coercion | +| --------- | ------------------------------------------------- | +| `string` | Pass-through | +| `number` | `"42"` → `42`, `"3.14"` → `3.14` | +| `boolean` | `"true"` / `"1"` / `"yes"` → `True` | + +## State and Resume + +Each workflow run persists its state at `.specify/workflows/runs//`: + +- `state.json` — current run state and step progress +- `inputs.json` — resolved input values +- `log.jsonl` — step-by-step execution log + +This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed. + +## FAQ + +### What happens when a workflow hits a gate step? + +The workflow pauses and waits for human input. Run `specify workflow resume ` after reviewing to continue. + +### Can I run the same workflow multiple times? + +Yes. Each run gets a unique ID and its own state directory. Use `specify workflow status` to see all runs. + +### Who maintains workflows? + +Most workflows are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support workflow code. Review a workflow's source before installing and use at your own discretion. diff --git a/docs/toc.yml b/docs/toc.yml index 3f53367075..5666cbb230 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -16,11 +16,13 @@ - name: Reference items: - name: Integrations - href: integrations.md + href: reference/integrations.md - name: Extensions - href: extensions.md + href: reference/extensions.md - name: Presets - href: presets.md + href: reference/presets.md + - name: Workflows + href: reference/workflows.md # Development workflows - name: Development diff --git a/docs/upgrade.md b/docs/upgrade.md index e08c0b93ed..020360d222 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,7 +76,7 @@ Run this inside your project directory: specify init --here --force --ai ``` -Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](integrations.md) +Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md) **Example:** diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5c079ece89..0608e7a8ac 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -351,8 +351,16 @@ def show_banner(): console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) console.print() +def _version_callback(value: bool): + if value: + console.print(f"specify {get_speckit_version()}") + raise typer.Exit() + @app.callback() -def callback(ctx: typer.Context): +def callback( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), +): """Show banner when no subcommand is provided.""" if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 3a2d3fbf2a..eb39a31e79 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -255,7 +255,7 @@ def evaluate_expression(template: str, context: Any) -> Any: ---------- template: The template string (e.g., ``"{{ steps.plan.output.task_count }}"`` - or ``"Processed {{ inputs.feature_name }}"``. + or ``"Processed {{ inputs.spec }}"``. context: A ``StepContext`` or compatible object. diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py new file mode 100644 index 0000000000..80555d8b77 --- /dev/null +++ b/tests/test_cli_version.py @@ -0,0 +1,35 @@ +"""Tests for the --version CLI flag.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +class TestVersionFlag: + """Test --version / -V flag on the root command.""" + + def test_version_long_flag(self): + """specify --version prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_short_flag(self): + """specify -V prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_flag_takes_precedence_over_subcommand(self): + """--version should work even when a subcommand follows.""" + with patch("specify_cli.get_speckit_version", return_value="0.7.2"): + result = runner.invoke(app, ["--version", "init"]) + assert result.exit_code == 0 + assert "specify 0.7.2" in result.output diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 96893249e2..c972945d04 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -54,7 +54,7 @@ def sample_workflow_yaml(): description: "A test workflow" inputs: - feature_name: + spec: type: string required: true scope: @@ -65,7 +65,7 @@ def sample_workflow_yaml(): - id: step-one command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: step-two command: speckit.plan @@ -1152,8 +1152,8 @@ def test_inputs_parsed(self, sample_workflow_yaml): from specify_cli.workflows.engine import WorkflowDefinition definition = WorkflowDefinition.from_string(sample_workflow_yaml) - assert "feature_name" in definition.inputs - assert definition.inputs["feature_name"]["required"] is True + assert "spec" in definition.inputs + assert definition.inputs["spec"]["required"] is True assert definition.inputs["scope"]["default"] == "full" diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index 857aaf7d11..ce0d251826 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -62,10 +62,10 @@ requires: any: ["claude", "gemini"] # At least one required inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" scope: type: string default: "full" @@ -75,7 +75,7 @@ steps: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review type: gate @@ -99,7 +99,7 @@ steps: ```bash # Run with required inputs -specify workflow run ./workflow.yml --input feature_name="user-auth" +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" # Check validation specify workflow info ./workflow.yml diff --git a/workflows/README.md b/workflows/README.md index 3ece00b6b0..31f736ff76 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -11,7 +11,7 @@ steps: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review type: gate @@ -35,10 +35,10 @@ specify workflow search specify workflow add speckit # Or run directly from a local YAML file -specify workflow run ./workflow.yml --input feature_name="user-auth" +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" # Run an installed workflow with inputs -specify workflow run speckit --input feature_name="user-auth" +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" # Check run status specify workflow status @@ -59,20 +59,20 @@ specify workflow remove speckit ```bash specify workflow add speckit -specify workflow run speckit --input feature_name="user-auth" +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" ``` ### From a Local YAML File ```bash -specify workflow run ./my-workflow.yml --input feature_name="user-auth" +specify workflow run ./my-workflow.yml --input spec="Build a user authentication system with OAuth support" ``` ### Multiple Inputs ```bash specify workflow run speckit \ - --input feature_name="user-auth" \ + --input spec="Build a user authentication system with OAuth support" \ --input scope="backend-only" ``` @@ -88,7 +88,7 @@ Invoke an installed Spec Kit command by name via the integration CLI: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" integration: claude # Optional: override workflow default model: "claude-sonnet-4-20250514" # Optional: override model ``` @@ -225,7 +225,7 @@ Workflow definitions use `{{ expression }}` syntax for dynamic values: ```yaml # Access inputs -args: "{{ inputs.feature_name }}" +args: "{{ inputs.spec }}" # Access previous step outputs args: "{{ steps.specify.output.file }}" @@ -245,10 +245,10 @@ Workflow inputs are type-checked and coerced from CLI string values: ```yaml inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" task_count: type: number default: 5 diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index a440c5c507..bf18451029 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -7,15 +7,15 @@ workflow: description: "Runs specify → plan → tasks → implement with review gates" requires: - speckit_version: ">=0.6.1" + speckit_version: ">=0.7.2" integrations: any: ["copilot", "claude", "gemini"] inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" integration: type: string default: "copilot" @@ -30,7 +30,7 @@ steps: command: speckit.specify integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review-spec type: gate @@ -42,7 +42,7 @@ steps: command: speckit.plan integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review-plan type: gate @@ -54,10 +54,10 @@ steps: command: speckit.tasks integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: implement command: speckit.implement integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" From 697daec7333874788c3791812624c5043394888d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:54:25 -0500 Subject: [PATCH 268/321] docs: add core commands reference and simplify README CLI section (#2245) * docs: add core commands reference and simplify README CLI section - New docs/reference/core.md: reference for init (active options only, copilot as main example), check, and version commands - docs/toc.yml: add Core Commands under Reference - README.md: replace verbose CLI Reference section (init options table, 30+ per-agent examples, deprecated flags, env vars) with links to reference docs; use copilot as main example throughout * docs: add CLI reference overview page - New docs/reference/overview.md: explains each CLI surface area (core, integrations, extensions, presets, workflows) with key commands and links to detailed reference pages - docs/toc.yml: add Overview as first item under Reference - README.md: simplify CLI Reference to single link to overview page * docs: remove command references from overview, keep paragraphs only --- README.md | 151 +++---------------------------------- docs/reference/core.md | 79 +++++++++++++++++++ docs/reference/overview.md | 33 ++++++++ docs/toc.yml | 4 + 4 files changed, 127 insertions(+), 140 deletions(-) create mode 100644 docs/reference/core.md create mode 100644 docs/reference/overview.md diff --git a/README.md b/README.md index a12c6cbb75..119f0c8a0f 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ And use the tool directly: specify init # Or initialize in existing project -specify init . --ai claude +specify init . --ai copilot # or -specify init --here --ai claude +specify init --here --ai copilot # Check installed tools specify check @@ -100,9 +100,9 @@ Run directly without installing: uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init # Or initialize in existing project -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot # or -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` **Benefits of persistent installation:** @@ -349,136 +349,7 @@ Additional commands for enhanced quality and validation: ## 🔧 Specify CLI Reference -The `specify` tool is invoked as - -```text -specify [SUBCOMMAND] [OPTIONS] -``` - -and supports the following commands: - -### Commands - -| Command | Description | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `init` | Initialize a new Specify project from the latest template. | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | -| `version` | Show the currently installed Spec Kit version. | -| `extension` | Manage extensions | -| `preset` | Manage presets | -| `integration` | Manage integrations | -| `workflow` | Run, manage, and search workflows. See the [Workflows reference](https://github.github.io/spec-kit/reference/workflows.html) | - -### `specify init` Arguments & Options - -```bash -specify init [PROJECT_NAME] -``` - -| Argument/Option | Type | Description | -| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | -| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | -| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | -| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | -| `--no-git` | Flag | Skip git repository initialization | -| `--here` | Flag | Initialize project in the current directory instead of creating a new one | -| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) | -| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | -| `--debug` | Flag | Enable detailed debug output for troubleshooting | -| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | -| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`, …, `1000`, … — expands beyond 3 digits automatically) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | - -### Examples - -```bash -# Basic project initialization -specify init my-project - -# Initialize with specific AI assistant -specify init my-project --ai claude - -# Initialize with Cursor support -specify init my-project --ai cursor-agent - -# Initialize with Qoder support -specify init my-project --ai qodercli - -# Initialize with Windsurf support -specify init my-project --ai windsurf - -# Initialize with Kiro CLI support -specify init my-project --ai kiro-cli - -# Initialize with Amp support -specify init my-project --ai amp - -# Initialize with SHAI support -specify init my-project --ai shai - -# Initialize with Mistral Vibe support -specify init my-project --ai vibe - -# Initialize with IBM Bob support -specify init my-project --ai bob - -# Initialize with Pi Coding Agent support -specify init my-project --ai pi - -# Initialize with Codex CLI support -specify init my-project --ai codex --ai-skills - -# Initialize with Antigravity support -specify init my-project --ai agy --ai-skills - -# Initialize with Forge support -specify init my-project --ai forge - -# Initialize with an unsupported agent (generic / bring your own agent) -specify init my-project --ai generic --ai-commands-dir .myagent/commands/ - -# Initialize with PowerShell scripts (Windows/cross-platform) -specify init my-project --ai copilot --script ps - -# Initialize in current directory -specify init . --ai copilot -# or use the --here flag -specify init --here --ai copilot - -# Force merge into current (non-empty) directory without confirmation -specify init . --force --ai copilot -# or -specify init --here --force --ai copilot - -# Skip git initialization -specify init my-project --ai gemini --no-git - -# Enable debug output for troubleshooting -specify init my-project --ai claude --debug - -# Use GitHub token for API requests (helpful for corporate environments) -specify init my-project --ai claude --github-token ghp_your_token_here - -# Claude Code installs skills with the project by default -specify init my-project --ai claude - -# Initialize in current directory with agent skills -specify init --here --ai gemini --ai-skills - -# Use timestamp-based branch numbering (useful for distributed teams) -specify init my-project --ai claude --branch-numbering timestamp - -# Check system requirements -specify check -``` - -### Environment Variables - -| Variable | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.
\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | +For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html). ## 🧩 Making Spec Kit Your Own: Extensions & Presets @@ -627,29 +498,29 @@ specify init --here --force You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal: ```bash -specify init --ai claude +specify init --ai copilot specify init --ai gemini specify init --ai copilot # Or in current directory: -specify init . --ai claude +specify init . --ai copilot specify init . --ai codex --ai-skills # or use --here flag -specify init --here --ai claude +specify init --here --ai copilot specify init --here --ai codex --ai-skills # Force merge into a non-empty current directory -specify init . --force --ai claude +specify init . --force --ai copilot # or -specify init --here --force --ai claude +specify init --here --force --ai copilot ``` The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash -specify init --ai claude --ignore-agent-tools +specify init --ai copilot --ignore-agent-tools ``` ### **STEP 1:** Establish project principles diff --git a/docs/reference/core.md b/docs/reference/core.md new file mode 100644 index 0000000000..fdab05a02b --- /dev/null +++ b/docs/reference/core.md @@ -0,0 +1,79 @@ +# Core Commands + +The core `specify` commands handle project initialization, system checks, and version information. + +## Initialize a Project + +```bash +specify init [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--integration ` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys | +| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--here` | Initialize in the current directory instead of creating a new one | +| `--force` | Force merge/overwrite when initializing in an existing directory | +| `--no-git` | Skip git repository initialization | +| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | +| `--preset ` | Install a preset during initialization | +| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` | + +Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. + +Use `` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. + +### Examples + +```bash +# Create a new project with an integration +specify init my-project --integration copilot + +# Initialize in the current directory +specify init --here --integration copilot + +# Force merge into a non-empty directory +specify init --here --force --integration copilot + +# Use PowerShell scripts (Windows/cross-platform) +specify init my-project --integration copilot --script ps + +# Skip git initialization +specify init my-project --integration copilot --no-git + +# Install a preset during initialization +specify init my-project --integration copilot --preset compliance + +# Use timestamp-based branch numbering (useful for distributed teams) +specify init my-project --integration copilot --branch-numbering timestamp +``` + +### Environment Variables + +| Variable | Description | +| ----------------- | ------------------------------------------------------------------------ | +| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | + +## Check Installed Tools + +```bash +specify check +``` + +Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool. + +## Version Information + +```bash +specify version +``` + +Displays the Spec Kit CLI version, Python version, platform, and architecture. + +A quick version check is also available via: + +```bash +specify --version +specify -V +``` diff --git a/docs/reference/overview.md b/docs/reference/overview.md new file mode 100644 index 0000000000..10fcdc3bca --- /dev/null +++ b/docs/reference/overview.md @@ -0,0 +1,33 @@ +# CLI Reference + +The Specify CLI (`specify`) manages the full lifecycle of Spec-Driven Development — from project initialization to workflow automation. + +## Core Commands + +The foundational commands for creating and managing Spec Kit projects. Initialize a new project with the necessary directory structure, templates, and scripts. Verify that your system has the required tools installed. Check version and system information. + +[Core Commands reference →](core.md) + +## Integrations + +Integrations connect Spec Kit to your AI coding agent. Each integration sets up the appropriate command files, context rules, and directory structures for a specific agent. Only one integration is active per project at a time, and you can switch between them at any point. + +[Integrations reference →](integrations.md) + +## Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They are discovered through catalogs and can be installed, updated, enabled, disabled, or removed independently. Multiple extensions can coexist in a single project. + +[Extensions reference →](extensions.md) + +## Presets + +Presets customize how Spec Kit works — overriding command files, template files, and script files without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering to layer customizations. + +[Presets reference →](presets.md) + +## Workflows + +Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. + +[Workflows reference →](workflows.md) diff --git a/docs/toc.yml b/docs/toc.yml index 5666cbb230..70ae77de39 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -15,6 +15,10 @@ # Reference - name: Reference items: + - name: Overview + href: reference/overview.md + - name: Core Commands + href: reference/core.md - name: Integrations href: reference/integrations.md - name: Extensions From 26fab003ee1b37d48138c52b52b43c190cd787b5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:03:59 -0500 Subject: [PATCH 269/321] chore: release 0.7.2, begin 0.7.3.dev0 development (#2247) * chore: bump version to 0.7.2 * chore: begin 0.7.3.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c362aee259..a8d2202f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.7.2] - 2026-04-16 + +### Changed + +- docs: add core commands reference and simplify README CLI section (#2245) +- docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244) +- docs: add presets reference page and rename pack_id to preset_id (#2243) +- docs: add extensions reference page and integrations FAQ (#2242) +- docs: consolidate integration documentation into docs/integrations.md (#2241) +- feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240) +- feat: Integration catalog — discovery, versioning, and community distribution (#2130) +- Add Catalog CI extension to community catalog (#2239) +- Added issues extension (#2194) +- chore: release 0.7.1, begin 0.7.2.dev0 development (#2235) + ## [0.7.1] - 2026-04-15 ### Changed diff --git a/pyproject.toml b/pyproject.toml index d7208fa389..fc4b306351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.2.dev0" +version = "0.7.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 669e253809ec3f5df29eefbaab9fdb9a4f8adfb9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:25 -0500 Subject: [PATCH 270/321] fix: add reference/*.md to docfx content glob (#2248) Without this, the reference subdirectory pages are not included in the docfx build and return 404 on the published site. --- docs/docfx.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docfx.json b/docs/docfx.json index dca3f0f578..c34fe84b84 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -4,7 +4,8 @@ { "files": [ "*.md", - "toc.yml" + "toc.yml", + "reference/*.md" ] }, { From ca382992f7deae803620b2e841b5e64d7f5586df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:46:47 -0500 Subject: [PATCH 271/321] chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#2251) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5f1f97dc77..6fe87ddce2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -51,7 +51,7 @@ jobs: uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: 'docs/_site' From 2c11525be5a2eac24f8b6da5b58eede8e0504c61 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:04:43 +0200 Subject: [PATCH 272/321] preset: Update preset-fiction-book-writing to community catalog -> v1.5.0 (#2256) * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- presets/catalog.community.json | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 119f0c8a0f..756aa092aa 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose with 5 prose profiles. Supports interactive elements like brainstorming, interview, roleplay. | 21 templates, 26 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index bc105e7486..b25a5ee2ba 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -108,11 +108,11 @@ "fiction-book-writing": { "name": "Fiction Book Writing", "id": "fiction-book-writing", - "version": "1.3.0", + "version": "1.5.0", "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", - "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.5.0.zip", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "license": "MIT", @@ -121,23 +121,24 @@ }, "provides": { "templates": 21, - "commands": 17, - "scripts": 1 + "commands": 26 }, "tags": [ "writing", "novel", - "book", "fiction", "storytelling", "creative-writing", "kdp", - "single-pov", "multi-pov", - "export" + "export", + "book", + "brainstorming", + "roleplay", + "audiobook" ], "created_at": "2026-04-09T08:00:00Z", - "updated_at": "2026-04-09T08:00:00Z" + "updated_at": "2026-04-16T08:00:00Z" }, "multi-repo-branching": { "name": "Multi-Repo Branching", From dedcae7cd87282d64b3c07b7a03f70f42dad566c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:14:24 +0900 Subject: [PATCH 273/321] feat: register Blueprint in community catalog (#2252) * feat: add Blueprint extension to community catalog - Extension ID: blueprint - Version: 1.0.0 - Author: chordpli - Repository: https://github.com/chordpli/spec-kit-blueprint * fix: update catalog root updated_at to current timestamp * fix: update hooks count to 1 (removed before_implement) * fix: use canonical /speckit.implement command name in description --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 756aa092aa..2a5d3a51d4 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ The following community-contributed extensions are available in [`catalog.commun | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 17bf6f70e2..f732f745b1 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-16T18:00:00Z", + "updated_at": "2026-04-17T01:05:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -170,6 +170,38 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "blueprint": { + "name": "Blueprint", + "id": "blueprint", + "description": "Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs", + "author": "chordpli", + "version": "1.0.0", + "download_url": "https://github.com/chordpli/spec-kit-blueprint/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/chordpli/spec-kit-blueprint", + "homepage": "https://github.com/chordpli/spec-kit-blueprint", + "documentation": "https://github.com/chordpli/spec-kit-blueprint/blob/main/README.md", + "changelog": "https://github.com/chordpli/spec-kit-blueprint/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "blueprint", + "pre-implementation", + "review", + "scaffolding", + "code-literacy" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T00:00:00Z", + "updated_at": "2026-04-17T00:00:00Z" + }, "branch-convention": { "name": "Branch Convention", "id": "branch-convention", From ba9a8b8e59cbb549f8304959294f87129f49f5e1 Mon Sep 17 00:00:00 2001 From: saram ali <140950904+SARAMALI15792@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:27:45 +0500 Subject: [PATCH 274/321] fix: suppress CRLF warnings in auto-commit.ps1 (#2258) * fix: suppress CRLF warnings in auto-commit.ps1 (#2253) Replace 2> with 2>&1 redirection and assignment to properly suppress stderr output including CRLF warnings on Windows. Exit code logic preserved for change detection. Fixes #2253 * fix: use SilentlyContinue for CRLF stderr handling, add tests The 2>&1 approach still raises terminating errors under $ErrorActionPreference='Stop'. Instead, temporarily set SilentlyContinue around all native git calls that may emit CRLF warnings to stderr (rev-parse, diff, ls-files, add, commit). Adds 5 pytest tests (TestAutoCommitPowerShellCRLF) that set core.autocrlf=true with LF-ending files. On Windows runners this triggers actual CRLF warnings; on other platforms the tests pass trivially. Fixes #2253 * refactor: address Copilot review feedback - Use 'Continue' instead of 'SilentlyContinue' so error output is still captured in $out for diagnostics on real git failures. - Wrap all three EAP save/restore blocks in try/finally to guarantee restoration even on unexpected exceptions. - Fix CRLF test to commit a tracked LF file first, then modify it, so git diff --quiet HEAD actually inspects the tracked change and triggers the CRLF warning on Windows. * test: assert CRLF warning fires on Windows On Windows, probe git diff stderr before running the script to verify the test setup actually produces the expected CRLF warning. This makes the regression test deterministic on the Windows runner. On non-Windows the probe is skipped (warnings don't fire there). --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- .../git/scripts/powershell/auto-commit.ps1 | 30 +++- tests/extensions/git/test_git_extension.py | 151 ++++++++++++++++++ 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index 2229ed2b8d..4a8b0e00cd 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -36,10 +36,17 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { exit 0 } +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' try { git rev-parse --is-inside-work-tree 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { throw "not a repo" } -} catch { + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" exit 0 } @@ -117,9 +124,16 @@ if (-not $enabled) { } # Check if there are changes to commit -$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE -$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE -$untracked = git ls-files --others --exclude-standard 2>$null +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray @@ -136,6 +150,10 @@ if (-not $commitMsg) { } # Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' try { $out = git add . 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } @@ -144,6 +162,8 @@ try { } catch { Write-Warning "[specify] Error: $_" exit 1 +} finally { + $ErrorActionPreference = $savedEAP } Write-Host "[OK] Changes committed $phase $commandName" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 30694fc9d8..c4f986d177 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -14,6 +14,7 @@ import re import shutil import subprocess +import sys from pathlib import Path import pytest @@ -585,6 +586,156 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path): assert "\u2713" not in result.stdout, "Must not use Unicode checkmark" +# ── auto-commit.ps1 CRLF warning tests (issue #2253) ──────────────────────── + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestAutoCommitPowerShellCRLF: + """Tests for CRLF warning handling in auto-commit.ps1 (issue #2253). + + On Windows, git emits CRLF warnings to stderr when core.autocrlf=true + and files use LF line endings. PowerShell's $ErrorActionPreference='Stop' + converts stderr output into terminating errors, crashing the script. + + These tests use core.autocrlf=true + explicit LF-ending files. On Windows + the CRLF warnings fire and exercise the fix; on other platforms the tests + still run (they just won't produce stderr warnings, so they pass trivially). + """ + + # -- positive tests (fix works) ---------------------------------------- + + def test_commit_succeeds_with_autocrlf(self, tmp_path: Path): + """auto-commit.ps1 creates a commit when core.autocrlf=true (CRLF + warnings on stderr must not crash the script).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "crlf commit"\n' + )) + # Create and commit a tracked LF-ending file first so the script's + # `git diff --quiet HEAD` checks inspect a tracked modification. + tracked = project / "crlf-test.txt" + tracked.write_bytes(b"line one\nline two\nline three\n") + subprocess.run(["git", "add", "crlf-test.txt"], cwd=project, check=True) + subprocess.run( + ["git", "commit", "-m", "seed tracked file"], + cwd=project, check=True, env={**os.environ, **_GIT_ENV}, + ) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Modify the tracked file with explicit LF endings to trigger the + # CRLF warning during diff/status checks on Windows. + tracked.write_bytes(b"line one\nline two changed\nline three\n") + + # On Windows, verify the test setup actually produces a CRLF warning. + if sys.platform == "win32": + probe = subprocess.run( + ["git", "diff", "--quiet", "HEAD"], + cwd=project, capture_output=True, text=True, + ) + assert "LF will be replaced by CRLF" in probe.stderr, ( + "Expected CRLF warning from git on Windows; test setup may be wrong" + ) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + + assert result.returncode == 0, ( + f"Script crashed (likely CRLF stderr); stderr:\n{result.stderr}" + ) + assert "[OK] Changes committed" in result.stdout + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "crlf commit" in log.stdout + + def test_custom_message_not_corrupted_by_crlf(self, tmp_path: Path): + """Commit message is the configured value, not a CRLF warning.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + ' message: "[Project] Plan done"\n' + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + (project / "plan.txt").write_bytes(b"plan\ncontent\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--format=%s", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "[Project] Plan done" in log.stdout.strip() + + def test_no_changes_still_skips_with_autocrlf(self, tmp_path: Path): + """Script correctly detects 'no changes' even with core.autocrlf=true.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Stage and commit everything so the working tree is clean. + subprocess.run(["git", "add", "."], cwd=project, check=True, + env={**os.environ, **_GIT_ENV}) + subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, + check=True, env={**os.environ, **_GIT_ENV}) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK]" not in result.stdout, "Should not have committed anything" + + # -- negative tests (real errors still surface) ------------------------ + + def test_not_a_repo_still_detected_with_autocrlf(self, tmp_path: Path): + """Script still exits gracefully when not in a git repo, even though + ErrorActionPreference is relaxed around the rev-parse call.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, "auto_commit:\n default: true\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + combined = result.stdout + result.stderr + assert "not a git repository" in combined.lower() or "warning" in combined.lower() + + def test_missing_config_still_exits_cleanly_with_autocrlf(self, tmp_path: Path): + """Script exits 0 when git-config.yml is absent (no over-suppression).""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + config = project / ".specify" / "extensions" / "git" / "git-config.yml" + config.unlink(missing_ok=True) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + # Should not have committed anything — config file missing means disabled. + log = subprocess.run( + ["git", "log", "--oneline"], + cwd=project, capture_output=True, text=True, + ) + assert log.stdout.strip().count("\n") == 0 # only the seed commit + + # ── git-common.sh Tests ────────────────────────────────────────────────────── From 3b82e0bcdd34e63049d46aab75e22912201359bb Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:54:08 +0200 Subject: [PATCH 275/321] docs: add Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace to README (#2250) * docs: add Claude Code / Copilot plugin installation option Add Option 4 to README installation section documenting plugin-based installation via Claude Code and Copilot CLI marketplace commands * docs(readme): move cc-spec-kit plugin to Community Friends Relocate the cc-spec-kit plugin reference to the Community Friends --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2a5d3a51d4..9050ab2d01 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,8 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. +- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. + ## 🤖 Supported AI Coding Agent Integrations Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide. From 13b614e9d5e5591e365570cccb3798a7f87f7360 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 17 Apr 2026 21:40:19 +0500 Subject: [PATCH 276/321] Add Spec Scope extension to community catalog (#2172) - Adds scope entry to catalog.community.json (between review and security-review) - Adds Spec Scope row to community extensions table in README.md (between Spec Refine and Spec Sync) - Bumps top-level updated_at to 2026-04-16T19:00:00Z --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9050ab2d01..5c2f2fd96a 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | +| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f732f745b1..8761682c17 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-17T01:05:00Z", + "updated_at": "2026-04-17T02:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1657,6 +1657,39 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-04-09T00:00:00Z" }, + "scope": { + "name": "Spec Scope", + "id": "scope", + "description": "Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-scope-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "estimation", + "scope", + "effort", + "planning", + "project-management", + "tracking" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T02:00:00Z", + "updated_at": "2026-04-17T02:00:00Z" + }, "security-review": { "name": "Security Review", "id": "security-review", From 518dc9ddadf5f76d8e1818bdcd0278dd0f351233 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:33:20 -0500 Subject: [PATCH 277/321] Add Community Friends page to docs site (#2261) Move the Community Friends section from the main README into a dedicated docs page at docs/community/friends.md, following the same structure as the Reference section. - New: docs/community/friends.md with content from README - Updated: docs/toc.yml with Community section and Friends entry - Updated: docs/docfx.json to include community/*.md in content glob - Updated: README.md to link to the new docs page instead of inline list --- README.md | 13 +------------ docs/community/friends.md | 14 ++++++++++++++ docs/docfx.json | 1 + docs/toc.yml | 6 ++++++ 4 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 docs/community/friends.md diff --git a/README.md b/README.md index 5c2f2fd96a..9f196bc123 100644 --- a/README.md +++ b/README.md @@ -305,18 +305,7 @@ See Spec-Driven Development in action across different scenarios with these comm ## 🛠️ Community Friends -> [!NOTE] -> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. - -Community projects that extend, visualize, or build on Spec Kit: - -- **[cc-spex](https://github.com/rhuss/cc-spex)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. - -- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. - -- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. - -- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. +Community projects that extend, visualize, or build on Spec Kit. See the full list on the [Community Friends](https://github.github.io/spec-kit/community/friends.html) page. ## 🤖 Supported AI Coding Agent Integrations diff --git a/docs/community/friends.md b/docs/community/friends.md new file mode 100644 index 0000000000..31c6318699 --- /dev/null +++ b/docs/community/friends.md @@ -0,0 +1,14 @@ +# Community Friends + +> [!NOTE] +> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. + +Community projects that extend, visualize, or build on Spec Kit: + +- **[cc-spex](https://github.com/rhuss/cc-spex)** — A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. + +- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. + +- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. + +- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. diff --git a/docs/docfx.json b/docs/docfx.json index c34fe84b84..3fb9c32ebb 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -5,6 +5,7 @@ "files": [ "*.md", "toc.yml", + "community/*.md", "reference/*.md" ] }, diff --git a/docs/toc.yml b/docs/toc.yml index 70ae77de39..add814d757 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -33,3 +33,9 @@ items: - name: Local Development href: local-development.md + +# Community +- name: Community + items: + - name: Friends + href: community/friends.md From fc3d1244c07c612e146cfad65d9e36542ee14175 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:57:51 -0500 Subject: [PATCH 278/321] fix: replace shell-based context updates with marker-based upsert (#2259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace shell-based context updates with marker-based upsert Replace ~3500 lines of bash/PowerShell agent context update scripts with a Python-based approach using markers. IntegrationBase now manages the agent context file directly: - upsert_context_section(): creates or updates the marked section at init/install/switch time with a directive to read the current plan - remove_context_section(): removes the section at uninstall, deleting the file only if it becomes empty - __CONTEXT_FILE__ placeholder in command templates is resolved per integration so the plan command references the correct agent file - context_file is persisted in init-options.json for extension access The plan command template instructs the LLM to update the plan reference between the markers in the agent context file. Removed: - scripts/bash/update-agent-context.sh (857 lines) - scripts/powershell/update-agent-context.ps1 (515 lines) - 56 integration wrapper scripts (update-context.sh/.ps1) - templates/agent-file-template.md - agent_scripts frontmatter key and {AGENT_SCRIPT} replacement logic - update-context reference from integration.json - tests/test_cursor_frontmatter.py (tested deleted scripts) Added: - upsert/remove context section methods on IntegrationBase - __CONTEXT_FILE__ placeholder support in process_template() - context_file field in init-options.json (init/switch/uninstall) - Per-integration tests: context file correctness, plan reference, init-options persistence (78 new context_file tests) - End-to-end CLI validation across all 28 integrations * fix: search for end marker after start marker in context section methods Address Copilot review: content.find(CONTEXT_MARKER_END) searched from the start of the file rather than after the located start marker. If the file contained a stray end marker before the start marker, the wrong slice could be replaced. Now both upsert_context_section() and remove_context_section() pass start_idx as the second argument to find() and validate end_idx > start_idx before performing the replacement. * fix: address Copilot review feedback on context section handling 1. Fix grammar in _build_context_section() directive text — add commas for a complete sentence. 2. Resolve __CONTEXT_FILE__ in resolve_skill_placeholders() — skills generated via extensions/presets for codex/kimi now replace the placeholder using the context_file value from init-options.json. 3. Handle Cursor .mdc frontmatter — when creating a new .mdc context file, prepend alwaysApply: true YAML frontmatter so Cursor auto-loads the rules. 4. Fix empty-file leading newline — when the context file exists but is empty, write the section directly instead of prepending a blank line. * fix: address second round of Copilot review feedback 1. Ensure .mdc frontmatter on existing files — upsert_context_section() now checks for missing YAML frontmatter on .mdc files during updates (not just creation), so pre-existing Cursor files get alwaysApply. 2. Guard against context_file=None — use 'or ""' instead of a default arg so explicit null values in init-options.json don't cause a TypeError in str.replace(). 3. Clean up .mdc files on removal — remove_context_section() treats files containing only the Speckit-generated frontmatter block as empty, deleting them rather than leaving orphaned frontmatter. * fix: address third round of Copilot review feedback 1. CRLF-safe .mdc frontmatter check — use lstrip().startswith('---') instead of startswith('---\n') so CRLF files don't get duplicate frontmatter. 2. CRLF-safe .mdc removal check — normalize line endings before comparing against the sentinel frontmatter string. 3. Call remove_context_section() during integration_uninstall() — the manifest-only uninstall was leaving the managed SPECKIT markers behind in the agent context file. 4. Fix stale docstring — remove 'agent_scripts' mention from test_lean_commands_have_no_scripts(). * fix: address fourth round of Copilot review feedback 1. Remove unused script_type parameter from _write_integration_json() and all 3 call sites — the parameter was no longer referenced after the update-context script removal. 2. Fix _build_context_section() docstring — correct example path from '.specify/plans/plan.md' to 'specs//plan.md'. 3. Improve .mdc frontmatter-only detection in remove_context_section() — use regex to match any YAML frontmatter block (not just the exact Speckit-generated one), so .mdc files with additional frontmatter keys are also cleaned up when no body content remains. * fix: handle corrupted markers and parse .mdc frontmatter robustly 1. Handle partial/corrupted markers in upsert_context_section() — if only the START marker exists (no END), replace from START through EOF. If only the END marker exists, replace from BOF through END. This keeps upsert idempotent even when a user accidentally deletes one marker. 2. Parse .mdc YAML frontmatter properly — new _ensure_mdc_frontmatter() helper parses existing frontmatter and ensures alwaysApply: true is set, rather than just checking for the --- delimiter. Handles missing frontmatter, existing frontmatter without alwaysApply, and already-correct frontmatter. * fix: preserve .mdc frontmatter, add tests, clean up on switch 1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments, formatting, and custom keys in existing frontmatter instead of destructively re-serializing via yaml.safe_dump(). Inserts or fixes alwaysApply: true in place. 2. Add 6 focused .mdc frontmatter tests to cursor-agent test file: new file creation, missing frontmatter, preserved custom keys, wrong alwaysApply value, idempotent upserts, removal cleanup. 3. Call remove_context_section() during integration switch Phase 1 — prevents stale SPECKIT markers from being left in the old integration's context file. Also clear context_file from init-options during the metadata reset. * fix: remove unused MDC_FRONTMATTER, preserve inline comments, normalize bare CR 1. Remove unused MDC_FRONTMATTER class variable — dead code after _ensure_mdc_frontmatter() was rewritten with regex. 2. Preserve inline comments when fixing alwaysApply — the regex substitution now captures trailing '# comment' text and keeps it. 3. Normalize bare CR in upsert_context_section() — match the behavior of remove_context_section() which already normalizes both CRLF and bare CR. 4. Clarify .mdc removal comment — 'treat frontmatter-only as empty' instead of misleading 'strip frontmatter'. * fix: handle corrupted markers in remove, CRLF-safe end-marker consumption 1. Handle corrupted markers in remove_context_section() — mirror upsert's behavior: start-only removes start→EOF, end-only removes BOF→end. Previously bailed out leaving partial markers behind. 2. CRLF-safe end-marker consumption — both upsert and remove now handle \r\n after the end marker, not just \n. Prevents extra blank lines at replacement boundaries in CRLF files. 3. Clarify path rule in plan template — distinguish filesystem operations (absolute paths) from documentation/agent context references (project-relative paths). * fix: only remove context section when both markers are well-ordered remove_context_section() previously treated mismatched markers as corruption and aggressively removed from BOF→end-marker or start-marker→EOF, which could delete user-authored content if only one marker remained. Now it only removes when both START and END markers exist and are properly ordered, returning False otherwise. --- pyproject.toml | 1 - scripts/bash/update-agent-context.sh | 857 ------------------ scripts/powershell/update-agent-context.ps1 | 515 ----------- src/specify_cli/__init__.py | 25 +- src/specify_cli/agents.py | 35 +- .../agy/scripts/update-context.ps1 | 17 - .../agy/scripts/update-context.sh | 24 - .../amp/scripts/update-context.ps1 | 23 - .../amp/scripts/update-context.sh | 28 - .../auggie/scripts/update-context.ps1 | 23 - .../auggie/scripts/update-context.sh | 28 - src/specify_cli/integrations/base.py | 294 +++++- .../bob/scripts/update-context.ps1 | 23 - .../bob/scripts/update-context.sh | 28 - .../claude/scripts/update-context.ps1 | 23 - .../claude/scripts/update-context.sh | 28 - .../codebuddy/scripts/update-context.ps1 | 23 - .../codebuddy/scripts/update-context.sh | 28 - .../codex/scripts/update-context.ps1 | 17 - .../codex/scripts/update-context.sh | 24 - .../integrations/copilot/__init__.py | 9 +- .../copilot/scripts/update-context.ps1 | 32 - .../copilot/scripts/update-context.sh | 37 - .../cursor_agent/scripts/update-context.ps1 | 23 - .../cursor_agent/scripts/update-context.sh | 28 - .../integrations/forge/__init__.py | 9 +- .../forge/scripts/update-context.ps1 | 33 - .../forge/scripts/update-context.sh | 38 - .../gemini/scripts/update-context.ps1 | 23 - .../gemini/scripts/update-context.sh | 28 - .../integrations/generic/__init__.py | 11 +- .../generic/scripts/update-context.ps1 | 17 - .../generic/scripts/update-context.sh | 24 - .../goose/scripts/update-context.ps1 | 33 - .../goose/scripts/update-context.sh | 38 - .../iflow/scripts/update-context.ps1 | 23 - .../iflow/scripts/update-context.sh | 28 - .../junie/scripts/update-context.ps1 | 23 - .../junie/scripts/update-context.sh | 28 - .../kilocode/scripts/update-context.ps1 | 23 - .../kilocode/scripts/update-context.sh | 28 - .../kimi/scripts/update-context.ps1 | 17 - .../kimi/scripts/update-context.sh | 24 - .../kiro_cli/scripts/update-context.ps1 | 23 - .../kiro_cli/scripts/update-context.sh | 28 - .../opencode/scripts/update-context.ps1 | 23 - .../opencode/scripts/update-context.sh | 28 - .../pi/scripts/update-context.ps1 | 23 - .../integrations/pi/scripts/update-context.sh | 28 - .../qodercli/scripts/update-context.ps1 | 23 - .../qodercli/scripts/update-context.sh | 28 - .../qwen/scripts/update-context.ps1 | 23 - .../qwen/scripts/update-context.sh | 28 - .../roo/scripts/update-context.ps1 | 23 - .../roo/scripts/update-context.sh | 28 - .../shai/scripts/update-context.ps1 | 23 - .../shai/scripts/update-context.sh | 28 - .../tabnine/scripts/update-context.ps1 | 23 - .../tabnine/scripts/update-context.sh | 28 - .../trae/scripts/update-context.ps1 | 23 - .../trae/scripts/update-context.sh | 28 - .../vibe/scripts/update-context.ps1 | 23 - .../vibe/scripts/update-context.sh | 28 - .../windsurf/scripts/update-context.ps1 | 23 - .../windsurf/scripts/update-context.sh | 28 - templates/agent-file-template.md | 28 - templates/commands/plan.md | 13 +- tests/integrations/test_cli.py | 11 +- .../test_integration_base_markdown.py | 96 +- .../test_integration_base_skills.py | 87 +- .../test_integration_base_toml.py | 98 +- .../test_integration_base_yaml.py | 98 +- .../integrations/test_integration_catalog.py | 4 +- tests/integrations/test_integration_claude.py | 20 +- .../integrations/test_integration_copilot.py | 25 +- .../test_integration_cursor_agent.py | 80 ++ tests/integrations/test_integration_forge.py | 32 +- .../integrations/test_integration_generic.py | 71 +- tests/test_agent_config_consistency.py | 119 --- tests/test_cursor_frontmatter.py | 266 ------ tests/test_extension_skills.py | 5 - tests/test_extensions.py | 18 - tests/test_presets.py | 4 +- 83 files changed, 756 insertions(+), 3521 deletions(-) delete mode 100644 scripts/bash/update-agent-context.sh delete mode 100644 scripts/powershell/update-agent-context.ps1 delete mode 100644 src/specify_cli/integrations/agy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/agy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/amp/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/amp/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/auggie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/auggie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/bob/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/bob/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/claude/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/claude/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codebuddy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codex/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codex/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/cursor_agent/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/forge/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/forge/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/generic/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/generic/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/goose/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/goose/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/iflow/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/iflow/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/junie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/junie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kilocode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kilocode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kimi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kimi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kiro_cli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/opencode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/opencode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/pi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/pi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qodercli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qodercli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qwen/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qwen/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/roo/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/roo/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/shai/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/shai/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/trae/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/trae/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/vibe/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/vibe/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/windsurf/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/windsurf/scripts/update-context.sh delete mode 100644 templates/agent-file-template.md delete mode 100644 tests/test_cursor_frontmatter.py diff --git a/pyproject.toml b/pyproject.toml index fc4b306351..dae79b0f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ packages = ["src/specify_cli"] [tool.hatch.build.targets.wheel.force-include] # Bundle core assets so `specify init` works without network access (air-gapped / enterprise) # Page templates (exclude commands/ — bundled separately below to avoid duplication) -"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" "templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" "templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" "templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh deleted file mode 100644 index 2f71bb893c..0000000000 --- a/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env bash - -# Update agent context files with information from plan.md -# -# This script maintains AI agent context files by parsing feature specifications -# and updating agent-specific configuration files with project information. -# -# MAIN FUNCTIONS: -# 1. Environment Validation -# - Verifies git repository structure and branch information -# - Checks for required plan.md files and templates -# - Validates file permissions and accessibility -# -# 2. Plan Data Extraction -# - Parses plan.md files to extract project metadata -# - Identifies language/version, frameworks, databases, and project types -# - Handles missing or incomplete specification data gracefully -# -# 3. Agent File Management -# - Creates new agent context files from templates when needed -# - Updates existing agent files with new project information -# - Preserves manual additions and custom configurations -# - Supports multiple AI agent formats and directory structures -# -# 4. Content Generation -# - Generates language-specific build/test commands -# - Creates appropriate project directory structures -# - Updates technology stacks and recent changes sections -# - Maintains consistent formatting and timestamps -# -# 5. Multi-Agent Support -# - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic -# - Can update single agents or all existing agent files -# - Creates default Claude file if no agent files exist -# -# Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic -# Leave empty to update all existing agent files - -set -e - -# Enable strict error handling -set -u -set -o pipefail - -#============================================================================== -# Configuration and Global Variables -#============================================================================== - -# Get script directory and load common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths and variables from common functions -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code -AGENT_TYPE="${1:-}" - -# Agent-specific file paths -CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" -GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" -CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" -QWEN_FILE="$REPO_ROOT/QWEN.md" -AGENTS_FILE="$REPO_ROOT/AGENTS.md" -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md" -KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" -AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" -ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" -CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" -QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid -# updating the same file multiple times. -AMP_FILE="$AGENTS_FILE" -SHAI_FILE="$REPO_ROOT/SHAI.md" -TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$AGENTS_FILE" -AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$AGENTS_FILE" -VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" -KIMI_FILE="$REPO_ROOT/KIMI.md" -TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md" -IFLOW_FILE="$REPO_ROOT/IFLOW.md" -FORGE_FILE="$AGENTS_FILE" - -# Template file -TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" - -# Global variables for parsed plan data -NEW_LANG="" -NEW_FRAMEWORK="" -NEW_DB="" -NEW_PROJECT_TYPE="" - -#============================================================================== -# Utility Functions -#============================================================================== - -log_info() { - echo "INFO: $1" -} - -log_success() { - echo "✓ $1" -} - -log_error() { - echo "ERROR: $1" >&2 -} - -log_warning() { - echo "WARNING: $1" >&2 -} - -# Track temporary files for cleanup on interrupt -_CLEANUP_FILES=() - -# Cleanup function for temporary files -cleanup() { - local exit_code=$? - # Disarm traps to prevent re-entrant loop - trap - EXIT INT TERM - if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then - for f in "${_CLEANUP_FILES[@]}"; do - rm -f "$f" "$f.bak" "$f.tmp" - done - fi - exit $exit_code -} - -# Set up cleanup trap -trap cleanup EXIT INT TERM - -#============================================================================== -# Validation Functions -#============================================================================== - -validate_environment() { - # Check if we have a current branch/feature (git or non-git) - if [[ -z "$CURRENT_BRANCH" ]]; then - log_error "Unable to determine current feature" - if [[ "$HAS_GIT" == "true" ]]; then - log_info "Make sure you're on a feature branch" - else - log_info "Set SPECIFY_FEATURE environment variable or create a feature first" - fi - exit 1 - fi - - # Check if plan.md exists - if [[ ! -f "$NEW_PLAN" ]]; then - log_error "No plan.md found at $NEW_PLAN" - log_info "Make sure you're working on a feature with a corresponding spec directory" - if [[ "$HAS_GIT" != "true" ]]; then - log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" - fi - exit 1 - fi - - # Check if template exists (needed for new files) - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_warning "Template file not found at $TEMPLATE_FILE" - log_warning "Creating new agent files will fail" - fi -} - -#============================================================================== -# Plan Parsing Functions -#============================================================================== - -extract_plan_field() { - local field_pattern="$1" - local plan_file="$2" - - grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ - head -1 | \ - sed "s|^\*\*${field_pattern}\*\*: ||" | \ - sed 's/^[ \t]*//;s/[ \t]*$//' | \ - grep -v "NEEDS CLARIFICATION" | \ - grep -v "^N/A$" || echo "" -} - -parse_plan_data() { - local plan_file="$1" - - if [[ ! -f "$plan_file" ]]; then - log_error "Plan file not found: $plan_file" - return 1 - fi - - if [[ ! -r "$plan_file" ]]; then - log_error "Plan file is not readable: $plan_file" - return 1 - fi - - log_info "Parsing plan data from $plan_file" - - NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") - NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") - NEW_DB=$(extract_plan_field "Storage" "$plan_file") - NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") - - # Log what we found - if [[ -n "$NEW_LANG" ]]; then - log_info "Found language: $NEW_LANG" - else - log_warning "No language information found in plan" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - log_info "Found framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - log_info "Found database: $NEW_DB" - fi - - if [[ -n "$NEW_PROJECT_TYPE" ]]; then - log_info "Found project type: $NEW_PROJECT_TYPE" - fi -} - -format_technology_stack() { - local lang="$1" - local framework="$2" - local parts=() - - # Add non-empty parts - [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") - [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") - - # Join with proper formatting - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - elif [[ ${#parts[@]} -eq 1 ]]; then - echo "${parts[0]}" - else - # Join multiple parts with " + " - local result="${parts[0]}" - for ((i=1; i<${#parts[@]}; i++)); do - result="$result + ${parts[i]}" - done - echo "$result" - fi -} - -#============================================================================== -# Template and Content Generation Functions -#============================================================================== - -get_project_structure() { - local project_type="$1" - - if [[ "$project_type" == *"web"* ]]; then - echo "backend/\\nfrontend/\\ntests/" - else - echo "src/\\ntests/" - fi -} - -get_commands_for_language() { - local lang="$1" - - case "$lang" in - *"Python"*) - echo "cd src && pytest && ruff check ." - ;; - *"Rust"*) - echo "cargo test && cargo clippy" - ;; - *"JavaScript"*|*"TypeScript"*) - echo "npm test && npm run lint" - ;; - *) - echo "# Add commands for $lang" - ;; - esac -} - -get_language_conventions() { - local lang="$1" - echo "$lang: Follow standard conventions" -} - -# Escape sed replacement-side specials for | delimiter. -# & and \ are replacement-side specials; | is our sed delimiter. -_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; } - -create_new_agent_file() { - local target_file="$1" - local temp_file="$2" - local project_name - project_name=$(_esc_sed "$3") - local current_date="$4" - - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_error "Template not found at $TEMPLATE_FILE" - return 1 - fi - - if [[ ! -r "$TEMPLATE_FILE" ]]; then - log_error "Template file is not readable: $TEMPLATE_FILE" - return 1 - fi - - log_info "Creating new agent context file from template..." - - if ! cp "$TEMPLATE_FILE" "$temp_file"; then - log_error "Failed to copy template file" - return 1 - fi - - # Replace template placeholders - local project_structure - project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") - project_structure=$(_esc_sed "$project_structure") - - local commands - commands=$(get_commands_for_language "$NEW_LANG") - - local language_conventions - language_conventions=$(get_language_conventions "$NEW_LANG") - - local escaped_lang=$(_esc_sed "$NEW_LANG") - local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK") - commands=$(_esc_sed "$commands") - language_conventions=$(_esc_sed "$language_conventions") - local escaped_branch=$(_esc_sed "$CURRENT_BRANCH") - - # Build technology stack and recent change strings conditionally - local tech_stack - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" - elif [[ -n "$escaped_lang" ]]; then - tech_stack="- $escaped_lang ($escaped_branch)" - elif [[ -n "$escaped_framework" ]]; then - tech_stack="- $escaped_framework ($escaped_branch)" - else - tech_stack="- ($escaped_branch)" - fi - - local recent_change - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" - elif [[ -n "$escaped_lang" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang" - elif [[ -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_framework" - else - recent_change="- $escaped_branch: Added" - fi - - local substitutions=( - "s|\[PROJECT NAME\]|$project_name|" - "s|\[DATE\]|$current_date|" - "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" - "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" - "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" - "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" - "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" - ) - - for substitution in "${substitutions[@]}"; do - if ! sed -i.bak -e "$substitution" "$temp_file"; then - log_error "Failed to perform substitution: $substitution" - rm -f "$temp_file" "$temp_file.bak" - return 1 - fi - done - - # Convert literal \n sequences to actual newlines (portable — works on BSD + GNU) - awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp" - mv "$temp_file.tmp" "$temp_file" - - # Clean up backup files from sed -i.bak - rm -f "$temp_file.bak" - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if [[ "$target_file" == *.mdc ]]; then - local frontmatter_file - frontmatter_file=$(mktemp) || return 1 - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - - return 0 -} - - - - -update_existing_agent_file() { - local target_file="$1" - local current_date="$2" - - log_info "Updating existing agent context file..." - - # Use a single temporary file for atomic update - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - # Process the file in one pass - local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") - local new_tech_entries=() - local new_change_entry="" - - # Prepare new technology entries - if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then - new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then - new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") - fi - - # Prepare new change entry - if [[ -n "$tech_stack" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" - elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" - fi - - # Check if sections exist in the file - local has_active_technologies=0 - local has_recent_changes=0 - - if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then - has_active_technologies=1 - fi - - if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then - has_recent_changes=1 - fi - - # Process file line by line - local in_tech_section=false - local in_changes_section=false - local tech_entries_added=false - local changes_entries_added=false - local existing_changes_count=0 - local file_ended=false - - while IFS= read -r line || [[ -n "$line" ]]; do - # Handle Active Technologies section - if [[ "$line" == "## Active Technologies" ]]; then - echo "$line" >> "$temp_file" - in_tech_section=true - continue - elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - # Add new tech entries before closing the section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - in_tech_section=false - continue - elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then - # Add new tech entries before empty line in tech section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - continue - fi - - # Handle Recent Changes section - if [[ "$line" == "## Recent Changes" ]]; then - echo "$line" >> "$temp_file" - # Add new change entry right after the heading - if [[ -n "$new_change_entry" ]]; then - echo "$new_change_entry" >> "$temp_file" - fi - in_changes_section=true - changes_entries_added=true - continue - elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - echo "$line" >> "$temp_file" - in_changes_section=false - continue - elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then - # Keep only first 2 existing changes - if [[ $existing_changes_count -lt 2 ]]; then - echo "$line" >> "$temp_file" - ((existing_changes_count++)) - fi - continue - fi - - # Update timestamp - if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then - echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" - else - echo "$line" >> "$temp_file" - fi - done < "$target_file" - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - # If sections don't exist, add them at the end of the file - if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - echo "" >> "$temp_file" - echo "## Active Technologies" >> "$temp_file" - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then - echo "" >> "$temp_file" - echo "## Recent Changes" >> "$temp_file" - echo "$new_change_entry" >> "$temp_file" - changes_entries_added=true - fi - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if [[ "$target_file" == *.mdc ]]; then - if ! head -1 "$temp_file" | grep -q '^---'; then - local frontmatter_file - frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - fi - - # Move temp file to target atomically - if ! mv "$temp_file" "$target_file"; then - log_error "Failed to update target file" - rm -f "$temp_file" - return 1 - fi - - return 0 -} -#============================================================================== -# Main Agent File Update Function -#============================================================================== - -update_agent_file() { - local target_file="$1" - local agent_name="$2" - - if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then - log_error "update_agent_file requires target_file and agent_name parameters" - return 1 - fi - - log_info "Updating $agent_name context file: $target_file" - - local project_name - project_name=$(basename "$REPO_ROOT") - local current_date - current_date=$(date +%Y-%m-%d) - - # Create directory if it doesn't exist - local target_dir - target_dir=$(dirname "$target_file") - if [[ ! -d "$target_dir" ]]; then - if ! mkdir -p "$target_dir"; then - log_error "Failed to create directory: $target_dir" - return 1 - fi - fi - - if [[ ! -f "$target_file" ]]; then - # Create new file from template - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then - if mv "$temp_file" "$target_file"; then - log_success "Created new $agent_name context file" - else - log_error "Failed to move temporary file to $target_file" - rm -f "$temp_file" - return 1 - fi - else - log_error "Failed to create new agent file" - rm -f "$temp_file" - return 1 - fi - else - # Update existing file - if [[ ! -r "$target_file" ]]; then - log_error "Cannot read existing file: $target_file" - return 1 - fi - - if [[ ! -w "$target_file" ]]; then - log_error "Cannot write to existing file: $target_file" - return 1 - fi - - if update_existing_agent_file "$target_file" "$current_date"; then - log_success "Updated existing $agent_name context file" - else - log_error "Failed to update existing agent file" - return 1 - fi - fi - - return 0 -} - -#============================================================================== -# Agent Selection and Processing -#============================================================================== - -update_specific_agent() { - local agent_type="$1" - - case "$agent_type" in - claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - ;; - gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 - ;; - copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 - ;; - cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 - ;; - qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 - ;; - opencode) - update_agent_file "$AGENTS_FILE" "opencode" || return 1 - ;; - codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 - ;; - windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 - ;; - junie) - update_agent_file "$JUNIE_FILE" "Junie" || return 1 - ;; - kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 - ;; - auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 - ;; - roo) - update_agent_file "$ROO_FILE" "Roo Code" || return 1 - ;; - codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 - ;; - qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 - ;; - amp) - update_agent_file "$AMP_FILE" "Amp" || return 1 - ;; - shai) - update_agent_file "$SHAI_FILE" "SHAI" || return 1 - ;; - tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 - ;; - kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 - ;; - agy) - update_agent_file "$AGY_FILE" "Antigravity" || return 1 - ;; - bob) - update_agent_file "$BOB_FILE" "IBM Bob" || return 1 - ;; - vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 - ;; - kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 - ;; - trae) - update_agent_file "$TRAE_FILE" "Trae" || return 1 - ;; - pi) - update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 - ;; - iflow) - update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 - ;; - forge) - update_agent_file "$AGENTS_FILE" "Forge" || return 1 - ;; - goose) - update_agent_file "$AGENTS_FILE" "Goose" || return 1 - ;; - generic) - log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." - ;; - *) - log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic" - exit 1 - ;; - esac -} - -# Helper: skip non-existent files and files already updated (dedup by -# realpath so that variables pointing to the same file — e.g. AMP_FILE, -# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). -# Uses a linear array instead of associative array for bash 3.2 compatibility. -# Note: defined at top level because bash 3.2 does not support true -# nested/local functions. _updated_paths, _found_agent, and _all_ok are -# initialised exclusively inside update_all_existing_agents so that -# sourcing this script has no side effects on the caller's environment. - -_update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - # Record the file as seen before attempting the update so that: - # (a) aliases pointing to the same path are not retried on failure - # (b) _found_agent reflects file existence, not update success - _updated_paths+=("$real_path") - _found_agent=true - update_agent_file "$file" "$name" -} - -update_all_existing_agents() { - _found_agent=false - _updated_paths=() - local _all_ok=true - - _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false - _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false - _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false - _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false - _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false - _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false - _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false - _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false - _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false - _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false - _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false - _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false - _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false - _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false - _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false - _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false - _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false - _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false - _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false - - # If no agent files exist, create a default Claude file - if [[ "$_found_agent" == false ]]; then - log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - fi - - [[ "$_all_ok" == true ]] -} -print_summary() { - echo - log_info "Summary of changes:" - - if [[ -n "$NEW_LANG" ]]; then - echo " - Added language: $NEW_LANG" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - echo " - Added framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - echo " - Added database: $NEW_DB" - fi - - echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]" -} - -#============================================================================== -# Main Execution -#============================================================================== - -main() { - # Validate environment before proceeding - validate_environment - - log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - - # Parse the plan file to extract project information - if ! parse_plan_data "$NEW_PLAN"; then - log_error "Failed to parse plan data" - exit 1 - fi - - # Process based on agent type argument - local success=true - - if [[ -z "$AGENT_TYPE" ]]; then - # No specific agent provided - update all existing agent files - log_info "No agent specified, updating all existing agent files..." - if ! update_all_existing_agents; then - success=false - fi - else - # Specific agent provided - update only that agent - log_info "Updating specific agent: $AGENT_TYPE" - if ! update_specific_agent "$AGENT_TYPE"; then - success=false - fi - fi - - # Print summary - print_summary - - if [[ "$success" == true ]]; then - log_success "Agent context update completed successfully" - exit 0 - else - log_error "Agent context update completed with errors" - exit 1 - fi -} - -# Execute main function if script is run directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index 3ee45d383c..0000000000 --- a/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' -$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' -$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' -$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' -$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' -$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if ($TargetFile -match '\.mdc$') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine - $content = $frontmatter + $content - } - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') - $output.InsertRange(0, $frontmatter) - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } - 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } - 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } - 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } - 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } - 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } - 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } - 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } - 'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' } - 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - $updatedPaths = @() - - # Helper function to update only if file exists and hasn't been updated yet - function Update-IfNew { - param( - [Parameter(Mandatory=$true)] - [string]$FilePath, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - - if (-not (Test-Path $FilePath)) { return $true } - - # Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md) - $realPath = (Get-Item -LiteralPath $FilePath).FullName - - # Check if we've already updated this file - if ($updatedPaths -contains $realPath) { - return $true - } - - # Record the file as seen before attempting the update - # Use parent scope (1) to modify Update-AllExistingAgents' local variables - Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1 - Set-Variable -Name found -Value $true -Scope 1 - - # Perform the update - return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName) - } - - if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false } - if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } - if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false } - if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false } - if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false } - - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0608e7a8ac..8c6fd02b9f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1261,15 +1261,11 @@ def init( manifest.save() # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" integration_json = project_path / ".specify" / "integration.json" integration_json.parent.mkdir(parents=True, exist_ok=True) integration_json.write_text(json.dumps({ "integration": resolved_integration.key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) @@ -1373,6 +1369,7 @@ def init( "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, "here": here, "preset": preset, "script": selected_script, @@ -1737,18 +1734,13 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: def _write_integration_json( project_root: Path, integration_key: str, - script_type: str, ) -> None: """Write ``.specify/integration.json`` for *integration_key*.""" - script_ext = "sh" if script_type == "sh" else "ps1" dest = project_root / INTEGRATION_JSON dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(json.dumps({ "integration": integration_key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") @@ -1936,7 +1928,7 @@ def integration_install( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, integration.key, selected_script) + _write_integration_json(project_root, integration.key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as e: @@ -2013,6 +2005,7 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key + opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration): @@ -2064,6 +2057,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) raise typer.Exit(0) @@ -2082,6 +2076,10 @@ def integration_uninstall( removed, skipped = manifest.uninstall(project_root, force=force) + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + _remove_integration_json(project_root) # Update init-options.json to clear the integration @@ -2090,6 +2088,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) name = (integration.config or {}).get("name", key) if integration else key @@ -2156,6 +2155,7 @@ def integration_switch( ) raise typer.Exit(1) removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: @@ -2186,6 +2186,7 @@ def integration_switch( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) # Ensure shared infrastructure is present (safe to run unconditionally; @@ -2212,7 +2213,7 @@ def integration_switch( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key, selected_script) + _write_integration_json(project_root, target_integration.key) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) except Exception as e: @@ -2320,7 +2321,7 @@ def integration_upgrade( raw_options=integration_options, ) new_manifest.save() - _write_integration_json(project_root, key, selected_script) + _write_integration_json(project_root, key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as exc: # Don't teardown — setup overwrites in-place, so teardown would diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 32fc6cdbf0..1a0e5a8317 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -110,9 +110,9 @@ 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` and `agent_scripts` keys (for example `../../scripts/`, - `../../templates/`, `../../memory/`, `scripts/`, `templates/`, and - `memory/`) to the `.specify/...` paths used in generated projects. + ``scripts`` key (for example ``../../scripts/``, + ``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and + ``memory/``) to the ``.specify/...`` paths used in generated projects. Args: frontmatter: Frontmatter dictionary @@ -122,11 +122,8 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """ frontmatter = deepcopy(frontmatter) - for script_key in ("scripts", "agent_scripts"): - scripts = frontmatter.get(script_key) - if not isinstance(scripts, dict): - continue - + 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) @@ -333,11 +330,8 @@ def resolve_skill_placeholders( frontmatter = {} scripts = frontmatter.get("scripts", {}) or {} - agent_scripts = frontmatter.get("agent_scripts", {}) or {} if not isinstance(scripts, dict): scripts = {} - if not isinstance(agent_scripts, dict): - agent_scripts = {} init_opts = load_init_options(project_root) if not isinstance(init_opts, dict): @@ -351,17 +345,14 @@ def resolve_skill_placeholders( ) secondary_variant = "sh" if default_variant == "ps" else "ps" - if default_variant in scripts or default_variant in agent_scripts: + if default_variant in scripts: fallback_order.append(default_variant) - if secondary_variant in scripts or secondary_variant in agent_scripts: + if secondary_variant in scripts: fallback_order.append(secondary_variant) for key in scripts: if key not in fallback_order: fallback_order.append(key) - for key in agent_scripts: - if key not in fallback_order: - fallback_order.append(key) script_variant = fallback_order[0] if fallback_order else None @@ -370,14 +361,12 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = ( - agent_scripts.get(script_variant) if script_variant else None - ) - if agent_script_command: - agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") - body = body.replace("{AGENT_SCRIPT}", agent_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( diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 deleted file mode 100644 index 9eeb461657..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh deleted file mode 100755 index d7303f6197..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1 deleted file mode 100644 index c217b99f9a..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh deleted file mode 100755 index 56cbf6e787..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" amp diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 deleted file mode 100644 index 49e7e6b5f3..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh deleted file mode 100755 index 4cf80bba2b..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" auggie diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 2c01e25b0e..4c71b165e5 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -84,6 +84,11 @@ class IntegrationBase(ABC): context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + # -- Markers for managed context section ------------------------------ + + CONTEXT_MARKER_START = "" + CONTEXT_MARKER_END = "" + # -- Public API ------------------------------------------------------- @classmethod @@ -380,22 +385,235 @@ def install_scripts( return created + # -- Agent context file management ------------------------------------ + + @staticmethod + def _ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + If frontmatter is missing, prepend it. If frontmatter exists but + ``alwaysApply`` is absent or not ``true``, inject/fix it. + + Uses string/regex manipulation to preserve comments and formatting + in existing frontmatter. + """ + import re as _re + + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + # Match frontmatter block: ---\n...\n--- + match = _re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + _re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + # Already correct? + if _re.search( + r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text + ): + return content + + # alwaysApply exists but wrong value — fix in place while preserving + # indentation and any trailing inline comment. + if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = _re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + @staticmethod + def _build_context_section(plan_path: str = "") -> str: + """Build the content for the managed section between markers. + + *plan_path* is the project-relative path to the current plan + (e.g. ``"specs//plan.md"``). When empty, the section + contains only the generic directive without a concrete path. + """ + lines = [ + "For additional context about technologies to be used, project structure,", + "shell commands, and other important information, read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + return "\n".join(lines) + + def upsert_context_section( + self, + project_root: Path, + plan_path: str = "", + ) -> Path | None: + """Create or update the managed section in the agent context file. + + If the context file does not exist it is created with just the + managed section. If it exists, the content between + ```` and ```` markers + is replaced (or appended when no markers are found). + + Returns the path to the context file, or ``None`` when + ``context_file`` is not set. + """ + if not self.context_file: + return None + + ctx_path = project_root / self.context_file + section = ( + f"{self.CONTEXT_MARKER_START}\n" + f"{self._build_context_section(plan_path)}\n" + f"{self.CONTEXT_MARKER_END}\n" + ) + + if ctx_path.exists(): + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + # Replace existing section (include the end marker + newline) + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + # Consume trailing line ending (CRLF or LF) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:start_idx] + section + content[end_of_marker:] + elif start_idx != -1: + # Corrupted: start marker without end — replace from start through EOF + new_content = content[:start_idx] + section + elif end_idx != -1: + # Corrupted: end marker without start — replace BOF through end marker + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + # No markers found — append + if content: + if not content.endswith("\n"): + content += "\n" + new_content = content + "\n" + section + else: + new_content = section + + # Ensure .mdc files have required YAML frontmatter + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(new_content) + else: + ctx_path.parent.mkdir(parents=True, exist_ok=True) + # Cursor .mdc files require YAML frontmatter to be loaded + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(section) + else: + new_content = section + + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + ctx_path.write_bytes(normalized.encode("utf-8")) + return ctx_path + + def remove_context_section(self, project_root: Path) -> bool: + """Remove the managed section from the agent context file. + + Returns ``True`` if the section was found and removed. If the + file becomes empty (or whitespace-only) after removal it is + deleted. + """ + if not self.context_file: + return False + + ctx_path = project_root / self.context_file + if not ctx_path.exists(): + return False + + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + # Only remove a complete, well-ordered managed section. If either + # marker is missing, leave the file unchanged to avoid deleting + # unrelated user-authored content. + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + return False + + removal_start = start_idx + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + + # Consume trailing line ending (CRLF or LF) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + + # Also strip a blank line before the section if present + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + + new_content = content[:removal_start] + content[removal_end:] + + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + + # For .mdc files, treat Speckit-generated frontmatter-only content as empty + if ctx_path.suffix == ".mdc": + import re + # Delete the file if only YAML frontmatter remains (no body content) + frontmatter_only = re.match( + r"^---\n.*?\n---\s*$", normalized, re.DOTALL + ) + if not normalized.strip() or frontmatter_only: + ctx_path.unlink() + return True + + if not normalized.strip(): + ctx_path.unlink() + else: + ctx_path.write_bytes(normalized.encode("utf-8")) + + return True + @staticmethod def process_template( content: str, agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", ) -> str: """Process a raw command template into agent-ready content. Performs the same transformations as the release script: 1. Extract ``scripts.`` value from YAML frontmatter 2. Replace ``{SCRIPT}`` with the extracted script command - 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` - 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* - 6. Replace ``__AGENT__`` with *agent_name* + 3. Strip ``scripts:`` section from frontmatter + 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* + 5. Replace ``__AGENT__`` with *agent_name* + 6. Replace ``__CONTEXT_FILE__`` with *context_file* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ # 1. Extract script command from frontmatter @@ -421,25 +639,7 @@ def process_template( if script_command: content = content.replace("{SCRIPT}", script_command) - # 3. Extract agent_script command - agent_script_command = "" - in_agent_scripts = False - for line in content.splitlines(): - if line.strip() == "agent_scripts:": - in_agent_scripts = True - continue - if in_agent_scripts and line and not line[0].isspace(): - in_agent_scripts = False - if in_agent_scripts: - m = script_pattern.match(line) - if m: - agent_script_command = m.group(1).strip() - break - - if agent_script_command: - content = content.replace("{AGENT_SCRIPT}", agent_script_command) - - # 4. Strip scripts: and agent_scripts: sections from frontmatter + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] in_frontmatter = False @@ -457,23 +657,26 @@ def process_template( output_lines.append(line) continue if in_frontmatter: - if stripped in ("scripts:", "agent_scripts:"): + if stripped == "scripts:": skip_section = True continue if skip_section: if line[0:1].isspace(): - continue # skip indented content under scripts/agent_scripts + continue # skip indented content under scripts skip_section = False output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} and $ARGUMENTS + # 4. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) content = content.replace("$ARGUMENTS", arg_placeholder) - # 6. Replace __AGENT__ + # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) + # 6. Replace __CONTEXT_FILE__ + content = content.replace("__CONTEXT_FILE__", context_file) + # 7. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. @@ -526,6 +729,9 @@ def setup( self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created def teardown( @@ -539,9 +745,11 @@ def teardown( Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). + Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ + self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -579,8 +787,8 @@ class MarkdownIntegration(IntegrationBase): (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs - integration-specific scripts (``update-context.sh`` / ``.ps1``). + ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the + managed context section into the agent context file. """ def build_exec_args( @@ -638,7 +846,8 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -646,7 +855,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -841,7 +1052,8 @@ def setup( raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -851,7 +1063,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1021,7 +1235,8 @@ def setup( title = self._human_title(src_file.stem) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1033,7 +1248,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1176,7 +1393,8 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP @@ -1220,5 +1438,7 @@ def _quote(v: str) -> str: ) created.append(dst) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/bob/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1 deleted file mode 100644 index 188860899f..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh deleted file mode 100755 index 0228603fea..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" bob diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1 deleted file mode 100644 index 837974d47a..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh deleted file mode 100755 index 4b83855a27..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" claude diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 deleted file mode 100644 index 0269392c09..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh deleted file mode 100755 index d57ddc3560..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codebuddy diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 deleted file mode 100644 index d73a5a4d34..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh deleted file mode 100755 index 512d6e91d3..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index e389138a84..be7fc819f6 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -183,7 +183,10 @@ def setup( # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest @@ -217,8 +220,8 @@ def setup( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # 4. Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 deleted file mode 100644 index 26e746a789..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.ps1 replaces its switch statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before -# dot-sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -# Invoke shared update-agent-context script as a separate process. -# Dot-sourcing is unsafe until that script guards its Main call. -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh deleted file mode 100644 index c7f3bc60b5..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.sh replaces its case statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) -# before sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -# Invoke shared update-agent-context script as a separate process. -# Sourcing is unsafe until that script guards its main logic. -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 deleted file mode 100644 index 4ce50a4873..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh deleted file mode 100755 index 597ca2289c..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" cursor-agent diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e1c4d9da62..a941d4c331 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -130,7 +130,10 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are # converted to {{parameters}} @@ -145,8 +148,8 @@ def setup( ) created.append(dst_file) - # Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 deleted file mode 100644 index 474a9c6d0b..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType forge -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh deleted file mode 100755 index 2a5c46e1d1..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" forge diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 deleted file mode 100644 index 51c9e0bc83..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh deleted file mode 100644 index c4e5003a55..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 4107c48690..fdaee4ed04 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,7 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = None + context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -122,12 +122,17 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 deleted file mode 100644 index 2e9467f801..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh deleted file mode 100755 index d8ad30a7b8..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic diff --git a/src/specify_cli/integrations/goose/scripts/update-context.ps1 b/src/specify_cli/integrations/goose/scripts/update-context.ps1 deleted file mode 100644 index eeb31f6296..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType goose -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/goose/scripts/update-context.sh b/src/specify_cli/integrations/goose/scripts/update-context.sh deleted file mode 100755 index 759ae3045a..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" goose diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 deleted file mode 100644 index b502d4182a..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh deleted file mode 100755 index 5080402071..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" iflow diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1 deleted file mode 100644 index 5a32432132..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh deleted file mode 100755 index f4c8ba6c0e..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" junie diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 deleted file mode 100644 index d87e7ef59f..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh deleted file mode 100755 index 132c0403f3..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kilocode diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 deleted file mode 100644 index aa6678d052..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh deleted file mode 100755 index 2f81bc2a48..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 deleted file mode 100644 index 7dd2b35fb7..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh deleted file mode 100755 index fa258edc75..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kiro-cli diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 deleted file mode 100644 index 4bba02b455..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh deleted file mode 100755 index 24c7e60251..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" opencode diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1 deleted file mode 100644 index 6362118a5b..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh deleted file mode 100755 index 1ad84c95a2..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" pi diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 deleted file mode 100644 index 1fa007a168..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh deleted file mode 100755 index d371ad7952..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qodercli diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 deleted file mode 100644 index 24e4c90fab..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh deleted file mode 100755 index d1c62eb161..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qwen diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1 deleted file mode 100644 index d1dec923ed..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh deleted file mode 100755 index 8fe255cb1b..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" roo diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1 deleted file mode 100644 index 2c621c76ac..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh deleted file mode 100755 index 093b9d1f76..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" shai diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 deleted file mode 100644 index 0ffb3a1649..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh deleted file mode 100644 index fe5050b6e9..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 deleted file mode 100644 index ae9a3d1cd0..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh deleted file mode 100755 index 32e5c16b29..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" trae diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 deleted file mode 100644 index d82ce3389c..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh deleted file mode 100755 index f924cdb896..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" vibe diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 deleted file mode 100644 index b5fe1d0c0a..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh deleted file mode 100755 index b9a78d320e..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" windsurf diff --git a/templates/agent-file-template.md b/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6678..0000000000 --- a/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..04db94ffaa 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -11,9 +11,6 @@ handoffs: scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: scripts/bash/update-agent-context.sh __AGENT__ - ps: scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- ## User Input @@ -145,15 +142,11 @@ You **MUST** consider the user input before proceeding (if not empty). - Skip if project is purely internal (build scripts, one-off tools, etc.) 3. **Agent context update**: - - Run `{AGENT_SCRIPT}` - - These scripts detect which AI agent is in use - - Update the appropriate agent-specific context file - - Add only new technology from current plan - - Preserve manual additions between markers + - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) -**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file +**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file ## Key rules -- Use absolute paths +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files - ERROR on gate failures or unresolved clarifications diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index bd73ccd664..04a91682e8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -56,14 +56,19 @@ def test_integration_copilot_creates_files(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" - assert "scripts" in data - assert "update-context" in data["scripts"] opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" + assert opts["context_file"] == ".github/copilot-instructions.md" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").exists() + + # Context section should be upserted into the copilot instructions file + ctx_file = project / ".github" / "copilot-instructions.md" + assert ctx_file.exists() + ctx_content = ctx_file.read_text(encoding="utf-8") + assert "" in ctx_content + assert "" in ctx_content shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 3700d35de5..f22bb298b4 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -99,7 +99,23 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_scripts: block" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) @@ -132,30 +148,35 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + # Add user content around the section + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -203,6 +224,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -220,10 +265,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.md") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(f".specify/integration.json") files.append(f".specify/init-options.json") @@ -232,14 +273,14 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "update-agent-context.sh"]: + "setup-plan.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + "setup-plan.ps1"]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", + for name in ["checklist-template.md", "constitution-template.md", "plan-template.md", "spec-template.md", "tasks-template.md"]: files.append(f".specify/templates/{name}") @@ -248,6 +289,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 72d32278ba..c8c152a84b 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -173,6 +173,23 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" + assert plan_file.exists(), f"Plan skill {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan skill should reference {i.context_file!r} but it was not found" + ) + assert "__CONTEXT_FILE__" not in content, ( + "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -217,30 +234,34 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -286,6 +307,30 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- IntegrationOption ------------------------------------------------ def test_options_include_skills_flag(self): @@ -316,8 +361,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/init-options.json", ".specify/integration.json", f".specify/integrations/{self.KEY}.manifest.json", - f".specify/integrations/{self.KEY}/scripts/update-context.ps1", - f".specify/integrations/{self.KEY}/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ] @@ -328,7 +371,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", ] else: files += [ @@ -336,11 +378,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", ] # Templates files += [ - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -352,6 +392,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index e80f9abc10..ca66b2123a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -310,6 +310,23 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -341,37 +358,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -441,6 +455,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -465,10 +503,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.toml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -481,7 +515,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -490,12 +523,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -508,6 +539,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index e4c31b3c88..08f088576c 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -189,6 +189,23 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -220,37 +237,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -320,6 +334,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -344,10 +382,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.yaml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -360,7 +394,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -369,12 +402,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -387,6 +418,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 3d0a14acdc..6d82a6c390 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -285,7 +285,7 @@ def test_clear_cache(self, tmp_path): "commands": [ {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, ], - "scripts": ["update-context.sh"], + "scripts": [], }, } @@ -305,7 +305,7 @@ def test_valid_descriptor(self, tmp_path): assert desc.description == "Integration for My Agent" assert desc.requires_speckit_version == ">=0.6.0" assert len(desc.commands) == 1 - assert desc.scripts == ["update-context.sh"] + assert desc.scripts == [] def test_missing_schema_version(self, tmp_path): data = {**VALID_DESCRIPTOR} diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index d3b01097fc..153983dcf4 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -62,19 +62,17 @@ def test_setup_creates_skill_files(self, tmp_path): assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) - created = integration.setup(tmp_path, manifest, script_type="sh") - - scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" - assert scripts_dir.is_dir() - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} - assert ".specify/integrations/claude/scripts/update-context.sh" in tracked - assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + integration.setup(tmp_path, manifest, script_type="sh") + + ctx_path = tmp_path / integration.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 34a9d54945..642b1e5300 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -143,7 +143,20 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference copilot's context file.""" + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + copilot.setup(tmp_path, m) + plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content, ( + f"Plan command should reference {copilot.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration copilot --script sh.""" @@ -181,18 +194,15 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -243,18 +253,15 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 3384fdc14f..352a0475b5 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,5 +1,10 @@ """Tests for CursorAgentIntegration.""" +from pathlib import Path + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_skills import SkillsIntegrationTests @@ -11,6 +16,81 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" +class TestCursorMdcFrontmatter: + """Verify .mdc frontmatter handling in upsert/remove context section.""" + + def _setup(self, tmp_path: Path): + i = get_integration("cursor-agent") + m = IntegrationManifest("cursor-agent", tmp_path) + return i, m + + def test_new_mdc_gets_frontmatter(self, tmp_path): + """A freshly created .mdc file includes alwaysApply: true.""" + i, m = self._setup(tmp_path) + i.setup(tmp_path, m) + ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert ctx.startswith("---\n") + assert "alwaysApply: true" in ctx + + def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): + """An existing .mdc without frontmatter gets it added.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text("# User rules\n", encoding="utf-8") + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert content.lstrip().startswith("---") + assert "alwaysApply: true" in content + assert "# User rules" in content + + def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): + """An existing .mdc with custom frontmatter is preserved.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "customKey: hello" in content + assert "" in content + + def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): + """An .mdc with alwaysApply: false gets corrected.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: false\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "alwaysApply: false" not in content + + def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): + """Repeated upserts don't duplicate frontmatter.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + i.upsert_context_section(tmp_path) + content = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert content.count("alwaysApply") == 1 + + def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): + """Removing the section from a Speckit-only .mdc deletes the file.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + i.remove_context_section(tmp_path) + assert not ctx_path.exists() + + class TestCursorAgentAutoPromote: """--ai cursor-agent auto-promotes to integration path.""" diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 7affd0d160..613ede91c0 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -73,19 +73,16 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_installs_update_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) - created = forge.setup(tmp_path, m) - script_files = [f for f in created if "scripts" in f.parts] - assert len(script_files) > 0 - sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh" - ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1" - assert sh_script in created - assert ps_script in created - assert sh_script.exists() - assert ps_script.exists() + forge.setup(tmp_path, m) + ctx_path = tmp_path / forge.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -159,7 +156,20 @@ def test_templates_are_processed(self, tmp_path): assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" # Frontmatter sections should be stripped assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference forge's context file.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert forge.context_file in content, ( + f"Plan command should reference {forge.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): """Test Forge-specific processing: name injection and handoffs stripping.""" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 74034ef105..8ca32078b5 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,9 +31,9 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_none(self): + def test_context_file_is_agents_md(self): i = get_integration("generic") - assert i.context_file is None + assert i.context_file == "AGENTS.md" # -- Options ---------------------------------------------------------- @@ -158,30 +158,31 @@ def test_different_commands_dirs(self, tmp_path): cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0 - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" - assert scripts_dir.is_dir(), "Scripts directory not created for generic" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): - i = get_integration("generic") - m = IntegrationManifest("generic", tmp_path) - i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference generic's context file.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content # -- CLI -------------------------------------------------------------- @@ -198,6 +199,28 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # The integration path validates via setup() assert result.exit_code != 0 + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the generic integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opts-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + assert opts.get("context_file") == "AGENTS.md" + def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" from typer.testing import CliRunner @@ -221,6 +244,7 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -233,16 +257,12 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -279,6 +299,7 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -291,16 +312,12 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 9cfe1ddbc9..75e80fdf33 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,6 +1,5 @@ """Consistency checks for agent configuration across runtime surfaces.""" -import re from pathlib import Path from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP @@ -61,20 +60,6 @@ def test_devcontainer_kiro_installer_uses_pinned_checksum(self): assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text - def test_agent_context_scripts_use_kiro_cli(self): - """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "kiro-cli" in bash_text - assert "kiro-cli" in pwsh_text - assert "Amazon Q Developer CLI" not in bash_text - assert "Amazon Q Developer CLI" not in pwsh_text - # --- Tabnine CLI consistency checks --- def test_runtime_config_includes_tabnine(self): @@ -96,20 +81,6 @@ def test_extension_registrar_includes_tabnine(self): assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_agent_context_scripts_include_tabnine(self): - """Agent context scripts should support tabnine agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "tabnine" in bash_text - assert "TABNINE_FILE" in bash_text - assert "tabnine" in pwsh_text - assert "TABNINE_FILE" in pwsh_text - def test_ai_help_includes_tabnine(self): """CLI help text for --ai should include tabnine.""" assert "tabnine" in AI_ASSISTANT_HELP @@ -132,18 +103,6 @@ def test_kimi_in_extension_registrar(self): assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "kimi" in validate_set_values - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -168,32 +127,6 @@ def test_trae_in_extension_registrar(self): assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == "/SKILL.md" - def test_trae_in_agent_context_scripts(self): - """Agent context scripts should support trae agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "trae" in bash_text - assert "TRAE_FILE" in bash_text - assert "trae" in pwsh_text - assert "TRAE_FILE" in pwsh_text - - def test_trae_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "trae" in validate_set_values - def test_ai_help_includes_trae(self): """CLI help text for --ai should include trae.""" assert "trae" in AI_ASSISTANT_HELP @@ -219,32 +152,6 @@ def test_pi_in_extension_registrar(self): assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "pi" in validate_set_values - - def test_agent_context_scripts_include_pi(self): - """Agent context scripts should support pi agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "pi" in bash_text - assert "Pi Coding Agent" in bash_text - assert "pi" in pwsh_text - assert "Pi Coding Agent" in pwsh_text - def test_ai_help_includes_pi(self): """CLI help text for --ai should include pi.""" assert "pi" in AI_ASSISTANT_HELP @@ -267,20 +174,6 @@ def test_iflow_in_extension_registrar(self): assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_agent_context_scripts(self): - """Agent context scripts should support iflow agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "iflow" in bash_text - assert "IFLOW_FILE" in bash_text - assert "iflow" in pwsh_text - assert "IFLOW_FILE" in pwsh_text - def test_ai_help_includes_iflow(self): """CLI help text for --ai should include iflow.""" assert "iflow" in AI_ASSISTANT_HELP @@ -303,18 +196,6 @@ def test_goose_in_extension_registrar(self): assert cfg["goose"]["format"] == "yaml" assert cfg["goose"]["args"] == "{{args}}" - def test_goose_in_agent_context_scripts(self): - """Agent context scripts should support goose agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "goose" in bash_text - assert "goose" in pwsh_text - def test_ai_help_includes_goose(self): """CLI help text for --ai should include goose.""" assert "goose" in AI_ASSISTANT_HELP diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py deleted file mode 100644 index 9f8c31ce10..0000000000 --- a/tests/test_cursor_frontmatter.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Tests for Cursor .mdc frontmatter generation (issue #669). - -Verifies that update-agent-context.sh properly prepends YAML frontmatter -to .mdc files so that Cursor IDE auto-includes the rules. -""" - -import os -import shutil -import subprocess -import textwrap - -import pytest - -from tests.conftest import requires_bash - -SCRIPT_PATH = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "bash", - "update-agent-context.sh", -) - -EXPECTED_FRONTMATTER_LINES = [ - "---", - "description: Project Development Guidelines", - 'globs: ["**/*"]', - "alwaysApply: true", - "---", -] - -requires_git = pytest.mark.skipif( - shutil.which("git") is None, - reason="git is not installed", -) - - -class TestScriptFrontmatterPattern: - """Static analysis — no git required.""" - - def test_create_new_has_mdc_frontmatter_logic(self): - """create_new_agent_file() must contain .mdc frontmatter logic.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - assert 'if [[ "$target_file" == *.mdc ]]' in content - assert "alwaysApply: true" in content - - def test_update_existing_has_mdc_frontmatter_logic(self): - """update_existing_agent_file() must also handle .mdc frontmatter.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - # There should be two occurrences of the .mdc check — one per function - occurrences = content.count('if [[ "$target_file" == *.mdc ]]') - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" - ) - - def test_powershell_script_has_mdc_frontmatter_logic(self): - """PowerShell script must also handle .mdc frontmatter.""" - ps_path = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "powershell", - "update-agent-context.ps1", - ) - with open(ps_path, encoding="utf-8") as f: - content = f.read() - assert "alwaysApply: true" in content - occurrences = content.count(r"\.mdc$") - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" - ) - - -@requires_git -@requires_bash -class TestCursorFrontmatterIntegration: - """Integration tests using a real git repo.""" - - @pytest.fixture - def git_repo(self, tmp_path): - """Create a minimal git repo with the spec-kit structure.""" - repo = tmp_path / "repo" - repo.mkdir() - - # Init git repo - subprocess.run( - ["git", "init"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=str(repo), - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create .specify dir with config - specify_dir = repo / ".specify" - specify_dir.mkdir() - (specify_dir / "config.yaml").write_text( - textwrap.dedent("""\ - project_type: webapp - language: python - framework: fastapi - database: N/A - """) - ) - - # Create template - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - (templates_dir / "agent-file-template.md").write_text( - "# [PROJECT NAME] Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: [DATE]\n\n" - "## Active Technologies\n\n" - "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" - "## Project Structure\n\n" - "[ACTUAL STRUCTURE FROM PLANS]\n\n" - "## Development Commands\n\n" - "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" - "## Coding Conventions\n\n" - "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" - "## Recent Changes\n\n" - "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" - ) - - # Create initial commit - subprocess.run( - ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a feature branch so CURRENT_BRANCH detection works - subprocess.run( - ["git", "checkout", "-b", "001-test-feature"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a spec so the script detects the feature - spec_dir = repo / "specs" / "001-test-feature" - spec_dir.mkdir(parents=True) - (spec_dir / "plan.md").write_text( - "# Test Feature Plan\n\n" - "## Technology Stack\n\n" - "- Language: Python\n" - "- Framework: FastAPI\n" - ) - - return repo - - def _run_update(self, repo, agent_type="cursor-agent"): - """Run update-agent-context.sh for a specific agent type.""" - script = os.path.abspath(SCRIPT_PATH) - result = subprocess.run( - ["bash", script, agent_type], - cwd=str(repo), - capture_output=True, - text=True, - timeout=30, - ) - return result - - def test_new_mdc_file_has_frontmatter(self, git_repo): - """Creating a new .mdc file must include YAML frontmatter.""" - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" - assert mdc_file.exists(), "Cursor .mdc file was not created" - - content = mdc_file.read_text() - lines = content.splitlines() - - # First line must be the opening --- - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - - # Check all frontmatter lines are present - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - # Content after frontmatter should be the template content - assert "Development Guidelines" in content - - def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): - """Updating an existing .mdc file that lacks frontmatter must add it.""" - # First, create the file WITHOUT frontmatter (simulating pre-fix state) - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - mdc_file.write_text( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - lines = content.splitlines() - - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): - """Updating an .mdc file that already has frontmatter must not duplicate it.""" - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - - frontmatter = ( - "---\n" - "description: Project Development Guidelines\n" - 'globs: ["**/*"]\n' - "alwaysApply: true\n" - "---\n\n" - ) - body = ( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - mdc_file.write_text(frontmatter + body) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - # Count occurrences of the frontmatter delimiter - assert content.count("alwaysApply: true") == 1, ( - "Frontmatter was duplicated" - ) - - def test_non_mdc_file_has_no_frontmatter(self, git_repo): - """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" - result = self._run_update(git_repo, agent_type="claude") - assert result.returncode == 0, f"Script failed: {result.stderr}" - - claude_file = git_repo / ".claude" / "CLAUDE.md" - if claude_file.exists(): - content = claude_file.read_text() - assert not content.startswith("---"), ( - "Non-mdc file should not have frontmatter" - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c9d13382ab..89e8b4a8b8 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -396,11 +396,8 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp "description: Scripted plan command\n" "scripts:\n" " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" - "agent_scripts:\n" - " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n" "---\n\n" "Run {SCRIPT}\n" - "Then {AGENT_SCRIPT}\n" "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" ) @@ -409,11 +406,9 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "{ARGS}" not in content assert "__AGENT__" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh claude" in content assert ".specify/templates/checklist.md" in content assert ".specify/memory/constitution.md" in content diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 460404d597..5379178afe 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1334,13 +1334,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} Agent __AGENT__ """ ) @@ -1361,11 +1357,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "__AGENT__" not in content assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" @@ -1451,13 +1445,9 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1474,13 +1464,10 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content if platform.system().lower().startswith("win"): assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content else: assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir @@ -1577,13 +1564,9 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1599,7 +1582,6 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( content = skill_file.read_text() assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content assert ".specify/scripts/bash/setup-plan.sh" not in content def test_register_commands_for_copilot(self, extension_dir, project_dir): diff --git a/tests/test_presets.py b/tests/test_presets.py index b883d554b0..35c19bdd7f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1648,7 +1648,6 @@ def test_url_cache_expired(self, project_dir): "tasks-template", "checklist-template", "constitution-template", - "agent-file-template", ] @@ -2911,7 +2910,7 @@ def test_lean_command_files_exist(self): assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" def test_lean_commands_have_no_scripts(self): - """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + """Verify lean commands have no scripts in frontmatter.""" from specify_cli.agents import CommandRegistrar for name in LEAN_COMMAND_NAMES: @@ -2919,7 +2918,6 @@ def test_lean_commands_have_no_scripts(self): content = cmd_path.read_text() frontmatter, _ = CommandRegistrar.parse_frontmatter(content) assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" def test_lean_commands_have_no_hooks(self): """Verify lean commands do not contain extension hook boilerplate.""" From c118c1c30f961921e41891df3318a2ccd2ceea54 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:33:38 -0500 Subject: [PATCH 279/321] chore: release 0.7.3, begin 0.7.4.dev0 development (#2263) * chore: bump version to 0.7.3 * chore: begin 0.7.4.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d2202f08..33cf9b5682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.7.3] - 2026-04-17 + +### Changed + +- fix: replace shell-based context updates with marker-based upsert (#2259) +- Add Community Friends page to docs site (#2261) +- Add Spec Scope extension to community catalog (#2172) +- docs: add Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace to README (#2250) +- fix: suppress CRLF warnings in auto-commit.ps1 (#2258) +- feat: register Blueprint in community catalog (#2252) +- preset: Update preset-fiction-book-writing to community catalog -> v1.5.0 (#2256) +- chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#2251) +- fix: add reference/*.md to docfx content glob (#2248) +- chore: release 0.7.2, begin 0.7.3.dev0 development (#2247) + ## [0.7.2] - 2026-04-16 ### Changed diff --git a/pyproject.toml b/pyproject.toml index dae79b0f6a..3d95d623b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.3.dev0" +version = "0.7.4.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 25684220856e5745dbef9d6dba0076550e00a445 Mon Sep 17 00:00:00 2001 From: BachVQ <47909357+baveku@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:57:45 +0700 Subject: [PATCH 280/321] fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276) * refactor(agy): update storage directory from .agent to .agents * feat: update Antigravity integration to use .agents/ directory layout and add version compatibility warnings * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: remove deprecated --skills flag from AgyIntegration and update related test assertions * Update src/specify_cli/integrations/agy/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: update Antigravity integration requirement to v1.20.5 and remove obsolete tests * test: update skills directory path from .agent to .agents in preset restoration test * Update tests/integrations/test_integration_agy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/integrations/test_integration_agy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/agy/__init__.py | 43 ++++++++++++-------- tests/integrations/test_integration_agy.py | 26 ++++++++++-- tests/test_presets.py | 2 +- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 9cd522745e..d62bafad40 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -1,13 +1,18 @@ """Antigravity (agy) integration — skills-based agent. -Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout. -Explicit command support was deprecated in version 1.20.5; -``--skills`` defaults to ``True``. +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (enforced since v1.20.5). """ from __future__ import annotations -from ..base import IntegrationOption, SkillsIntegration +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ..base import SkillsIntegration + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + class AgyIntegration(SkillsIntegration): @@ -16,26 +21,32 @@ class AgyIntegration(SkillsIntegration): key = "agy" config = { "name": "Antigravity", - "folder": ".agent/", + "folder": ".agents/", "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".agent/skills", + "dir": ".agents/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } context_file = "AGENTS.md" - @classmethod - def options(cls) -> list[IntegrationOption]: - return [ - IntegrationOption( - "--skills", - is_flag=True, - default=True, - help="Install as agent skills (default for Antigravity since v1.20.5)", - ), - ] + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + import click + + click.secho( + "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer. " + "Please ensure your agy installation is up to date.", + fg="yellow", + err=True, + ) + return super().setup(project_root, manifest, parsed_options=parsed_options, **opts) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 21cb1d832e..b95caf3bee 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -5,12 +5,17 @@ class TestAgyIntegration(SkillsIntegrationTests): KEY = "agy" - FOLDER = ".agent/" + FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".agent/skills" + REGISTRAR_DIR = ".agents/skills" CONTEXT_FILE = "AGENTS.md" - + def test_options_include_skills_flag(self): + """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" + from specify_cli.integrations import get_integration + i = get_integration(self.KEY) + skills_opts = [o for o in i.options() if o.name == "--skills"] + assert len(skills_opts) == 0 class TestAgyAutoPromote: """--ai agy auto-promotes to integration path.""" @@ -24,4 +29,17 @@ def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) assert result.exit_code == 0, f"init --ai agy failed: {result.output}" - assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_agy_setup_warning(self, tmp_path): + """Agy integration should print a warning about v1.20.5 requirement during setup.""" + from typer.testing import CliRunner + from specify_cli import app + + # Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed + runner = CliRunner() + target = tmp_path / "test-proj2" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) + + assert result.exit_code == 0 + assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr diff --git a/tests/test_presets.py b/tests/test_presets.py index 35c19bdd7f..60322b99a1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2447,7 +2447,7 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Agy preset removal should restore native skills instead of deleting them.""" self._write_init_options(project_dir, ai="agy", ai_skills=True) - skills_dir = project_dir / ".agent" / "skills" + skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-specify", body="before override") core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" From dc057a231422bfb1b046436b8da65dfb9e187bd0 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:14:43 +0200 Subject: [PATCH 281/321] Preset fiction book writing1.6 (#2270) * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Add fiction-book-writing preset to community catalog - Preset ID: fiction-book-writing - Version: 1.6.0 - Author: Andreas Daumann - Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed update_at for fiction-book-writing preset * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed description for fiction-book-writing --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- presets/catalog.community.json | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9f196bc123..0f3f3c8970 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose with 5 prose profiles. Supports interactive elements like brainstorming, interview, roleplay. | 21 templates, 26 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index b25a5ee2ba..0e0194b27d 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -108,11 +108,11 @@ "fiction-book-writing": { "name": "Fiction Book Writing", "id": "fiction-book-writing", - "version": "1.5.0", - "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", + "version": "1.6.0", + "description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", - "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.5.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "license": "MIT", @@ -120,8 +120,9 @@ "speckit_version": ">=0.5.0" }, "provides": { - "templates": 21, - "commands": 26 + "templates": 22, + "commands": 27, + "scripts": 1 }, "tags": [ "writing", @@ -135,11 +136,12 @@ "book", "brainstorming", "roleplay", - "audiobook" + "audiobook", + "language-support" ], "created_at": "2026-04-09T08:00:00Z", - "updated_at": "2026-04-16T08:00:00Z" - }, + "updated_at": "2026-04-19T08:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From b4c4e86cbc65f108e403ef19898e35b68c6ebc72 Mon Sep 17 00:00:00 2001 From: Ayesha Khalid Date: Tue, 21 Apr 2026 02:00:20 +0500 Subject: [PATCH 282/321] fix(integrations): strip UTF-8 BOM when reading agent context files (#2283) * fix(integrations): strip UTF-8 BOM when reading agent context files * test(integrations): add BOM regression tests for context file read/write * test(workflows): mock shutil.which in tests that assume CLI is absent * test(integrations): remove unused manifest variable in BOM test --- src/specify_cli/integrations/base.py | 4 +- tests/integrations/test_integration_claude.py | 41 +++++++++++++++++++ tests/test_workflows.py | 16 ++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 4c71b165e5..a3d8a42aa2 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -482,7 +482,7 @@ def upsert_context_section( ) if ctx_path.exists(): - content = ctx_path.read_text(encoding="utf-8") + content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(self.CONTEXT_MARKER_START) end_idx = content.find( self.CONTEXT_MARKER_END, @@ -547,7 +547,7 @@ def remove_context_section(self, project_root: Path) -> bool: if not ctx_path.exists(): return False - content = ctx_path.read_text(encoding="utf-8") + content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(self.CONTEXT_MARKER_START) end_idx = content.find( self.CONTEXT_MARKER_END, diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 153983dcf4..72e73bb02b 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,5 +1,6 @@ """Tests for ClaudeIntegration.""" +import codecs import json import os from unittest.mock import patch @@ -74,6 +75,46 @@ def test_setup_upserts_context_section(self, tmp_path): assert "" in content assert "read the current plan" in content + def test_upsert_context_section_strips_bom(self, tmp_path): + """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) + bom = codecs.BOM_UTF8 + ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + + integration.upsert_context_section(tmp_path) + + result = ctx_path.read_bytes() + assert not result.startswith(bom), "BOM must be stripped after upsert" + content = result.decode("utf-8") + assert "" in content + assert "Some existing content." in content + + def test_remove_context_section_strips_bom(self, tmp_path): + """remove_context_section must clean BOM from context file on Windows-authored files.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + marker_content = ( + "# CLAUDE.md\n\n" + "\n" + "For additional context about technologies to be used, project structure,\n" + "shell commands, and other important information, read the current plan\n" + "\n" + ) + ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) + + result = integration.remove_context_section(tmp_path) + + assert result is True + assert ctx_path.exists(), "File should exist (non-empty content remains)" + remaining = ctx_path.read_bytes() + assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" + assert b" +## [0.7.4] - 2026-04-21 + +### Changed + +- fix(copilot): use --yolo to grant all permissions in non-interactive mode (#2298) +- feat: add CITATION.cff and .zenodo.json for academic citation support (#2291) +- Add spec-validate to community catalog (#2274) +- feat: register Ripple in community catalog (#2272) +- Add version-guard to community catalog (#2286) +- Add spec-reference-loader to community catalog (#2285) +- Add memory-loader to community catalog (#2284) +- fix(integrations): strip UTF-8 BOM when reading agent context files (#2283) +- Preset fiction book writing1.6 (#2270) +- fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276) +- chore: release 0.7.3, begin 0.7.4.dev0 development (#2263) + ## [0.7.3] - 2026-04-17 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 3d95d623b8..fad821feac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.4.dev0" +version = "0.7.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 569d18a59d945af5d3341aac3da29ad2a531e3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:06:09 +0900 Subject: [PATCH 291/321] fix(agents): block directory traversal in command write paths (#2229) (#2296) Extend the alias containment guard from b67b285 to the two remaining write paths that derive filenames from free-form command/alias names: - Primary command write in CommandRegistrar.register_commands() - CommandRegistrar.write_copilot_prompt() Consolidate the check into a shared _ensure_inside() helper. Per maintainer guidance on #2229, use a lexical (os.path.normpath + Path.is_relative_to) containment check rather than resolve() so `..` / absolute-path traversal is rejected while intentionally symlinked sub-directories under an agent's commands directory (e.g. .claude/skills/shared -> /team/shared-skills) keep working for existing extension setups. Add 22 parametrised regression cases covering traversal payloads on primary commands, aliases, and the Copilot companion prompt, plus a positive case that confirms symlinked sub-directories remain supported. --- src/specify_cli/agents.py | 32 +++- tests/test_registrar_path_traversal.py | 204 +++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tests/test_registrar_path_traversal.py diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 1a0e5a8317..ef4e879c03 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -6,6 +6,7 @@ command files into agent-specific directories in the correct format. """ +import os from pathlib import Path from typing import Dict, List, Any @@ -399,6 +400,28 @@ def _compute_output_name( 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, @@ -485,6 +508,7 @@ def register_commands( 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") @@ -550,12 +574,7 @@ def register_commands( alias_file = ( commands_dir / f"{alias_output_name}{agent_config['extension']}" ) - try: - alias_file.resolve().relative_to(commands_dir.resolve()) - except ValueError: - raise ValueError( - f"Alias output path escapes commands directory: {alias_file!r}" - ) + 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": @@ -575,6 +594,7 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: 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( diff --git a/tests/test_registrar_path_traversal.py b/tests/test_registrar_path_traversal.py new file mode 100644 index 0000000000..fc423b4056 --- /dev/null +++ b/tests/test_registrar_path_traversal.py @@ -0,0 +1,204 @@ +"""Tests for CommandRegistrar directory traversal guards around issue #2229.""" + +import errno +from pathlib import Path + +import pytest + +from specify_cli.agents import CommandRegistrar + + +TRAVERSAL_PAYLOADS = [ + "../pwned", + "../../etc/passwd", + "subdir/../../escape", + "/absolute/evil", +] + + +def _write_source(ext_dir: Path) -> Path: + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "cmd.md").write_text( + "---\ndescription: test\n---\n\nbody\n", encoding="utf-8" + ) + return ext_dir + + +def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]: + return { + "name": name, + "file": "commands/cmd.md", + "aliases": list(aliases or []), + } + + +def _project_and_source(tmp_path): + project = tmp_path / "project" + project.mkdir() + ext_dir = _write_source(tmp_path / "ext-src") + return project, ext_dir + + +def _assert_no_stray_files(tmp_root: Path, marker: str) -> None: + """Fail if a file matching ``marker`` exists outside the project tree.""" + stray = [ + p for p in tmp_root.rglob("*") + if p.is_file() and marker in p.name and "project" not in p.parts + ] + assert stray == [], ( + f"Traversal payload leaked files outside the project tree: {stray}" + ) + + +class TestPrimaryCommandTraversal: + """Primary command names must not escape the agent's commands directory.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestAliasTraversal: + """Free-form aliases must not escape commands_dir (regression for b67b285).""" + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + +class TestCopilotPromptTraversal: + """`write_copilot_prompt` is a public static method — guard it directly.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_rejects_traversal_names(self, tmp_path, bad_name): + project = tmp_path / "project" + (project / ".github" / "prompts").mkdir(parents=True) + + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + CommandRegistrar.write_copilot_prompt(project, bad_name) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestSafeRegistration: + """Positive regression — well-formed names continue to register.""" + + def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path): + """Lexical check must not block legitimately symlinked sub-directories. + + Teams sometimes symlink shared skills into their agent commands dir + (e.g. ``.gemini/commands/shared -> /team/shared-commands``). The + guard is purely lexical, so such a setup continues to work even though + the resolved target lives outside commands_dir on disk. + """ + project, ext_dir = _project_and_source(tmp_path) + commands_dir = project / ".gemini" / "commands" + commands_dir.mkdir(parents=True) + + external_shared = tmp_path / "external-shared" + external_shared.mkdir() + try: + (commands_dir / "shared").symlink_to( + external_shared, target_is_directory=True + ) + except OSError as exc: + if exc.errno in {errno.EPERM, errno.EACCES}: + pytest.skip("symlink creation is not permitted in this environment") + raise + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [_cmd("shared/hello")], + "myext", + ext_dir, + project, + ) + + assert registered == ["shared/hello"] + assert (external_shared / "hello.toml").exists() + + def test_safe_command_and_alias_still_register(self, tmp_path): + project, ext_dir = _project_and_source(tmp_path) + (project / ".claude" / "skills").mkdir(parents=True) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "claude", + [_cmd("speckit.myext.hello", ["speckit.myext.hi"])], + "myext", + ext_dir, + project, + ) + + assert "speckit.myext.hello" in registered + assert "speckit.myext.hi" in registered + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hello" + / "SKILL.md" + ).exists() + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hi" + / "SKILL.md" + ).exists() From 22e76995c7526f92704ca5df5b2db7d3b84dfa0e Mon Sep 17 00:00:00 2001 From: Kennedy <116378813+kennedy-whytech@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:02:31 -0400 Subject: [PATCH 292/321] feat: implement preset wrap strategy (#2189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement strategy: wrap * fix: resolve merge conflict for strategy wrap correctness * feat: multi-preset composable wrapping with priority ordering Implements comment #4 from PR review: multiple installed wrap presets now compose in priority order rather than overwriting each other. Key changes: - PresetResolver.resolve() gains skip_presets flag; resolve_core() wraps it to skip tier 2, preventing accidental nesting during replay - _replay_wraps_for_command() recomposed all enabled wrap presets for a command in ascending priority order (innermost-first) after any install or remove - _replay_skill_override() keeps SKILL.md in sync with the recomposed command body for ai-skills-enabled projects - install_from_directory() detects strategy: wrap commands, stores wrap_commands in the registry entry, and calls replay after install - remove() reads wrap_commands before deletion, removes registry entry before rmtree so replay sees post-removal state, then replays remaining wraps or unregisters when none remain Tests: TestResolveCore (5), TestReplayWrapsForCommand (5), TestInstallRemoveWrapLifecycle (5), plus 2 skill/alias regression tests * fix: resolve extension commands via manifest file mapping PresetResolver.resolve_extension_command_via_manifest() consults each installed extension.yml to find the actual file declared for a command name, rather than assuming the file is named .md. This fixes _substitute_core_template for extensions like selftest where the manifest maps speckit.selftest.extension → commands/selftest.md. Resolution order in _substitute_core_template is now: 1. resolve_core(cmd_name) — project overrides win, then name-based lookup 2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback 3. resolve_core(short_name) — core template short-name fallback Path traversal guard mirrors the containment check already present in ExtensionManager to reject absolute paths or paths escaping the extension root. * fix: add bundled core_pack as Priority 5 in PresetResolver.resolve() resolve_core() was returning None for built-in commands (implement, specify, etc.) because PresetResolver only checked .specify/templates/ commands/ (Priority 4), which is never populated for commands in a normal project. strategy:wrap presets rely on resolve_core() to fetch the {CORE_TEMPLATE} body, so the wrap was silently skipped and SKILL.md was never updated. Priority 5 now checks core_pack/commands/ (wheel install) or repo_root/templates/commands/ (source checkout), mirroring the pattern used by _locate_core_pack() elsewhere. Updated two tests whose assertions assumed resolve_core() always returned None when .specify/templates/commands/ was absent. * fix: harden preset wrap replay removal * fix: stabilize existing directory error output * fix: track outermost_pack_id from contributing preset; use Path.parts in tests - outermost_pack_id now updates alongside outermost_frontmatter inside the wrap loop, so it reflects the actual last contributing preset rather than always taking wrap_presets[0] (which may have been skipped) - Replace str(path) substring checks in TestResolveCore with Path.parts tuple comparisons for correct behaviour on Windows (CI runs windows-latest) * fix: guard against non-mapping YAML manifests; apply integration post-processing in replay - ExtensionManifest._load raises ValidationError for non-dict YAML roots instead of TypeError - PresetManager._replay_wraps_for_command calls integration.post_process_skill_content, matching _register_skills behaviour - PresetResolver skips extensions that raise OSError/TypeError/AttributeError on manifest load - Tests: non-mapping YAML, OSError manifest skip, and replay integration post-processing --- presets/README.md | 2 +- .../self-test/commands/speckit.wrap-test.md | 14 + presets/self-test/preset.yml | 5 + src/specify_cli/__init__.py | 3 +- src/specify_cli/agents.py | 17 +- src/specify_cli/extensions.py | 7 +- src/specify_cli/presets.py | 458 +++++- tests/integrations/test_cli.py | 2 +- tests/test_extensions.py | 8 + tests/test_presets.py | 1271 ++++++++++++++++- 10 files changed, 1771 insertions(+), 16 deletions(-) create mode 100644 presets/self-test/commands/speckit.wrap-test.md diff --git a/presets/README.md b/presets/README.md index dd3997b239..72751b4bfb 100644 --- a/presets/README.md +++ b/presets/README.md @@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases: | **command** | ✓ (default) | ✓ | ✓ | ✓ | | **script** | ✓ (default) | — | — | ✓ | - For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable. + For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented). - **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. diff --git a/presets/self-test/commands/speckit.wrap-test.md b/presets/self-test/commands/speckit.wrap-test.md new file mode 100644 index 0000000000..78ace30ea8 --- /dev/null +++ b/presets/self-test/commands/speckit.wrap-test.md @@ -0,0 +1,14 @@ +--- +description: "Self-test wrap command — pre/post around core" +strategy: wrap +--- + +## Preset Pre-Logic + +preset:self-test wrap-pre + +{CORE_TEMPLATE} + +## Preset Post-Logic + +preset:self-test wrap-post diff --git a/presets/self-test/preset.yml b/presets/self-test/preset.yml index 82c7b068ad..8e718430aa 100644 --- a/presets/self-test/preset.yml +++ b/presets/self-test/preset.yml @@ -56,6 +56,11 @@ provides: description: "Self-test override of the specify command" replaces: "speckit.specify" + - type: "command" + name: "speckit.wrap-test" + file: "commands/speckit.wrap-test.md" + description: "Self-test wrap strategy command" + tags: - "testing" - "self-test" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8c6fd02b9f..97cb993a96 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1115,7 +1115,7 @@ def init( console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") else: error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" + f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" "Please choose a different project name or remove the existing directory.\n" "Use [bold]--force[/bold] to merge into the existing directory.", title="[red]Directory Conflict[/red]", @@ -1371,7 +1371,6 @@ def init( "branch_numbering": branch_numbering or "sequential", "context_file": resolved_integration.context_file, "here": here, - "preset": preset, "script": selected_script, "speckit_version": get_speckit_version(), } diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ef4e879c03..c5e25a7085 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -468,6 +468,15 @@ def register_commands( 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", []): @@ -495,10 +504,12 @@ def register_commands( project_root, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command( - frontmatter, body, source_id, context_note - ) + 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( diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d5543cd0b4..26ceab4034 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -140,11 +140,16 @@ def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: with open(path, 'r') as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) except yaml.YAMLError as e: raise ValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise ValidationError(f"Manifest not found: {path}") + if not isinstance(data, dict): + raise ValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data def _validate(self): """Validate manifest structure and required fields.""" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index d5513c8323..5f28be7204 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -16,7 +16,10 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import TYPE_CHECKING, Optional, Dict, List, Any + +if TYPE_CHECKING: + from .agents import CommandRegistrar from datetime import datetime, timezone import re @@ -27,6 +30,59 @@ from .extensions import ExtensionRegistry, normalize_priority +def _substitute_core_template( + body: str, + cmd_name: str, + project_root: "Path", + registrar: "CommandRegistrar", +) -> "tuple[str, dict]": + """Substitute {CORE_TEMPLATE} with the body of the installed core command template. + + Args: + body: Preset command body (may contain {CORE_TEMPLATE} placeholder). + cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify"). + project_root: Project root path. + registrar: CommandRegistrar instance for parse_frontmatter. + + Returns: + A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced + by the core template body and core_frontmatter holds the core template's parsed + frontmatter (so callers can inherit scripts/agent_scripts from it). Both are + unchanged / empty when the placeholder is absent or the core template file does + not exist. + """ + if "{CORE_TEMPLATE}" not in body: + return body, {} + + # Derive the short name (strip "speckit." prefix) used by core command templates. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(project_root) + # Resolution order for the core template: + # 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4 + # name-based lookup (file named .md). Checked first so that a + # local override always wins, even for extension commands. + # 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3 + # fallback for extension commands whose file is named differently from the + # command name (e.g. speckit.selftest.extension → commands/selftest.md). + # 3. resolve_core(short_name) — core template fallback using the unprefixed + # name (e.g. specify → templates/commands/specify.md). + # resolve_core() skips installed presets (tier 2) to prevent accidental nesting + # where another preset's wrap output is mistaken for the real core. + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8")) + return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter + + @dataclass class PresetCatalogEntry: """Represents a single entry in the preset catalog stack.""" @@ -555,6 +611,232 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + def _replay_wraps_for_command(self, cmd_name: str) -> None: + """Recompose and rewrite agent files for a wrap-strategy command. + + Collects all installed presets that declare cmd_name in their + wrap_commands registry field, sorts them so the highest-precedence + preset (lowest priority number) wraps outermost, then writes the + fully composed output to every agent directory. + + Called after every install and remove to keep agent files correct + regardless of installation order. + + Args: + cmd_name: Full command name (e.g. "speckit.specify") + """ + try: + from .agents import CommandRegistrar + except ImportError: + return + + # Collect enabled presets that wrap this command, sorted ascending + # (lowest priority number = highest precedence = outermost). + wrap_presets = [] + for pack_id, metadata in self.registry.list_by_priority(include_disabled=False): + if cmd_name not in metadata.get("wrap_commands", []): + continue + pack_dir = self.presets_dir / pack_id + if not pack_dir.is_dir(): + continue # corrupted state — skip + wrap_presets.append((pack_id, pack_dir)) + + if not wrap_presets: + return + + # Derive short name for core resolution fallback. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(self.project_root) + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or ( + resolver.resolve_extension_command_via_manifest(short_name) + if short_name != cmd_name + else None + ) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return + + registrar = CommandRegistrar() + core_frontmatter, core_body = registrar.parse_frontmatter( + core_file.read_text(encoding="utf-8") + ) + replay_aliases: List[str] = [] + seen_aliases: set[str] = set() + + # Apply wraps innermost-first (reverse of ascending list). + accumulated_body = core_body + outermost_frontmatter = {} + outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset + for pack_id, pack_dir in reversed(wrap_presets): + manifest_path = pack_dir / "preset.yml" + cmd_file: Optional[Path] = None + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + except (PresetValidationError, KeyError, TypeError, ValueError): + manifest = None + if manifest is not None: + for template in manifest.templates: + if template.get("type") != "command" or template.get("name") != cmd_name: + continue + file_rel = template.get("file") + if isinstance(file_rel, str): + rel_path = Path(file_rel) + if not rel_path.is_absolute(): + try: + preset_root = pack_dir.resolve() + candidate = (preset_root / rel_path).resolve() + candidate.relative_to(preset_root) + except (OSError, ValueError): + candidate = None + if candidate is not None: + cmd_file = candidate + aliases = template.get("aliases", []) + if not isinstance(aliases, list): + aliases = [] + for alias in aliases: + if isinstance(alias, str) and alias not in seen_aliases: + replay_aliases.append(alias) + seen_aliases.add(alias) + break + if cmd_file is None: + cmd_file = pack_dir / "commands" / f"{cmd_name}.md" + if not cmd_file.exists(): + continue + wrap_fm, wrap_body = registrar.parse_frontmatter( + cmd_file.read_text(encoding="utf-8") + ) + accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body) + outermost_frontmatter = wrap_fm # last iteration = outermost preset + outermost_pack_id = pack_id + + # Build final frontmatter: outermost preset wins; fall back to core for + # scripts/agent_scripts if the outermost preset does not define them. + final_frontmatter = dict(outermost_frontmatter) + final_frontmatter.pop("strategy", None) + for key in ("scripts", "agent_scripts"): + if key not in final_frontmatter and key in core_frontmatter: + final_frontmatter[key] = core_frontmatter[key] + + composed_content = ( + registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body + ) + + self._replay_skill_override(cmd_name, composed_content, outermost_pack_id) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + cmd_dir = tmp_path / "commands" + cmd_dir.mkdir() + (cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8") + registrar._ensure_configs() + for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): + if agent_config.get("extension") == "/SKILL.md": + continue + agent_dir = self.project_root / agent_config["dir"] + if not agent_dir.exists(): + continue + try: + registrar.register_commands( + agent_name, + [{ + "name": cmd_name, + "file": f"commands/{cmd_name}.md", + "aliases": replay_aliases, + }], + f"preset:{outermost_pack_id}", + tmp_path, + self.project_root, + ) + except ValueError: + continue + + def _replay_skill_override( + self, + cmd_name: str, + composed_content: str, + outermost_pack_id: str, + ) -> None: + """Rewrite any active SKILL.md override for a replayed wrap command.""" + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar + from .integrations import get_integration + + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + if not isinstance(selected_ai, str): + return + + registrar = CommandRegistrar() + integration = get_integration(selected_ai) + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md" + + skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + target_skill_names: List[str] = [] + if (skills_dir / skill_name).is_dir(): + target_skill_names.append(skill_name) + if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): + target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) + if not target_skill_names: + return + + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + short_name = raw_short_name.replace(".", "-") + skill_title = self._skill_title_from_command(cmd_name) + + frontmatter, body = registrar.parse_frontmatter(composed_content) + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + body = registrar.resolve_skill_placeholders( + selected_ai, dict(frontmatter), body, self.project_root + ) + + for target_skill_name in target_skill_names: + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + skill_subdir.mkdir(parents=True, exist_ok=True) + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{outermost_pack_id}", + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + (skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8") + def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -624,7 +906,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - except ValidationError: + except (ValidationError, TypeError, AttributeError): continue ext_root = ext_dir.resolve() @@ -761,6 +1043,13 @@ def _register_skills( content = source_file.read_text(encoding="utf-8") frontmatter, body = registrar.parse_frontmatter(content) + if frontmatter.get("strategy") == "wrap": + body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + original_desc = frontmatter.get("description", "") enhanced_desc = SKILL_DESCRIPTIONS.get( short_name, @@ -974,6 +1263,24 @@ def install_from_directory( # Update corresponding skills when --ai-skills was previously used registered_skills = self._register_skills(manifest, dest_dir) + # Detect wrap commands before registry.add() so a read failure doesn't + # leave a partially-committed registry entry. + wrap_commands = [] + try: + from .agents import CommandRegistrar as _CR + _registrar = _CR() + for cmd_tmpl in manifest.templates: + if cmd_tmpl.get("type") != "command": + continue + cmd_file = dest_dir / cmd_tmpl["file"] + if not cmd_file.exists(): + continue + cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8")) + if cmd_fm.get("strategy") == "wrap": + wrap_commands.append(cmd_tmpl["name"]) + except ImportError: + pass + self.registry.add(manifest.id, { "version": manifest.version, "source": "local", @@ -982,8 +1289,12 @@ def install_from_directory( "priority": priority, "registered_commands": registered_commands, "registered_skills": registered_skills, + "wrap_commands": wrap_commands, }) + for cmd_name in wrap_commands: + self._replay_wraps_for_command(cmd_name) + return manifest def install_from_zip( @@ -1058,9 +1369,16 @@ def remove(self, pack_id: str) -> bool: # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] registered_commands = metadata.get("registered_commands", {}) if metadata else {} + wrap_commands = metadata.get("wrap_commands", []) if metadata else [] pack_dir = self.presets_dir / pack_id + + # _unregister_skills must run before directory deletion (reads preset files) if registered_skills: self._unregister_skills(registered_skills, pack_dir) + # When _unregister_skills has already handled skill-agent files, strip + # those entries from registered_commands to avoid double-deletion. + # (When registered_skills is empty, skill-agent entries in + # registered_commands are the only deletion path for those files.) try: from .agents import CommandRegistrar except ImportError: @@ -1072,14 +1390,44 @@ def remove(self, pack_id: str) -> bool: if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" } - # Unregister non-skill command files from AI agents. - if registered_commands: - self._unregister_commands(registered_commands) - + # Delete the preset directory before mutating the registry so a + # filesystem failure cannot leave files on disk without a registry entry. if pack_dir.exists(): shutil.rmtree(pack_dir) + # Remove from registry before replaying so _replay_wraps_for_command sees + # the post-removal registry state. self.registry.remove(pack_id) + + # Separate wrap commands from non-wrap commands in registered_commands. + non_wrap_commands = { + agent_name: [c for c in cmd_names if c not in wrap_commands] + for agent_name, cmd_names in registered_commands.items() + } + non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v} + + # Unregister non-wrap command files from AI agents. + if non_wrap_commands: + self._unregister_commands(non_wrap_commands) + + # For each wrapped command, either re-compose remaining wraps or delete. + for cmd_name in wrap_commands: + remaining = [ + pid for pid, meta in self.registry.list().items() + if cmd_name in meta.get("wrap_commands", []) + ] + if remaining: + self._replay_wraps_for_command(cmd_name) + else: + # No wrap presets remain — delete the agent file entirely. + wrap_agent_commands = { + agent_name: [c for c in cmd_names if c == cmd_name] + for agent_name, cmd_names in registered_commands.items() + } + wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v} + if wrap_agent_commands: + self._unregister_commands(wrap_agent_commands) + return True def list_installed(self) -> List[Dict[str, Any]]: @@ -1735,6 +2083,7 @@ def resolve( self, template_name: str, template_type: str = "template", + skip_presets: bool = False, ) -> Optional[Path]: """Resolve a template name to its file path. @@ -1743,6 +2092,8 @@ def resolve( Args: template_name: Template name (e.g., "spec-template") template_type: Template type ("template", "command", or "script") + skip_presets: When True, skip tier 2 (installed presets). Use + resolve_core() as the preferred caller-facing API for this. Returns: Path to the resolved template file, or None if not found @@ -1771,7 +2122,7 @@ def resolve( return override # Priority 2: Installed presets (sorted by priority — lower number wins) - if self.presets_dir.exists(): + if not skip_presets and self.presets_dir.exists(): registry = PresetRegistry(self.presets_dir) for pack_id, _metadata in registry.list_by_priority(): pack_dir = self.presets_dir / pack_id @@ -1810,6 +2161,99 @@ def resolve( if core.exists(): return core + # Priority 5: Bundled core_pack (wheel install) or repo-root templates + # (source-checkout / editable install). This is the canonical home for + # speckit's built-in command/template files and must always be checked + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. + from specify_cli import _locate_core_pack # local import to avoid cycles + _core_pack = _locate_core_pack() + if _core_pack is not None: + # Wheel install path + if template_type == "template": + candidate = _core_pack / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = _core_pack / "commands" / f"{template_name}.md" + elif template_type == "script": + candidate = _core_pack / "scripts" / f"{template_name}{ext}" + else: + candidate = _core_pack / f"{template_name}.md" + if candidate.exists(): + return candidate + else: + # Source-checkout / editable install: templates live at repo root + repo_root = Path(__file__).parent.parent.parent + if template_type == "template": + candidate = repo_root / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + elif template_type == "script": + candidate = repo_root / "scripts" / f"{template_name}{ext}" + else: + candidate = repo_root / f"{template_name}.md" + if candidate.exists(): + return candidate + + return None + + def resolve_core( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve while skipping installed presets (tier 2). + + Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback). + Use when resolving {CORE_TEMPLATE} to guarantee the result is actual + base content, never another preset's wrap output. + """ + return self.resolve(template_name, template_type, skip_presets=True) + + def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]: + """Resolve an extension command by consulting installed extension manifests. + + Walks installed extension directories in priority order, loads each + extension.yml via ExtensionManifest, and looks up the command by its + declared name to find the actual file path. This is necessary because + the manifest's ``provides.commands[].file`` field is authoritative and + may differ from the command name + (e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``). + + Returns None if no manifest maps the given command name, so the caller + can fall back to the name-based lookup. + """ + if not self.extensions_dir.exists(): + return None + + from .extensions import ExtensionManifest, ValidationError + + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + continue + for cmd_info in manifest.commands: + if cmd_info.get("name") != cmd_name: + continue + file_rel = cmd_info.get("file") + if not file_rel: + continue + # Mirror the containment check in ExtensionManager to guard against + # path traversal via a malformed manifest (e.g. file: ../../AGENTS.md). + cmd_path = Path(file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = ext_dir.resolve() + candidate = (ext_root / cmd_path).resolve() + candidate.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + if candidate.is_file(): + return candidate return None def resolve_with_source( diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 04a91682e8..ff9386d626 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -261,7 +261,7 @@ def test_without_force_errors_on_existing_dir(self, tmp_path): ], catch_exceptions=False) assert result.exit_code == 1 - assert "already exists" in result.output + assert "already exists" in _normalize_cli_output(result.output) class TestGitExtensionAutoInstall: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5379178afe..e1acb8486b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -217,6 +217,14 @@ def test_missing_required_field(self, temp_dir): with pytest.raises(ValidationError, match="Missing required field"): ExtensionManifest(manifest_path) + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError.""" + manifest_path = temp_dir / "extension.yml" + for bad_content in ("42\n", "[]\n", "null\n"): + manifest_path.write_text(bad_content) + with pytest.raises(ValidationError, match="YAML mapping"): + ExtensionManifest(manifest_path) + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml diff --git a/tests/test_presets.py b/tests/test_presets.py index 60322b99a1..d913c3b195 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -999,6 +999,94 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): assert result is None +class TestResolveCore: + """Test PresetResolver.resolve_core() skips the installed-presets tier.""" + + def test_resolve_core_does_not_return_preset_files(self, project_dir): + """resolve_core must not return files from .specify/presets/.""" + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + # The preset file must never be returned — but the bundled core may be. + if result is not None: + assert "presets" not in result.parts + + def test_resolve_core_returns_core_template(self, project_dir): + """resolve_core falls through to core templates (tier 4).""" + core_cmd_dir = project_dir / ".specify" / "templates" / "commands" + core_cmd_dir.mkdir(parents=True, exist_ok=True) + (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Also place a preset file — resolve_core must still return the core + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert "presets" not in result.parts + assert result.parts[-3:] == ("templates", "commands", "specify.md") + + def test_resolve_core_returns_override(self, project_dir): + """resolve_core returns tier-1 override if present.""" + override_dir = project_dir / ".specify" / "templates" / "overrides" + override_dir.mkdir(parents=True) + (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert result.parts[-2:] == ("overrides", "specify.md") + + def test_resolve_core_returns_extension_template(self, project_dir): + """resolve_core returns extension templates (tier 3).""" + ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" + ext_cmd_dir.mkdir(parents=True) + (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("myext-cmd", "command") + assert result is not None + assert result.parts[-4:-1] == ("extensions", "myext", "commands") + + def test_resolve_core_returns_none_when_nothing_found(self, project_dir): + """resolve_core returns None when no file found in tiers 1/3/4.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("nonexistent", "command") + assert result is None + + def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): + """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" + import unittest.mock as mock + + ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" + " description: d\n author: a\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n commands:\n" + " - name: speckit.bad-ext.mycmd\n" + " file: commands/mycmd.md\n" + " description: My command\n" + ) + + resolver = PresetResolver(project_dir) + # Simulate a permission error when opening the manifest file. + with mock.patch("builtins.open", side_effect=PermissionError("denied")): + result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") + + assert result is None, "OSError during manifest load must be silently skipped" + + class TestExtensionPriorityResolution: """Test extension priority resolution with registered and unregistered extensions.""" @@ -1665,7 +1753,7 @@ def test_self_test_manifest_valid(self): assert manifest.id == "self-test" assert manifest.name == "Self-Test Preset" assert manifest.version == "1.0.0" - assert len(manifest.templates) == 7 # 6 templates + 1 command + assert len(manifest.templates) == 8 # 6 templates + 2 commands def test_self_test_provides_all_core_templates(self): """Verify the self-test preset provides an override for every core template.""" @@ -3041,3 +3129,1184 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir): output = strip_ansi(result.output).lower() assert "bundled" in output, result.output assert "reinstall" in output, result.output + + +class TestWrapStrategy: + """Tests for strategy: wrap preset command substitution.""" + + def test_substitute_core_template_replaces_placeholder(self, project_dir): + """Core template body replaces {CORE_TEMPLATE} in preset command body.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Set up a core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" + ) + + registrar = CommandRegistrar() + body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "{CORE_TEMPLATE}" not in result + assert "# Core Specify" in result + assert "## Pre-Logic" in result + assert "## Post-Logic" in result + assert core_fm.get("description") == "core" + + def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): + """Returns body unchanged when {CORE_TEMPLATE} is not present.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") + + registrar = CommandRegistrar() + body = "## No placeholder here.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result == body + assert core_fm == {} + + def test_substitute_core_template_no_op_when_core_missing(self, project_dir): + """Returns body unchanged when core template file does not exist.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) + assert result == body + assert "{CORE_TEMPLATE}" in result + assert core_fm == {} + + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): + """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" + from specify_cli.agents import CommandRegistrar + + # Set up core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" + ) + + # Create a preset command dir with a wrap-strategy command + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] + registrar = CommandRegistrar() + + # Use a generic agent that writes markdown to commands/ + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir + import copy + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", commands, "test-preset", + project_dir / "preset", project_dir + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Specify" in written + assert "## Pre" in written + assert "## Post" in written + + def test_end_to_end_wrap_via_self_test_preset(self, project_dir): + """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" + from specify_cli.presets import PresetManager + + # Install a core template that wrap-test will wrap around + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" + ) + + # Set up skills dir (simulating --ai claude) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + + # Write init-options so _register_skills finds the claude skills dir + import json + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + written = (skill_subdir / "SKILL.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Wrap-Test Body" in written + assert "preset:self-test wrap-pre" in written + assert "preset:self-test wrap-post" in written + + def test_substitute_core_template_returns_core_scripts(self, project_dir): + """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "# Body" in result + assert core_fm.get("scripts") == {"sh": "run.sh"} + assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + + def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): + """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" + from specify_cli.presets import PresetManager + import json + + # Core template with scripts + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" + "Run: {SCRIPT}\n" + ) + + # Skills dir for claude + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + written = (skill_subdir / "SKILL.md").read_text() + # {SCRIPT} should have been resolved (not left as a literal placeholder) + assert "{SCRIPT}" not in written + + def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): + """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" + ) + + registrar = CommandRegistrar() + body = "{CORE_TEMPLATE}" + _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + # Simulate preset frontmatter that already defines scripts + preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} + for key in ("scripts", "agent_scripts"): + if key not in preset_fm and key in core_fm: + preset_fm[key] = core_fm[key] + + # Preset's scripts must not be overwritten by core + assert preset_fm["scripts"] == {"sh": "preset-run.sh"} + + def test_register_commands_inherits_scripts_from_core(self, project_dir): + """register_commands merges scripts/agent_scripts from core and normalizes paths.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + # Preset has strategy: wrap but no scripts of its own + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "Run:" in written + assert "scripts:" in written + assert "run.sh" in written + + def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): + """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + toml_dir = project_dir / ".gemini" / "commands" + toml_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-toml-agent"] = { + "dir": str(toml_dir.relative_to(project_dir)), + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-toml-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (toml_dir / "speckit.specify.toml").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + # args token must use TOML format, not the intermediate $ARGUMENTS + assert "$ARGUMENTS" not in written + assert "{{args}}" in written + + def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): + """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-md-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-md-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + assert "strategy" not in written + + def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): + """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + + resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second + _convert_argument_placeholder call must convert those to the agent's native format. + """ + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".forge" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-forge-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-forge-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{SCRIPT}" not in written + assert "run.sh" in written + # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted + assert "$ARGUMENTS" not in written + assert "{{parameters}}" in written + + def test_extension_command_resolves_via_extension_directory(self, project_dir): + """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. + + Both _register_skills and register_commands pass the full cmd_name to + _substitute_core_template, which tries the full name first via PresetResolver + and finds speckit.git.feature.md in the extension commands directory. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Place the template where a real extension would install it + ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" + ext_cmd_dir.mkdir(parents=True, exist_ok=True) + (ext_cmd_dir / "speckit.git.feature.md").write_text( + "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + ) + # Ensure a hyphenated or dot-separated fallback does NOT exist + assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() + assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + + # Both call sites now pass the full cmd_name + result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + + assert "# Git Feature Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): + """Extension commands whose filename differs from the command name resolve via extension.yml. + + The selftest extension maps speckit.selftest.extension → commands/selftest.md. + Name-based lookup would look for commands/speckit.selftest.extension.md and fail; + manifest-based lookup must find the actual file declared in the manifest. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + + # File is named selftest.md, NOT speckit.selftest.extension.md + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + ) + # Manifest maps the command name to the actual file + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) + + assert "# Selftest Core" in result + assert "{CORE_TEMPLATE}" not in result + + +# ===== _replay_wraps_for_command Tests ===== + +def _make_wrap_preset_dir( + base: Path, + preset_id: str, + cmd_name: str, + pre: str, + post: str, + aliases: list[str] | None = None, + file_rel: str | None = None, +) -> Path: + """Create a minimal wrap-strategy preset directory for testing.""" + preset_dir = base / preset_id + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + file_rel = file_rel or f"commands/{cmd_name}.md" + template = { + "type": "command", + "name": cmd_name, + "file": file_rel, + "description": f"{preset_id} wrap", + } + if aliases is not None: + template["aliases"] = aliases + manifest = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": f"Preset {preset_id}", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [template] + }, + "tags": [], + } + import yaml as _yaml + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + command_path = preset_dir / file_rel + command_path.parent.mkdir(parents=True, exist_ok=True) + command_path.write_text( + f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" + f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" + ) + return preset_dir + + +class TestReplayWrapsForCommand: + """Tests for PresetManager._replay_wraps_for_command().""" + + def test_replay_no_op_when_no_wrap_presets(self, project_dir): + """replay does nothing when no presets declare wrap_commands for the command.""" + manager = PresetManager(project_dir) + # Should not raise + manager._replay_wraps_for_command("speckit.specify") + + def test_replay_no_op_when_core_missing(self, project_dir, temp_dir): + """replay exits gracefully when resolve_core returns None.""" + from specify_cli.agents import CommandRegistrar + import copy + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.nonexistent-cmd", "pre-a", "post-a") + installed = project_dir / ".specify" / "presets" / "preset-a" + import shutil as _shutil + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.nonexistent-cmd"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + # No core file exists for this command — replay should return without writing + manager._replay_wraps_for_command("speckit.nonexistent-cmd") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.nonexistent-cmd.md").exists() + + def test_replay_single_preset_writes_composed_output(self, project_dir, temp_dir): + """Single wrap preset: replay writes pre + core + post to agent dirs.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + # Core template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Install preset-a + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "[pre-a]" in written + assert "core body" in written + assert "[post-a]" in written + assert "{CORE_TEMPLATE}" not in written + assert "strategy" not in written + + def test_replay_uses_manifest_command_file_mapping(self, project_dir, temp_dir): + """Replay reads wrapper files from preset.yml instead of assuming command-name paths.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir( + temp_dir, + "preset-a", + "speckit.specify", + "pre-a", + "post-a", + file_rel="commands/custom-wrapper.md", + ) + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "[pre-a]" in written + assert "CORE" in written + assert "[post-a]" in written + + def test_replay_resolves_extension_core_via_manifest_mapping(self, project_dir, temp_dir): + """Replay finds extension core commands whose manifest file differs from command name.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\nEXTENSION-CORE\n" + ) + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + preset_dir = _make_wrap_preset_dir( + temp_dir, "preset-a", "speckit.selftest.extension", "pre-a", "post-a" + ) + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.selftest.extension"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.selftest.extension") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.selftest.extension.md").read_text() + assert "[pre-a]" in written + assert "EXTENSION-CORE" in written + assert "[post-a]" in written + + def test_replay_priority_order_lower_number_outermost(self, project_dir, temp_dir): + """Two wrap presets: lower priority number = outermost wrapper.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + for pid in ("preset-outer", "preset-inner"): + src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") + _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + + manager = PresetManager(project_dir) + # preset-outer has priority 1 (highest precedence = outermost) + # preset-inner has priority 10 (lowest precedence = innermost) + for pid, pri in (("preset-outer", 1), ("preset-inner", 10)): + manager.registry.add(pid, { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": pri, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + # Outermost (preset-outer, p=1) wraps everything; innermost (preset-inner, p=10) is next + outer_pre = written.index("[pre-preset-outer]") + inner_pre = written.index("[pre-preset-inner]") + core_pos = written.index("CORE") + inner_post = written.index("[post-preset-inner]") + outer_post = written.index("[post-preset-outer]") + assert outer_pre < inner_pre < core_pos < inner_post < outer_post + + def test_replay_install_order_independent(self, project_dir, temp_dir): + """Nesting order is determined by priority, not install order.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + for pid in ("preset-a", "preset-b"): + src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") + _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + + manager = PresetManager(project_dir) + # preset-a priority=5 (outermost), preset-b priority=10 (innermost) + # Install in reverse order to verify install order doesn't affect nesting + for pid, pri in (("preset-b", 10), ("preset-a", 5)): + manager.registry.add(pid, { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": pri, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + a_pre = written.index("[pre-preset-a]") + b_pre = written.index("[pre-preset-b]") + core_pos = written.index("CORE") + b_post = written.index("[post-preset-b]") + a_post = written.index("[post-preset-a]") + # preset-a (p=5) is outermost regardless of install order + assert a_pre < b_pre < core_pos < b_post < a_post + + def test_replay_updates_skill_outputs(self, project_dir, temp_dir): + """Replay also rewrites SKILL.md-backed agent outputs.""" + import json + import shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + skills_dir = project_dir / ".claude" / "skills" + skill_subdir = skills_dir / "speckit-specify" + skill_subdir.mkdir(parents=True) + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager._replay_wraps_for_command("speckit.specify") + + written = (skill_subdir / "SKILL.md").read_text() + assert "[pre-a]" in written + assert "CORE" in written + assert "[post-a]" in written + + def test_replay_applies_integration_post_processing_to_skill(self, project_dir, temp_dir): + """_replay_skill_override must call post_process_skill_content, matching _register_skills.""" + import json + import shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + skills_dir = project_dir / ".claude" / "skills" + skill_subdir = skills_dir / "speckit-specify" + skill_subdir.mkdir(parents=True) + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager._replay_wraps_for_command("speckit.specify") + + # ClaudeIntegration.post_process_skill_content injects these flags. + # Their presence proves the integration hook ran during replay. + written = (skill_subdir / "SKILL.md").read_text() + assert "disable-model-invocation: false" in written, ( + "_replay_skill_override must call post_process_skill_content " + "(same as _register_skills)" + ) + + +class TestInstallRemoveWrapLifecycle: + """Tests for wrap_commands stored on install and replayed on remove.""" + + def _setup_agent(self, project_dir, registrar, agent_configs_dict): + """Register a test markdown agent and return its commands dir.""" + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + agent_configs_dict["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + return agent_dir + + def test_install_stores_wrap_commands_in_registry(self, project_dir, temp_dir): + """install_from_directory stores wrap_commands in the registry entry.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore\n") + + preset_src = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre", "post") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + manager.install_from_directory(preset_src, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + meta = manager.registry.get("preset-a") + assert "wrap_commands" in meta + assert "speckit.specify" in meta["wrap_commands"] + + def test_install_replay_produces_correct_nested_output(self, project_dir, temp_dir): + """After installing two wrap presets, agent file contains correctly nested output.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + # Install outermost first (priority=5), then innermost (priority=10) + outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") + # Rename to avoid id conflict with fixture + inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + outer_pre = written.index("OUTER-PRE") + inner_pre = written.index("INNER-PRE") + core_pos = written.index("CORE") + inner_post = written.index("INNER-POST") + outer_post = written.index("OUTER-POST") + assert outer_pre < inner_pre < core_pos < inner_post < outer_post + + def test_remove_replays_remaining_wraps(self, project_dir, temp_dir): + """Removing one wrap preset re-composes the remaining wraps correctly.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") + inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + manager.remove("preset-outer") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + # Only inner wrap remains — should be: INNER-PRE + CORE + INNER-POST, no OUTER + assert "INNER-PRE" in written + assert "CORE" in written + assert "INNER-POST" in written + assert "OUTER-PRE" not in written + assert "OUTER-POST" not in written + + def test_wrap_aliases_are_replayed_and_removed(self, project_dir, temp_dir): + """Replay preserves wrap aliases across install/remove lifecycle changes.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + outer_src = _make_wrap_preset_dir( + temp_dir, + "preset-outer", + "speckit.specify", + "OUTER-PRE", + "OUTER-POST", + aliases=["speckit.alias"], + ) + inner_src = _make_wrap_preset_dir( + temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST" + ) + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + + alias_file = agent_dir / "speckit.alias.md" + written = alias_file.read_text() + assert "OUTER-PRE" in written + assert "INNER-PRE" in written + assert "INNER-POST" in written + assert "OUTER-POST" in written + + manager.remove("preset-inner") + written = alias_file.read_text() + assert "OUTER-PRE" in written + assert "OUTER-POST" in written + assert "INNER-PRE" not in written + assert "INNER-POST" not in written + + manager.remove("preset-outer") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.alias.md").exists() + + def test_remove_last_wrap_preset_deletes_agent_file(self, project_dir, temp_dir): + """Removing the only wrap preset deletes the agent command file.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + src = _make_wrap_preset_dir(temp_dir, "preset-only", "speckit.specify", "PRE", "POST") + manager.install_from_directory(src, "0.1.0", priority=10) + assert (agent_dir / "speckit.specify.md").exists() + manager.remove("preset-only") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.specify.md").exists() + + def test_remove_keeps_registry_entry_when_directory_delete_fails(self, project_dir, monkeypatch): + """A failed preset directory delete must not leave files untracked by the registry.""" + manager = PresetManager(project_dir) + pack_dir = manager.presets_dir / "preset-a" + pack_dir.mkdir(parents=True) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": [], + }) + + def fail_rmtree(_path): + raise OSError("locked") + + monkeypatch.setattr(shutil, "rmtree", fail_rmtree) + + with pytest.raises(OSError, match="locked"): + manager.remove("preset-a") + + assert manager.registry.is_installed("preset-a") + assert pack_dir.exists() + + def test_non_wrap_commands_unaffected_by_wrap_lifecycle(self, project_dir, temp_dir): + """wrap_commands is empty for a preset with no strategy:wrap commands.""" + from specify_cli.agents import CommandRegistrar + import copy + import yaml as _yaml + + # Create a preset with a non-wrap command + preset_dir = temp_dir / "non-wrap-preset" + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "non-wrap-preset", "name": "Non-wrap", "version": "1.0.0", + "description": "no wrap", "author": "test", + "repository": "https://example.com", "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [ + {"type": "command", "name": "speckit.specify", + "file": "commands/speckit.specify.md", "description": "override"}, + ]}, + "tags": [], + } + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: plain override\n---\n\nplain body\n" + ) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + meta = manager.registry.get("non-wrap-preset") + assert meta.get("wrap_commands", []) == [] + written = (agent_dir / "speckit.specify.md").read_text() + assert "plain body" in written From dd9c0b050020e48e62623b0d5da5ef27d98d3a89 Mon Sep 17 00:00:00 2001 From: WangX <111967294+WangX0111@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:14:58 +0800 Subject: [PATCH 293/321] Add superpowers-bridge community extension (#2309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add superpowers-bridge community extension Adds the superpowers-bridge extension by WangX0111 to the community catalog and README table. This extension bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent-driven-development, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking. Extension details: - ID: superpowers-bridge - Repository: https://github.com/WangX0111/superspec - Version: 1.0.0 - Commands: 5, Hooks: 3 - License: MIT * Address Copilot review feedback - Update top-level updated_at to 2026-04-22 - Shorten description to under 200 characters --------- Co-authored-by: 乘浩 --- README.md | 1 + extensions/catalog.community.json | 37 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38d319c9fa..c1c723c5b3 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ The following community-contributed extensions are available in [`catalog.commun | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | | TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 53bea347ca..55848afb55 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-21T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2122,6 +2122,39 @@ "created_at": "2026-03-30T00:00:00Z", "updated_at": "2026-04-16T14:08:23Z" }, + "superpowers-bridge": { + "name": "Superpowers Bridge", + "id": "superpowers-bridge", + "description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.", + "author": "WangX0111", + "version": "1.0.0", + "download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/WangX0111/superspec", + "homepage": "https://github.com/WangX0111/superspec", + "documentation": "https://github.com/WangX0111/superspec/blob/main/README.md", + "changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 5, + "hooks": 3 + }, + "tags": [ + "superpowers", + "brainstorming", + "tdd", + "code-review", + "subagent", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", @@ -2405,4 +2438,4 @@ "updated_at": "2026-04-13T00:00:00Z" } } -} +} \ No newline at end of file From 4dcf2921d1f725f9bde43bcc9830bbfbb980990a Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Wed, 22 Apr 2026 15:33:08 +0200 Subject: [PATCH 294/321] feat(catalog): add red-team extension to community catalog (#2306) * feat(catalog): add red-team extension Adds the `red-team` community extension to the catalog: - Adversarial review of functional specs before /speckit.plan locks in architecture. - Complements /speckit.clarify (correctness) and /speckit.analyze (consistency) with parallel adversarial lens agents. - One command: speckit.red-team.run - MIT licensed; requires spec-kit >= 0.7.0. Origin: this extension was originally proposed as a core command (github/spec-kit#2303). Per maintainer guidance (mnriem's comment on that PR), it's been restructured as a community extension hosted at https://github.com/ashbrener/spec-kit-red-team. Dogfood-validated on a 500-line functional spec: 5 lens agents dispatched in parallel returned 25 findings in ~1.5 min wall-clock, 19 of which met the meaningful-finding bar (severity >= HIGH AND novel adversarial angle that clarify/analyze structurally cannot catch). Full detail in the extension's CHANGELOG. * catalog: shorten red-team description to fit <200 char schema limit Resolves Copilot review comment on #2306. Previous description (259 chars) exceeded the extensions/EXTENSION-PUBLISHING-GUIDE.md Appendix schema ceiling. Shortened to 188 chars, keeping the distinctive value proposition (adversarial, complements clarify/analyze) and moving the per-phase mechanics to the extension's own README. * catalog: bump red-team to v1.0.1 (lower required spec-kit version) Follow-up to v1.0.0 catalog entry: - version: 1.0.0 -> 1.0.1 - download_url: points at v1.0.1 release asset - requires.speckit_version: >=0.7.0 -> >=0.1.0 The v1.0.0 requirement was too strict and blocked installation on common 0.6.x field versions (confirmed via local install attempt). The extension uses no 0.7.x-specific APIs; matches community norm (reconcile, refine, others use >=0.1.0). * catalog: bump red-team to v1.0.2 (adds mandatory before_plan gate) v1.0.2 ships a /speckit.red-team.gate command wired as a mandatory before_plan hook so /speckit.plan auto-invokes it on every run against qualifying specs. Non-qualifying specs return PROCEED silently; qualifying specs without findings on record return HALT with explicit remediation (run /speckit.red-team.run, or opt out via --skip-red-team-gate: which is recorded as an Accepted Risk [red-team-skipped] in the plan). Catalog metadata delta: - version: 1.0.1 -> 1.0.2 - download_url: v1.0.2/red-team-v1.0.2.zip - provides.commands: 1 -> 2 (adds speckit.red-team.gate) - provides.hooks: 0 -> 1 (adds before_plan hook) No breaking changes. Projects that do not want the gate simply do not install the extension. --------- Co-authored-by: Ash Brener --- extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 55848afb55..ff4718c96a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1523,6 +1523,38 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "red-team": { + "name": "Red Team", + "id": "red-team", + "description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.", + "author": "Ash Brener", + "version": "1.0.2", + "download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip", + "repository": "https://github.com/ashbrener/spec-kit-red-team", + "homepage": "https://github.com/ashbrener/spec-kit-red-team", + "documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md", + "changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "adversarial-review", + "quality-gate", + "spec-hardening", + "pre-plan", + "audit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "refine": { "name": "Spec Refine", "id": "refine", From deb80956f381121837dd609ff5f08a0806dad089 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Wed, 22 Apr 2026 16:03:06 +0200 Subject: [PATCH 295/321] docs(readme): list red-team in community-extensions table (#2311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2306 (merged). Per maintainer request (https://github.com/github/spec-kit/pull/2306#issuecomment-4296655643), adds the red-team entry to the alphabetically-ordered community-extensions table in README.md so the extension is discoverable alongside the other community entries — not only via catalog.community.json. Slotted alphabetically between "Reconcile Extension" and "Repository Index". Category: docs. Effect: Read+Write (produces a structured findings-report file at specs//red-team-findings-*.md; does not modify specs — every resolution is maintainer-authorised). Co-authored-by: Ash Brener --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c1c723c5b3..618e485045 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The following community-contributed extensions are available in [`catalog.commun | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) | | Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | From d402a392c3a4d0f9d57d9e810a36a1eff8f61777 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:17:48 -0500 Subject: [PATCH 296/321] Move community walkthroughs from README to docs/community (#2312) * Move community walkthroughs from README to docs/community Extract the community walkthroughs section from README.md into its own docs/community/walkthroughs.md file and replace it with a short summary linking to the GitHub Pages URL. * Address review: fix double-See phrasing, add walkthroughs to docs nav --- README.md | 19 +------------------ docs/community/walkthroughs.md | 20 ++++++++++++++++++++ docs/toc.yml | 2 ++ 3 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 docs/community/walkthroughs.md diff --git a/README.md b/README.md index 618e485045..a1c0af220e 100644 --- a/README.md +++ b/README.md @@ -291,24 +291,7 @@ To build and publish your own preset, see the [Presets Publishing Guide](presets ## 🚶 Community Walkthroughs -> [!NOTE] -> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. - -See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: - -- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. - -- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. - -- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. - -- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. - -- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. - -- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. - -- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. +See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page. ## 🛠️ Community Friends diff --git a/docs/community/walkthroughs.md b/docs/community/walkthroughs.md new file mode 100644 index 0000000000..b32c025803 --- /dev/null +++ b/docs/community/walkthroughs.md @@ -0,0 +1,20 @@ +# Community Walkthroughs + +> [!NOTE] +> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. + +See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: + +- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. + +- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. + +- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. + +- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. + +- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. + +- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. + +- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. diff --git a/docs/toc.yml b/docs/toc.yml index add814d757..ba65853d1f 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -37,5 +37,7 @@ # Community - name: Community items: + - name: Walkthroughs + href: community/walkthroughs.md - name: Friends href: community/friends.md From c52ea23ba2acbd2ac35f846844695232b7151698 Mon Sep 17 00:00:00 2001 From: TortoiseWolfe <101219421+TortoiseWolfe@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:20:13 -0400 Subject: [PATCH 297/321] catalog: add wireframe extension (v0.1.1) (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * catalog: add wireframe extension Adds https://github.com/TortoiseWolfe/spec-kit-extension-wireframe (v0.1.0) to the community catalog. Provides a visual feedback loop for spec-driven development: SVG wireframe generation, review, and sign-off. Approved wireframes become spec constraints honored by /plan, /tasks, and /implement. Supersedes #1410 — the old PR predated the extension system introduced in #2130 and proposed commands in templates/commands/, which is no longer the right home for third-party commands. * catalog: address review feedback (position + author) Two changes per Copilot review: - Move `wireframe` entry alphabetically between `whatif` and `worktree` (was appended after `worktrees`). - Simplify `author` from "TortoiseWolfe (turtlewolfe.com)" to just "TortoiseWolfe" so the exact-match author filter in `ExtensionCatalog.search` finds the entry. Portfolio URL remains accessible via `homepage`/`repository`. Thanks @Copilot, @mnriem for the review. * docs(readme): add Wireframe Visual Feedback Loop row Addresses @mnriem's follow-up: the README extension table also needs an entry, not just the catalog JSON. Slots in alphabetically between "What-if Analysis" and "Worktree Isolation" with category `visibility` and Read+Write effect (since sign-off writes the approved wireframe paths into spec.md). * catalog: use speckit-prefixed command names in wireframe description Address remaining Copilot review comment on PR #2262. The actual commands are /speckit.plan, /speckit.tasks, /speckit.implement; the unprefixed names would mislead catalog users. Co-Authored-By: Claude Opus 4.7 (1M context) * catalog: bump wireframe extension to v0.1.1 v0.1.1 of spec-kit-extension-wireframe ships the /speckit.-prefixed command references in extension.yml and README.md. This updates the catalog entry to point at the new release tag so `specify extension add wireframe` installs the corrected version. * catalog: set wireframe created_at to current timestamp Per EXTENSION-PUBLISHING-GUIDE.md: newly added entries should use the current timestamp for both created_at and updated_at. The 04-17 value reflected when I drafted the entry locally, not when the catalog submission landed. --------- Co-authored-by: TortoiseWolfe Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 1 + extensions/catalog.community.json | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/README.md b/README.md index a1c0af220e..15e528b5e6 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ The following community-contributed extensions are available in [`catalog.commun | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | +| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | | Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ff4718c96a..9ddc29b558 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2405,6 +2405,41 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "wireframe": { + "name": "Wireframe Visual Feedback Loop", + "id": "wireframe", + "description": "SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement.", + "author": "TortoiseWolfe", + "version": "0.1.1", + "download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md", + "changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 6, + "hooks": 3 + }, + "tags": [ + "wireframe", + "visual", + "design", + "ui", + "mockup", + "svg", + "feedback-loop", + "sign-off" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "worktree": { "name": "Worktree Isolation", "id": "worktree", From efb04e26ebd21d2640e3d4dd9f7e5e1e2e385080 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:05:14 -0500 Subject: [PATCH 298/321] docs: move community presets from README to docs/community (#2314) Move the community presets table from the main README to a dedicated docs/community/presets.md page, matching the pattern used for walkthroughs and friends. - Add docs/community/presets.md with the full presets table - Add Claude AskUserQuestion preset (was in catalog but missing from table) - Add Presets entry to docs/toc.yml under Community - Replace inline README table with a short link to the docs page --- README.md | 18 +----------------- docs/community/presets.md | 20 ++++++++++++++++++++ docs/toc.yml | 2 ++ 3 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 docs/community/presets.md diff --git a/README.md b/README.md index 15e528b5e6..468608a6e4 100644 --- a/README.md +++ b/README.md @@ -272,23 +272,7 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX ## 🎨 Community Presets -> [!NOTE] -> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. - -The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): - -| Preset | Purpose | Provides | Requires | URL | -|--------|---------|----------|----------|-----| -| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | -| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | -| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | -| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | -| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | -| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | -| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | - -To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). +Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page. ## 🚶 Community Walkthroughs diff --git a/docs/community/presets.md b/docs/community/presets.md new file mode 100644 index 0000000000..c214e97c03 --- /dev/null +++ b/docs/community/presets.md @@ -0,0 +1,20 @@ +# Community Presets + +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | +| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | +| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | + +To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md). diff --git a/docs/toc.yml b/docs/toc.yml index ba65853d1f..636a8f03a1 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -37,6 +37,8 @@ # Community - name: Community items: + - name: Presets + href: community/presets.md - name: Walkthroughs href: community/walkthroughs.md - name: Friends From 58f7a43ec35b2af9f1c7d34e07025f2170c50d68 Mon Sep 17 00:00:00 2001 From: Kevin Brown <51965072+KevinBrown5280@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:27:56 -0400 Subject: [PATCH 299/321] Update version-guard to v1.1.0 (#2318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 1.0.0 → 1.1.0 - Commands: 1 → 3 (check, load, validate) - Hooks: 2 → 4 (before_plan, before_tasks, before_implement, after_implement) - Added: persistent constraints artifact, two-channel model, CVE detection, decision record fallback for greenfield projects, skip artifact persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9ddc29b558..aa59d27bfa 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T17:54:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2351,8 +2351,8 @@ "id": "version-guard", "description": "Verify tech stack versions against live registries before planning and implementation", "author": "KevinBrown5280", - "version": "1.0.0", - "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", @@ -2362,8 +2362,8 @@ "speckit_version": ">=0.2.0" }, "provides": { - "commands": 1, - "hooks": 2 + "commands": 3, + "hooks": 4 }, "tags": [ "versioning", @@ -2375,7 +2375,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-20T00:00:00Z", - "updated_at": "2026-04-20T00:00:00Z" + "updated_at": "2026-04-22T17:54:00Z" }, "whatif": { "name": "What-if Analysis", From c5c20134df2364ac3f967bbfe5a41195b19d611e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:33:03 +0900 Subject: [PATCH 300/321] feat(cli): add specify self check and self upgrade stub (#2316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add specify self check and self upgrade stub (#2282) Introduce a new `specify self` Typer sub-app with two subcommands. `specify self check` performs a read-only lookup against the GitHub Releases API, compares the installed version to the latest tag with PEP 440 semantics, and prints one of four verdicts (newer-available, up-to-date, indeterminate, graceful-failure). When a newer stable release is available, the output includes a copy-pasteable `uv tool install --force --from git+...@` reinstall command. `GH_TOKEN` / `GITHUB_TOKEN` is attached as a bearer credential when set so users behind shared IPs escape the anonymous 60/hour rate limit. `specify self upgrade` is a documented non-destructive stub in this release: three-line guidance output, exit 0, no outbound call, no install-method detection. The real destructive implementation is planned as follow-up work. Failure categorization is a fixed three-entry enum (offline or timeout, rate limited, HTTP ). Anything outside those three categories propagates as a non-zero exit so bugs surface instead of being silently swallowed. No machine-readable output, no retries, no caching in this release — see issue #2282 discussion. Tests mock `urllib.request.urlopen`; the suite performs zero real network calls. Full regression suite: 1586 passed. * fix(cli): disable Rich highlight for deterministic output Rich's default `highlight=True` applies ANSI color to detected patterns (integers, version strings, paths) whenever stdout is deemed a TTY. This caused intermittent failures in existing pytest assertions in tests/test_cli_version.py and tests/test_extensions.py::TestExtensionRemoveCLI that compare plain-text output without passing through `strip_ansi()`. Setting `Console(highlight=False)` globally makes all CLI output deterministic and fixes the flake without modifying the affected tests. The numeric cyan highlighting was not a documented part of the CLI visual contract. * fix: address copilot review feedback * fix: tighten self-check token handling * fix: align self-check helpers and script metadata * fix: harden self-check version handling * fix: guard self-check failure rendering --- src/specify_cli/__init__.py | 184 ++++++++++++++++-- tests/test_upgrade.py | 371 ++++++++++++++++++++++++++++++++++++ 2 files changed, 538 insertions(+), 17 deletions(-) create mode 100644 tests/test_upgrade.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 97cb993a96..839562f111 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -7,6 +7,8 @@ # "platformdirs", # "readchar", # "json5", +# "pyyaml", +# "packaging", # ] # /// """ @@ -34,8 +36,12 @@ import json5 import stat import shlex +import urllib.error +import urllib.request import yaml from pathlib import Path + +from packaging.version import InvalidVersion, Version from typing import Any, Optional import typer @@ -51,6 +57,8 @@ # For cross-platform keyboard input import readchar +GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" + def _build_agent_config() -> dict[str, dict[str, Any]]: """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" from .integrations import INTEGRATION_REGISTRY @@ -318,7 +326,7 @@ def run_selection_loop(): return selected_key -console = Console() +console = Console(highlight=False) class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -1599,25 +1607,10 @@ def check(): def version(): """Display version and system information.""" import platform - import importlib.metadata show_banner() - # Get CLI version from package metadata - cli_version = "unknown" - try: - cli_version = importlib.metadata.version("specify-cli") - except Exception: - # Fallback: try reading from pyproject.toml if running from source - try: - import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - cli_version = data.get("project", {}).get("version", "unknown") - except Exception: - pass + cli_version = get_speckit_version() info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") @@ -1640,6 +1633,163 @@ def version(): console.print(panel) console.print() +def _get_installed_version() -> str: + """Return the installed specify-cli distribution version or 'unknown'. + + Uses importlib.metadata so the value reflects what was actually installed + by pip/uv/pipx — not a value read from pyproject.toml. This is + intentional for `specify self check`, which should reason about the + installed distribution rather than a source-tree fallback. Callers must + treat the sentinel string 'unknown' as an indeterminate value (see FR-020). + """ + + import importlib.metadata + + metadata_errors = [importlib.metadata.PackageNotFoundError] + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is not None: + metadata_errors.append(invalid_metadata_error) + + try: + return importlib.metadata.version("specify-cli") + except tuple(metadata_errors): + return "unknown" + +def _normalize_tag(tag: str) -> str: + """Strip exactly one leading 'v' from a release tag. + + Returns the rest of the string unchanged. This handles the common + 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more + aggressively (e.g., two leading 'v's keeps one). + """ + return tag[1:] if tag.startswith("v") else tag + +def _is_newer(latest: str, current: str) -> bool: + """Return True iff `latest` is strictly greater than `current` under PEP 440. + + Returns False whenever either side is 'unknown' or fails to parse; this + keeps the comparison indeterminate (rather than crashing or falsely + recommending a downgrade) on edge inputs. + """ + if latest == "unknown" or current == "unknown": + return False + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def _fetch_latest_release_tag() -> tuple[str | None, str | None]: + """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. + + On success: (tag_name, None). + On a documented network/HTTP failure (added in T029/T030): (None, category). + On anything else — including a malformed response body — the exception + propagates; there is no catch-all (research D-006). + """ + req = urllib.request.Request( + GITHUB_API_LATEST, + headers={"Accept": "application/vnd.github+json"}, + ) + token = None + for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): + candidate = os.environ.get(env_var) + if candidate is not None: + candidate = candidate.strip() + if candidate: + token = candidate + break + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise ValueError("GitHub API response missing valid tag_name") + return tag, None + except urllib.error.HTTPError as e: + # Order matters: HTTPError is a subclass of URLError. + if e.code == 403: + return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, f"HTTP {e.code}" + except (urllib.error.URLError, OSError): + return None, "offline or timeout" + + +# ===== Self Commands ===== +self_app = typer.Typer( + name="self", + help="Manage the specify CLI itself (read-only check and reserved upgrade command).", + add_completion=False, +) +app.add_typer(self_app, name="self") + +@self_app.command("check") +def self_check() -> None: + """Check whether a newer specify-cli release is available. Read-only. + + This command only checks for updates; it does not modify your installation. + The reserved (and currently non-destructive) `specify self upgrade` command + is the name that a future release will use for actual self-upgrade — its + behavior is not implemented in this release and is intentionally out of + scope here. See `specify self upgrade --help` for its current status. + """ + + installed = _get_installed_version() + tag, failure_reason = _fetch_latest_release_tag() + + if tag is None: + # Graceful-failure path (FR-008). `failure_reason` is one of the + # enumerated strings produced by _fetch_latest_release_tag() — it + # never contains a URL, headers, response body, or traceback. + assert failure_reason is not None + console.print(f"Installed: {installed}") + console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") + return + + latest_normalized = _normalize_tag(tag) + + if installed == "unknown": + # FR-020: surface the latest release and the recovery action even + # when the local distribution metadata is unavailable. + console.print("Current version could not be determined.") + console.print(f"Latest release: {latest_normalized}") + console.print("\nTo reinstall:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + if _is_newer(latest_normalized, installed): + console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") + console.print("\nTo upgrade:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + # Installed is parseable AND is >= latest → "up to date" (FR-006). + # Also reached when the tag is unparseable (InvalidVersion) → _is_newer + # returns False, and the up-to-date branch is the safer default per + # FR-004 / test T016. + console.print(f"[green]Up to date:[/green] {installed}") + + +@self_app.command("upgrade") +def self_upgrade() -> None: + """Reserved command surface for self-upgrade; not implemented in this release. + + This command is a documented non-destructive stub in this release: it + performs no outbound network request, no install-method detection, and + invokes no installer. It prints a three-line guidance message and exits 0. + Actual self-upgrade is planned as follow-up work. + + Use `specify self check` today to see whether a newer release is available + and to get a copy-pasteable reinstall command. + """ + console.print("specify self upgrade is not implemented yet.") + console.print("Run 'specify self check' to see whether a newer release is available.") + console.print("Actual self-upgrade is planned as follow-up work.") + # ===== Extension Commands ===== diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py new file mode 100644 index 0000000000..96aa627874 --- /dev/null +++ b/tests/test_upgrade.py @@ -0,0 +1,371 @@ +"""Tests for the `specify self` sub-app (`self check` and `self upgrade`). + +Network isolation contract (SC-004 / FR-014): every test that exercises +`specify self check` or `_fetch_latest_release_tag()` MUST mock +`urllib.request.urlopen` so no real outbound call ever reaches +api.github.com. The `self upgrade` stub tests do not need that patch because +the stub is contractually network-free. Run this module under `pytest-socket` +(if installed) with `--disable-socket` as an extra safety net. +""" + +import json +import urllib.error +import importlib.metadata +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import ( + _get_installed_version, + _fetch_latest_release_tag, + _is_newer, + _normalize_tag, + app, +) + +from tests.conftest import strip_ansi + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" + + +def _mock_urlopen_response(payload: dict) -> MagicMock: + body = json.dumps(payload).encode("utf-8") + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False + return cm + + +def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="https://api.github.com/repos/github/spec-kit/releases/latest", + code=code, + msg=message, + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + + +class TestSelfUpgradeStub: + """Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016).""" + + def test_prints_exactly_three_lines_and_exits_zero(self): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + lines = strip_ansi(result.output).strip().splitlines() + assert lines == [ + "specify self upgrade is not implemented yet.", + "Run 'specify self check' to see whether a newer release is available.", + "Actual self-upgrade is planned as follow-up work.", + ] + + def test_stub_makes_no_network_call(self): + # If the stub ever starts calling urllib, this patch's side_effect + # would fire and the assertion below would fail. + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=AssertionError("stub must not hit the network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + +class TestIsNewer: + def test_latest_strictly_greater_returns_true(self): + assert _is_newer("0.8.0", "0.7.4") is True + + def test_equal_versions_returns_false(self): + assert _is_newer("0.7.4", "0.7.4") is False + + def test_current_greater_than_latest_returns_false(self): + assert _is_newer("0.7.0", "0.7.4") is False + + def test_dev_build_ahead_of_release_returns_false(self): + assert _is_newer("0.7.4", "0.7.5.dev0") is False + + def test_invalid_version_returns_false(self): + assert _is_newer("not-a-version", "0.7.4") is False + + def test_local_version_containing_unknown_is_not_treated_as_sentinel(self): + assert _is_newer("1.2.4", "1.2.3+unknown") is True + + +class TestInstalledVersion: + def test_invalid_metadata_error_returns_unknown(self): + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is None: + pytest.skip("InvalidMetadataError is not available on this Python version") + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + + +class TestNormalizeTag: + def test_strips_single_leading_v(self): + assert _normalize_tag("v0.7.4") == "0.7.4" + + def test_idempotent_when_no_leading_v(self): + assert _normalize_tag("0.7.4") == "0.7.4" + + def test_strips_exactly_one_v(self): + assert _normalize_tag("vv0.7.4") == "v0.7.4" + + def test_empty_string_passthrough(self): + assert _normalize_tag("") == "" + + +class TestUserStory1: + def test_newer_available_prints_update_and_install_command(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" in output + assert "0.7.4" in output + assert "0.9.0" in output + assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output + + def test_up_to_date_prints_current_only(self): + with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Up to date: 0.9.0" in output + assert "Update available" not in output + assert "git+https://" not in output + + def test_dev_build_ahead_of_release_is_up_to_date(self): + with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + + def test_unknown_installed_still_prints_latest_and_reinstall(self): + with patch("specify_cli._get_installed_version", return_value="unknown"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Current version could not be determined" in output + assert "0.7.4" in output + assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output + + def test_unparseable_tag_routes_to_indeterminate(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + assert "0.7.4" in output + + +class TestFailureCategorization: + def test_urlerror_maps_to_offline(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=urllib.error.URLError("no route to host"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_timeout_maps_to_offline(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=TimeoutError(), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_403_maps_to_rate_limited(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=_http_error(403, "rate limited"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + + @pytest.mark.parametrize("code", [404, 500, 502]) + def test_other_http_uses_code_string(self, code): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=_http_error(code, "oops"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == f"HTTP {code}" + + def test_generic_exception_propagates(self): + # Per research D-006, no catch-all exists; RuntimeError MUST bubble. + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=RuntimeError("boom"), + ): + with pytest.raises(RuntimeError): + _fetch_latest_release_tag() + + +_FAILURE_CASES = [ + ("offline or timeout", urllib.error.URLError("down")), + ("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)), + ("HTTP 500", _http_error(500)), +] + + +class TestUserStory2: + @pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES) + def test_failure_prints_installed_plus_one_line_reason( + self, expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert "Installed: 0.7.4" in output + if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)": + assert "Could not check latest release: rate limited" in output + assert "GH_TOKEN" in output + assert "GITHUB_TOKEN" in output + else: + assert f"Could not check latest release: {expected_reason}" in output + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_exits_zero(self, _expected_reason, side_effect): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + assert result.exit_code == 0 + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_output_contains_no_traceback_no_url( + self, _expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = (result.output or "") + (result.stderr or "") + combined = strip_ansi(combined) + assert "Traceback" not in combined + assert "https://api.github.com" not in combined + + +def _capture_request_via_urlopen(): + captured = {} + + def _side_effect(req, timeout=None): + captured["request"] = req + return _mock_urlopen_response({"tag_name": "v0.7.4"}) + + return captured, _side_effect + + +class TestUserStory3: + def test_gh_token_attached_as_bearer_header(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" + + def test_github_token_used_when_gh_token_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + def test_no_authorization_header_when_both_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_empty_string_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", "") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_gh_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GH_TOKEN not in combined + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_github_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GITHUB_TOKEN not in combined From ecb3b94b430b8a44ff119a7df32c193c920e3a94 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:12:44 +0200 Subject: [PATCH 301/321] fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313) * fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi * chore: remove unused NATIVE_SKILLS_AGENTS constant --- src/specify_cli/__init__.py | 1 - src/specify_cli/agents.py | 3 +- tests/test_extensions.py | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 839562f111..9376f9d924 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -928,7 +928,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" -NATIVE_SKILLS_AGENTS = {"codex", "kimi"} SKILL_DESCRIPTIONS = { "specify": "Create or update feature specifications from natural language descriptions.", "plan": "Generate technical implementation plans from feature specifications.", diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c5e25a7085..43cbfbe08c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -282,7 +282,8 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} - if agent_name in {"codex", "kimi"}: + 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 ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e1acb8486b..fdeb5a24ee 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1369,6 +1369,79 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + @pytest.mark.parametrize("agent_name,skills_path", [ + ("codex", ".agents/skills"), + ("kimi", ".kimi/skills"), + ("claude", ".claude/skills"), + ("cursor-agent", ".cursor/skills"), + ("trae", ".trae/skills"), + ("agy", ".agents/skills"), + ]) + def test_all_skill_agents_register_commands_with_resolved_placeholders( + self, project_dir, temp_dir, agent_name, skills_path + ): + """All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered.""" + import yaml + + ext_dir = temp_dir / f"ext-{agent_name}" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": f"ext-{agent_name}", + "name": "Scripted Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.ext-{agent_name}.run", + "file": "commands/run.md", + "description": "Scripted command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Scripted command\n" + "scripts:\n" + ' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n' + "---\n\n" + "Run {SCRIPT}\n" + "Agent is __AGENT__.\n" + ) + + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}') + + skills_dir = project_dir + for part in skills_path.split("/"): + skills_dir = skills_dir / part + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir) + + skill_dir_name = f"speckit-ext-{agent_name}-run" + skill_file = skills_dir / skill_dir_name / "SKILL.md" + assert skill_file.exists(), f"SKILL.md not created for {agent_name}" + + content = skill_file.read_text() + assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}" + assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}" + assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}" + assert '.specify/scripts/bash/setup-plan.sh' in content + def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" import yaml From f612e1a30d416c0247cdbcf224098cb370e07ae7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:26:44 -0500 Subject: [PATCH 302/321] chore: release 0.7.5, begin 0.7.6.dev0 development (#2322) * chore: bump version to 0.7.5 * chore: begin 0.7.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5e361beb..bd75c336aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ +## [0.7.5] - 2026-04-22 + +### Changed + +- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313) +- feat(cli): add specify self check and self upgrade stub (#2316) +- Update version-guard to v1.1.0 (#2318) +- docs: move community presets from README to docs/community (#2314) +- catalog: add wireframe extension (v0.1.1) (#2262) +- Move community walkthroughs from README to docs/community (#2312) +- docs(readme): list red-team in community-extensions table (#2311) +- feat(catalog): add red-team extension to community catalog (#2306) +- Add superpowers-bridge community extension (#2309) +- feat: implement preset wrap strategy (#2189) +- fix(agents): block directory traversal in command write paths (#2229) (#2296) +- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299) + ## [0.7.4] - 2026-04-21 ### Changed diff --git a/pyproject.toml b/pyproject.toml index fad821feac..6b76d46f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.5.dev0" +version = "0.7.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 3970855797ceb73f3c725e9dd2c9f69ba37a2016 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:40:41 -0500 Subject: [PATCH 303/321] fix: `--force` now overwrites shared infra files during init and upgrade (#2320) * fix: --force now overwrites shared infra files during init and upgrade _install_shared_infra() previously skipped all existing files under .specify/scripts/ and .specify/templates/, regardless of --force. This meant users could never receive upstream fixes to shared scripts or templates after initial project setup. Changes: - Add force parameter to _install_shared_infra(); when True, existing files are overwritten with the latest bundled versions - Wire force=True through specify init --here --force and specify integration upgrade --force call sites - Replace hidden logging.warning with visible console output listing skipped files and suggesting --force - Fix contradictory upgrade docs that claimed --force updated shared infra (it didn't) and warned about overwrites (they didn't happen) - Add 6 tests: unit tests for skip/overwrite/warning behavior, plus end-to-end CLI tests for both --force and non-force paths Fixes #2319 * fix: improve skip warning to suggest specific commands Address review feedback: the generic '--force' suggestion was misleading when _install_shared_infra is called from integration install/switch (which don't have a --force for shared infra). Now points users to the specific commands that can refresh shared infra: 'specify init --here --force' or 'specify integration upgrade --force'. --- docs/upgrade.md | 15 ++-- src/specify_cli/__init__.py | 31 +++++--- tests/integrations/test_cli.py | 137 ++++++++++++++++++++++++++++++--- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index 020360d222..16f4b4b0c0 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -53,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template Running `specify init --here --force` will update: - ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.) -- ✅ **Script files** (`.specify/scripts/`) -- ✅ **Template files** (`.specify/templates/`) +- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added +- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added - ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below** ### What stays safe? @@ -94,7 +94,9 @@ Template files will be merged with existing content and may overwrite existing f Proceed? [y/N] ``` -With `--force`, it skips the confirmation and proceeds immediately. +With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release. + +Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated. **Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten. @@ -126,13 +128,14 @@ Or use git to restore it: git restore .specify/memory/constitution.md ``` -### 2. Custom template modifications +### 2. Custom script or template modifications -If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first: +If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first: ```bash -# Back up custom templates +# Back up custom templates and scripts cp -r .specify/templates .specify/templates-backup +cp -r .specify/scripts .specify/scripts-backup # After upgrade, merge your changes back manually ``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9376f9d924..31f1b765e1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -722,12 +722,18 @@ def _install_shared_infra( project_path: Path, script_type: str, tracker: StepTracker | None = None, + force: bool = False, ) -> bool: """Install shared infrastructure files into *project_path*. Copies ``.specify/scripts/`` and ``.specify/templates/`` from the bundled core_pack or source checkout. Tracks all installed files in ``speckit.manifest.json``. + + When *force* is ``True``, existing files are overwritten with the + latest bundled versions. When ``False`` (default), only missing + files are added and existing ones are skipped. + Returns ``True`` on success. """ from .integrations.manifest import IntegrationManifest @@ -752,12 +758,11 @@ def _install_shared_infra( if variant_src.is_dir(): dest_variant = dest_scripts / variant_dir dest_variant.mkdir(parents=True, exist_ok=True) - # Merge without overwriting — only add files that don't exist yet for src_path in variant_src.rglob("*"): if src_path.is_file(): rel_path = src_path.relative_to(variant_src) dst_path = dest_variant / rel_path - if dst_path.exists(): + if dst_path.exists() and not force: skipped_files.append(str(dst_path.relative_to(project_path))) else: dst_path.parent.mkdir(parents=True, exist_ok=True) @@ -778,7 +783,7 @@ def _install_shared_infra( for f in templates_src.iterdir(): if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): dst = dest_templates / f.name - if dst.exists(): + if dst.exists() and not force: skipped_files.append(str(dst.relative_to(project_path))) else: shutil.copy2(f, dst) @@ -786,10 +791,15 @@ def _install_shared_infra( manifest.record_existing(rel) if skipped_files: - import logging - logging.getLogger(__name__).warning( - "The following shared files already exist and were not overwritten:\n%s", - "\n".join(f" {f}" for f in skipped_files), + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" + ) + for f in skipped_files: + console.print(f" {f}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." ) manifest.save() @@ -1279,7 +1289,7 @@ def init( # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker) + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -2446,9 +2456,8 @@ def integration_upgrade( selected_script = _resolve_script_type(project_root, script) - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + # Ensure shared infrastructure is up to date; --force overwrites existing files. + _install_shared_infra(project_root, selected_script, force=force) if os.name != "nt": ensure_executable_scripts(project_root) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ff9386d626..9672ab76bf 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -173,13 +173,43 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): assert "speckit-specify" in command_file.read_text(encoding="utf-8") assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - def test_shared_infra_skips_existing_files(self, tmp_path): - """Pre-existing shared files are not overwritten by _install_shared_infra.""" - from typer.testing import CliRunner - from specify_cli import app + def test_shared_infra_skips_existing_files_without_force(self, tmp_path): + """Pre-existing shared files are not overwritten without --force.""" + from specify_cli import _install_shared_infra project = tmp_path / "skip-test" project.mkdir() + (project / ".specify").mkdir() + + # Pre-create a shared script with custom content + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + # Pre-create a shared template with custom content + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + custom_template = "# user-modified spec-template\n" + (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + # User's files should be preserved (not overwritten) + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + + # Other shared files should still be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): + """Pre-existing shared files ARE overwritten when force=True.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "force-test" + project.mkdir() + (project / ".specify").mkdir() # Pre-create a shared script with custom content scripts_dir = project / ".specify" / "scripts" / "bash" @@ -193,6 +223,67 @@ def test_shared_infra_skips_existing_files(self, tmp_path): custom_template = "# user-modified spec-template\n" (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + _install_shared_infra(project, "sh", force=True) + + # Files should be overwritten with bundled versions + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template + + # Other shared files should also be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys): + """Console warning is displayed when files are skipped.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + captured = capsys.readouterr() + assert "already exist and were not updated" in captured.out + assert "specify init --here --force" in captured.out + # Rich may wrap long lines; normalize whitespace for the second command + normalized = " ".join(captured.out.split()) + assert "specify integration upgrade --force" in normalized + + def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys): + """No skip warning when force=True (all files overwritten).""" + from specify_cli import _install_shared_infra + + project = tmp_path / "no-warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=True) + + captured = capsys.readouterr() + assert "already exist and were not updated" not in captured.out + + def test_init_here_force_overwrites_shared_infra(self, tmp_path): + """E2E: specify init --here --force overwrites shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "e2e-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + old_cwd = os.getcwd() try: os.chdir(project) @@ -207,14 +298,40 @@ def test_shared_infra_skips_existing_files(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 + # --force should overwrite the custom file + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content - # User's files should be preserved - assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content - assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + def test_init_here_without_force_preserves_shared_infra(self, tmp_path): + """E2E: specify init --here (no --force) preserves existing shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app - # Other shared files should still be installed - assert (scripts_dir / "setup-plan.sh").exists() - assert (templates_dir / "plan-template.md").exists() + project = tmp_path / "e2e-no-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + ], input="y\n", catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Without --force, custom file should be preserved + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + # Warning about skipped files should appear + assert "not updated" in result.output class TestForceExistingDirectory: From 9e259e1f8d15d94cb78c6d5fa47af9bdd6c5e6ce Mon Sep 17 00:00:00 2001 From: Kevin Brown <51965072+KevinBrown5280@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:44:06 -0400 Subject: [PATCH 304/321] Update version-guard to v1.2.0 (#2321) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index aa59d27bfa..3a1fe05bfc 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T17:54:00Z", + "updated_at": "2026-04-22T21:10:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2351,8 +2351,8 @@ "id": "version-guard", "description": "Verify tech stack versions against live registries before planning and implementation", "author": "KevinBrown5280", - "version": "1.1.0", - "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip", + "version": "1.2.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip", "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", @@ -2375,7 +2375,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-20T00:00:00Z", - "updated_at": "2026-04-22T17:54:00Z" + "updated_at": "2026-04-22T21:10:00Z" }, "whatif": { "name": "What-if Analysis", @@ -2505,4 +2505,4 @@ "updated_at": "2026-04-13T00:00:00Z" } } -} \ No newline at end of file +} From 709457cec20c724ad5432626a1f0c21e8123cebe Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 23 Apr 2026 19:50:26 +0700 Subject: [PATCH 305/321] Add Memory MD community extension (#2327) --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 468608a6e4..b652aa3611 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | +| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3a1fe05bfc..ecfcbef2c7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T21:10:00Z", + "updated_at": "2026-04-23T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1198,6 +1198,38 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-20T00:00:00Z" }, + "memory-md": { + "name": "Memory MD", + "id": "memory-md", + "description": "Repository-native durable memory for Spec Kit projects", + "author": "DyanGalih", + "version": "0.6.2", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip", + "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", + "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", + "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 5, + "hooks": 0 + }, + "tags": [ + "memory", + "workflow", + "docs", + "copilot", + "markdown" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-04-23T00:00:00Z" + }, "memorylint": { "name": "MemoryLint", "id": "memorylint", From b278d66b2c9b7a695186be0aa52ceb5feac65120 Mon Sep 17 00:00:00 2001 From: sha0dow <85430783+D7x7z49@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:12:17 +0800 Subject: [PATCH 306/321] docs(install): add pipx as alternative installation method (#2288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(install): add pipx as alternative installation method - Add pipx commands to README.md installation section - Add note about pipx compatibility to docs/installation.md - Mention pipx persistent installation in docs/quickstart.md - Add pipx upgrade instructions to docs/upgrade.md - Clarify that project has no uv-specific dependencies Refs: https://github.com/github/spec-kit/discussions/2255 * docs(install): address Copilot feedback - update prerequisites and upgrade references for pipx * Update docs/quickstart.md markdownlint’s MD012 (enabled in this repo) flags multiple consecutive blank lines Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/upgrade.md In the Quick Reference table, the label “pipx upgrade” is misleading because the command shown is `pipx install --force ...` (a reinstall). by copilot. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 7 ++++++- docs/installation.md | 9 ++++++++- docs/quickstart.md | 11 +++++++++++ docs/upgrade.md | 9 +++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b652aa3611..88332922d5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX # Or install latest from main (may include unreleased changes) uv tool install specify-cli --from git+https://github.com/github/spec-kit.git + +# Alternative: using pipx (also works) +pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +pipx install git+https://github.com/github/spec-kit.git ``` Then verify the correct version is installed: @@ -89,6 +93,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst ```bash uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z +# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z ``` #### Option 2: One-time Usage @@ -426,7 +431,7 @@ Our research and experimentation focus on: - **Linux/macOS/Windows** - [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. -- [uv](https://docs.astral.sh/uv/) for package management +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) diff --git a/docs/installation.md b/docs/installation.md index ed253902af..c99810f706 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,7 +4,7 @@ - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) -- [uv](https://docs.astral.sh/uv/) for package management +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) @@ -24,6 +24,13 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init ``` +> [!NOTE] +> For a persistent installation, `pipx` works equally well: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +> ``` +> The project uses a standard `hatchling` build backend and has no uv-specific dependencies. + Or initialize in the current directory: ```bash diff --git a/docs/quickstart.md b/docs/quickstart.md index 4b2c3c8807..5c0f009306 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -22,6 +22,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init [!NOTE] +> You can also install the CLI persistently with `pipx`: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git +> ``` +> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example: +> ```bash +> specify init +> specify init . +> ``` + Pick script type explicitly (optional): ```bash diff --git a/docs/upgrade.md b/docs/upgrade.md index 16f4b4b0c0..934be675e2 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -9,6 +9,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| | **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | +| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release | | **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | @@ -34,6 +35,14 @@ Specify the desired release tag: uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` +### If you installed with `pipx` + +Upgrade to a specific release: + +```bash +pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z +``` + ### Verify the upgrade ```bash From 8fefd2a532d15a94fa7852791c2ef6071ed61e05 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:26:59 -0500 Subject: [PATCH 307/321] feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324) * Initial plan * feat(copilot): add --skills flag for skills-based scaffolding Add --skills integration option to CopilotIntegration that scaffolds commands as speckit-/SKILL.md under .github/skills/ instead of the default .agent.md + .prompt.md layout. - Add options() with --skills flag (default=False) - Branch setup() between default and skills modes - Add post_process_skill_content() for Copilot-specific mode: field - Adjust build_command_invocation() for skills mode (/speckit-) - Update dispatch_command() with skills mode detection - Parse --integration-options during init command - Add 22 new skills-mode tests - All 15 existing default-mode tests continue to pass Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs(AGENTS.md): document Copilot --skills option Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address PR #2324 review feedback - Reset _skills_mode at start of setup() to prevent singleton state leak - Tighten skills auto-detection to require speckit-*/SKILL.md (not any non-empty .github/skills/ directory) - Add copilot_skill_mode to init next-steps so skills mode renders /speckit-plan instead of /speckit.plan - Fix docstring quoting to match actual unquoted output - Add 4 tests covering singleton reset, auto-detection false positive, speckit layout detection, and next-steps skill syntax - Fix skipped test_invalid_metadata_error_returns_unknown by simulating InvalidMetadataError on Python versions that lack it * fix: inline skills prompt in dispatch_command auto-detection path build_command_invocation() reads self._skills_mode which stays False when skills mode is only auto-detected from the project layout. Inline the /speckit- prompt construction so dispatch_command() sends the correct prompt regardless of how skills mode was detected. Also strengthen test_dispatch_detects_speckit_skills_layout to assert the -p prompt contains /speckit-plan and the user args. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- AGENTS.md | 24 +- src/specify_cli/__init__.py | 19 +- .../integrations/copilot/__init__.py | 196 +++++++- .../integrations/test_integration_copilot.py | 419 ++++++++++++++++++ tests/test_upgrade.py | 25 +- 5 files changed, 658 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b076dc384..7adfd1d12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de | Override | When to use | Example | |---|---|---| | `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | -| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag | -| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` | +| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag | +| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-/SKILL.md` (skills mode) | | `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | **Example — Copilot (fully custom `setup`):** -Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. +Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. ### 7. Update Devcontainer files (Optional) @@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that: 2. Generates companion `.prompt.md` files 3. Merges VS Code settings +**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout +via `--integration-options="--skills"`. When enabled: +- Commands are scaffolded as `speckit-/SKILL.md` under `.github/skills/` +- No companion `.prompt.md` files are generated +- No `.vscode/settings.json` merge +- `post_process_skill_content()` injects a `mode: speckit.` frontmatter field +- `build_command_invocation()` returns `/speckit-` instead of bare args + +The two modes are mutually exclusive — a project uses one or the other: + +```bash +# Default mode: .agent.md agents + .prompt.md companions + settings merge +specify init my-project --integration copilot + +# Skills mode: speckit-/SKILL.md under .github/skills/ +specify init my-project --integration copilot --integration-options="--skills" +``` + ### Forge Integration Forge has special frontmatter and argument requirements: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 31f1b765e1..743ceb9954 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1268,6 +1268,12 @@ def init( integration_parsed_options["commands_dir"] = ai_commands_dir if ai_skills: integration_parsed_options["skills"] = True + # Parse --integration-options and merge into parsed_options so + # flags like --skills reach the integration's setup(). + if integration_options: + extra = _parse_integration_options(resolved_integration, integration_options) + if extra: + integration_parsed_options.update(extra) resolved_integration.setup( project_path, manifest, @@ -1393,8 +1399,10 @@ def init( } # Ensure ai_skills is set for SkillsIntegration so downstream # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills). from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -1506,7 +1514,7 @@ def init( # Determine skill display mode for the next-steps panel. # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) @@ -1514,7 +1522,8 @@ def init( agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1535,7 +1544,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" @@ -2166,7 +2175,7 @@ def _update_init_options_for_integration( opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type - if isinstance(integration, SkillsIntegration): + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 45ec6f3532..5c4d0e5410 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,6 +5,10 @@ - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations - Context file lives at ``.github/copilot-instructions.md`` + +When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds +commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` +instead. The two modes are mutually exclusive. """ from __future__ import annotations @@ -16,7 +20,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationBase +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -44,12 +48,40 @@ def _allow_all() -> bool: return True +class _CopilotSkillsHelper(SkillsIntegration): + """Internal helper used when Copilot is scaffolded in skills mode. + + Not registered in the integration registry — only used as a delegate + by ``CopilotIntegration`` when ``--skills`` is passed. + """ + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "skills", + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", + "requires_cli": False, + } + registrar_config = { + "dir": ".github/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".github/copilot-instructions.md" + + class CopilotIntegration(IntegrationBase): """Integration for GitHub Copilot (VS Code IDE + CLI). The IDE integration (``requires_cli: False``) installs ``.agent.md`` command files. Workflow dispatch additionally requires the ``copilot`` CLI to be installed separately. + + When ``--skills`` is passed via ``--integration-options``, commands + are scaffolded as ``speckit-/SKILL.md`` under ``.github/skills/`` + instead of the default ``.agent.md`` + ``.prompt.md`` layout. """ key = "copilot" @@ -68,6 +100,20 @@ class CopilotIntegration(IntegrationBase): } context_file = ".github/copilot-instructions.md" + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", + ), + ] + def build_exec_args( self, prompt: str, @@ -92,7 +138,19 @@ def build_exec_args( return args def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Copilot agents are not slash-commands — just return the args as prompt.""" + """Build the native invocation for a Copilot command. + + Default mode: agents are not slash-commands — return args as prompt. + Skills mode: ``/speckit-`` slash-command dispatch. + """ + if self._skills_mode: + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + invocation = f"/speckit-{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation return args or "" def dispatch_command( @@ -110,19 +168,37 @@ def dispatch_command( Copilot ``.agent.md`` files are agents, not skills. The CLI selects them with ``--agent `` and the prompt is just the user's arguments. + + In skills mode, the prompt includes the skill invocation + (``/speckit-``). """ import subprocess stem = command_name if "." in stem: stem = stem.rsplit(".", 1)[-1] - agent_name = f"speckit.{stem}" - prompt = args or "" - cli_args = [ - "copilot", "-p", prompt, - "--agent", agent_name, - ] + # Detect skills mode from project layout when not set via setup() + skills_mode = self._skills_mode + if not skills_mode and project_root: + skills_dir = project_root / ".github" / "skills" + if skills_dir.is_dir(): + skills_mode = any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + + if skills_mode: + prompt = f"/speckit-{stem}" + if args: + prompt = f"{prompt} {args}" + else: + agent_name = f"speckit.{stem}" + prompt = args or "" + + cli_args = ["copilot", "-p", prompt] + if not skills_mode: + cli_args.extend(["--agent", agent_name]) if _allow_all(): cli_args.append("--yolo") if model: @@ -168,6 +244,59 @@ def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" return f"speckit.{template_name}.agent.md" + def post_process_skill_content(self, content: str) -> str: + """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. + + Inserts ``mode: speckit.`` before the closing ``---`` so + Copilot can associate the skill with its agent mode. + """ + lines = content.splitlines(keepends=True) + + # Extract skill name from frontmatter to derive the mode value + dash_count = 0 + skill_name = "" + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1: + if stripped.startswith("mode:"): + return content # already present + if stripped.startswith("name:"): + # Parse: name: "speckit-plan" → speckit.plan + val = stripped.split(":", 1)[1].strip().strip('"').strip("'") + # Convert speckit-plan → speckit.plan + if val.startswith("speckit-"): + skill_name = "speckit." + val[len("speckit-"):] + else: + skill_name = val + + if not skill_name: + return content + + # Inject mode: before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"mode: {skill_name}{eol}") + injected = True + out.append(line) + return "".join(out) + def setup( self, project_root: Path, @@ -177,10 +306,24 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - Uses base class primitives to: read templates, process them - (replace placeholders, strip script blocks, rewrite paths), - write as ``.agent.md``, then add companion prompts and VS Code settings. + When ``parsed_options["skills"]`` is truthy, delegates to skills + scaffolding (``speckit-/SKILL.md`` under ``.github/skills/``). + Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. """ + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + return self._setup_skills(project_root, manifest, parsed_options, **opts) + return self._setup_default(project_root, manifest, parsed_options, **opts) + + def _setup_default( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Default mode: .agent.md + .prompt.md + VS Code settings merge.""" project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( @@ -252,6 +395,37 @@ def setup( return created + def _setup_skills( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process.""" + helper = _CopilotSkillsHelper() + created = SkillsIntegration.setup( + helper, project_root, manifest, parsed_options, **opts + ) + + # Post-process generated skill files with Copilot-specific frontmatter + skills_dir = helper.skills_dest(project_root).resolve() + for path in created: + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content = path.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created + def _vscode_settings_path(self) -> Path | None: """Return path to the bundled vscode-settings.json template.""" tpl_dir = self.shared_templates_dir() diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 642b1e5300..462f6d120a 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -3,6 +3,8 @@ import json import os +import yaml + from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest @@ -275,3 +277,420 @@ def test_complete_file_inventory_ps(self, tmp_path): f"Missing: {sorted(set(expected) - set(actual))}\n" f"Extra: {sorted(set(actual) - set(expected))}" ) + + +class TestCopilotSkillsMode: + """Tests for Copilot integration in --skills mode.""" + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _make_copilot(self): + from specify_cli.integrations.copilot import CopilotIntegration + return CopilotIntegration() + + def _setup_skills(self, copilot, tmp_path): + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m, parsed_options={"skills": True}) + return created, m + + # -- Options ---------------------------------------------------------- + + def test_options_include_skills_flag(self): + copilot = get_integration("copilot") + opts = copilot.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + assert skills_opts[0].default is False + + # -- Skills directory structure --------------------------------------- + + def test_skills_creates_skill_files(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + assert len(created) > 0 + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + assert f.exists() + assert f.parent.name.startswith("speckit-") + + def test_skills_directory_under_github_skills(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skills_dir = tmp_path / ".github" / "skills" + assert skills_dir.is_dir() + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + assert f.resolve().parent.parent == skills_dir.resolve(), ( + f"{f} is not under {skills_dir}" + ) + + def test_skills_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + expected_commands = set(self._SKILL_COMMANDS) + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + assert actual_commands == expected_commands + + # -- No companion files in skills mode -------------------------------- + + def test_skills_no_prompt_md_companions(self, tmp_path): + """Skills mode must not generate .prompt.md companion files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + prompt_files = [f for f in created if f.name.endswith(".prompt.md")] + assert prompt_files == [] + prompts_dir = tmp_path / ".github" / "prompts" + if prompts_dir.exists(): + assert list(prompts_dir.iterdir()) == [] + + def test_skills_no_vscode_settings(self, tmp_path): + """Skills mode must not create or merge .vscode/settings.json.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + settings = tmp_path / ".vscode" / "settings.json" + assert not settings.exists() + + def test_skills_no_agent_md_files(self, tmp_path): + """Skills mode must not produce .agent.md files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + agent_files = [f for f in created if f.name.endswith(".agent.md")] + assert agent_files == [] + + # -- Frontmatter structure -------------------------------------------- + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + + # -- Copilot-specific post-processing --------------------------------- + + def test_post_process_skill_content_injects_mode(self): + """post_process_skill_content() should inject mode: field.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + updated = copilot.post_process_skill_content(content) + assert "mode: speckit.plan" in updated + + def test_post_process_idempotent(self): + """post_process_skill_content() must be idempotent.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + first = copilot.post_process_skill_content(content) + second = copilot.post_process_skill_content(first) + assert first == second + + def test_skills_have_mode_in_frontmatter(self, tmp_path): + """Generated SKILL.md files should have mode: field from post-processing.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "mode" in fm, f"{f} frontmatter missing 'mode'" + # mode should be speckit. + skill_dir_name = f.parent.name + stem = skill_dir_name.removeprefix("speckit-") + assert fm["mode"] == f"speckit.{stem}" + + # -- Template processing ---------------------------------------------- + + def test_skills_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference copilot's context file.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content + assert "__CONTEXT_FILE__" not in content + + # -- Manifest tracking ------------------------------------------------ + + def test_all_files_tracked_in_manifest(self, tmp_path): + copilot = self._make_copilot() + created, m = self._setup_skills(copilot, tmp_path) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + # -- Install/uninstall roundtrip -------------------------------------- + + def test_install_uninstall_roundtrip(self, tmp_path): + copilot = self._make_copilot() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m, parsed_options={"skills": True}) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = copilot.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + copilot = self._make_copilot() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m, parsed_options={"skills": True}) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = copilot.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- build_command_invocation ----------------------------------------- + + def test_build_command_invocation_skills_mode(self): + copilot = self._make_copilot() + copilot._skills_mode = True + assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args" + + def test_build_command_invocation_default_mode(self): + copilot = self._make_copilot() + assert copilot.build_command_invocation("plan", "my args") == "my args" + assert copilot.build_command_invocation("plan") == "" + + # -- Context section --------------------------------------------------- + + def test_skills_setup_upserts_context_section(self, tmp_path): + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + ctx_path = tmp_path / copilot.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + # -- CLI integration test --------------------------------------------- + + def test_init_with_integration_options_skills(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' scaffolds skills.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + skills_dir = project / ".github" / "skills" + assert skills_dir.is_dir(), "Skills directory was not created" + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists(), "speckit-plan/SKILL.md not found" + # Verify no default-mode artifacts + assert not (project / ".github" / "agents").exists() + assert not (project / ".github" / "prompts").exists() + assert not (project / ".vscode" / "settings.json").exists() + + def test_complete_file_inventory_skills_sh(self, tmp_path): + """Every file produced by specify init --integration copilot --integration-options='--skills' --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-skills-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + # Skill files + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], + # Context file + ".github/copilot-instructions.md", + # Integration metadata + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + # Scripts (sh) + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + # Templates + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + # Bundled workflow + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + # -- Singleton leak: _skills_mode must reset -------------------------- + + def test_skills_mode_resets_on_default_setup(self, tmp_path): + """setup() with skills=True then without must reset _skills_mode.""" + copilot = self._make_copilot() + + # First call: skills mode + (tmp_path / "proj1").mkdir() + m1 = IntegrationManifest("copilot", tmp_path / "proj1") + copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) + assert copilot._skills_mode is True + + # Second call: default mode (no skills option) + (tmp_path / "proj2").mkdir() + m2 = IntegrationManifest("copilot", tmp_path / "proj2") + copilot.setup(tmp_path / "proj2", m2) + assert copilot._skills_mode is False + + # build_command_invocation must use default (dotted) mode + assert copilot.build_command_invocation("plan", "args") == "args" + + # -- Auto-detection must ignore unrelated .github/skills/ ------------- + + def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path): + """dispatch_command() must not treat unrelated .github/skills/ as skills mode.""" + copilot = self._make_copilot() + # Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training) + unrelated = tmp_path / ".github" / "skills" / "introduction-to-github" + unrelated.mkdir(parents=True) + (unrelated / "README.md").write_text("# GitHub Skills training\n") + + # Should NOT detect skills mode — cli_args should contain --agent + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" in call_args, ( + f"Expected --agent in cli_args but got: {call_args}" + ) + assert "speckit.plan" in call_args + + def test_dispatch_detects_speckit_skills_layout(self, tmp_path): + """dispatch_command() detects speckit-*/SKILL.md as skills mode.""" + copilot = self._make_copilot() + skill_dir = tmp_path / ".github" / "skills" / "speckit-plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n") + + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" not in call_args, ( + f"Skills mode should not use --agent, got: {call_args}" + ) + prompt = call_args[call_args.index("-p") + 1] + assert "/speckit-plan" in prompt, ( + f"Skills mode prompt should invoke /speckit-plan, got: {prompt}" + ) + assert "my args" in prompt, ( + f"Skills mode prompt should preserve user args, got: {prompt}" + ) + + # -- Next-steps display for Copilot skills mode ----------------------- + + def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-nextsteps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + # Skills mode should show /speckit-plan (hyphenated) + assert "/speckit-plan" in result.output, ( + f"Expected /speckit-plan in next steps but got:\n{result.output}" + ) + # Must NOT show the dotted /speckit.plan form + assert "/speckit.plan" not in result.output, ( + f"Should not show /speckit.plan in skills mode:\n{result.output}" + ) \ No newline at end of file diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 96aa627874..28a0ce6414 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -100,12 +100,25 @@ class TestInstalledVersion: def test_invalid_metadata_error_returns_unknown(self): invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) if invalid_metadata_error is None: - pytest.skip("InvalidMetadataError is not available on this Python version") - with patch( - "importlib.metadata.version", - side_effect=invalid_metadata_error("bad metadata"), - ): - assert _get_installed_version() == "unknown" + # Python versions without InvalidMetadataError: simulate with a + # custom exception to verify the guarded except path works. + class _FakeInvalidMetadataError(Exception): + pass + invalid_metadata_error = _FakeInvalidMetadataError + # Patch the attribute onto importlib.metadata so the production + # getattr() finds it during this test. + with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True): + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + else: + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" class TestNormalizeTag: From a067d4c2e3a56f68185769e43a44f53b1282562d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:07:52 -0500 Subject: [PATCH 308/321] 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. to .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> --- presets/ARCHITECTURE.md | 18 + presets/README.md | 44 +- presets/scaffold/preset.yml | 29 + scripts/bash/common.sh | 227 +++++- scripts/powershell/common.ps1 | 219 ++++++ src/specify_cli/__init__.py | 58 +- src/specify_cli/agents.py | 45 +- src/specify_cli/presets.py | 1284 ++++++++++++++++++++++++++------- tests/test_presets.py | 1231 ++++++++++++++++--------------- 9 files changed, 2299 insertions(+), 856 deletions(-) diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index d0e6547816..3a119cbd5f 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency: - **Bash**: `resolve_template()` in `scripts/bash/common.sh` - **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` +### Composition Strategies + +Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it: + +| Strategy | Description | Templates | Commands | Scripts | +|----------|-------------|-----------|----------|---------| +| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ | +| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — | +| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — | +| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ | + +Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy. + +Content resolution functions for composition: +- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts) +- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver) +- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver) + ## Command Registration When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. diff --git a/presets/README.md b/presets/README.md index 72751b4bfb..7d7b9ae8a2 100644 --- a/presets/README.md +++ b/presets/README.md @@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa specify preset add pm-workflow --priority 1 # overrides everything ``` -Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely. +Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content. + +### Composition Strategies + +Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/.md`): + +```yaml +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-addendum.md" + strategy: "append" # adds content after the core template +``` + +| Strategy | Description | +|----------|-------------| +| `replace` (default) | Fully replaces the lower-priority template | +| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line | +| `append` | Places content **after** the resolved lower-priority template, separated by a blank line | +| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content | + +**Supported combinations:** + +| Type | `replace` | `prepend` | `append` | `wrap` | +|------|-----------|-----------|----------|--------| +| **template** | ✓ (default) | ✓ | ✓ | ✓ | +| **command** | ✓ (default) | ✓ | ✓ | ✓ | +| **script** | ✓ (default) | — | — | ✓ | + +Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer. ## Catalog Management @@ -108,13 +138,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset The following enhancements are under consideration for future releases: -- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`: - - | Type | `replace` | `prepend` | `append` | `wrap` | - |------|-----------|-----------|----------|--------| - | **template** | ✓ (default) | ✓ | ✓ | ✓ | - | **command** | ✓ (default) | ✓ | ✓ | ✓ | - | **script** | ✓ (default) | — | — | ✓ | - - For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented). -- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. +- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security"). +- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml index 975a92a413..65111ba9f3 100644 --- a/presets/scaffold/preset.yml +++ b/presets/scaffold/preset.yml @@ -32,6 +32,15 @@ provides: templates: # CUSTOMIZE: Define your template overrides # Templates are document scaffolds (spec-template.md, plan-template.md, etc.) + # + # Strategy options (optional, defaults to "replace"): + # replace - Fully replaces the lower-priority template (default) + # prepend - Places this content BEFORE the lower-priority template + # append - Places this content AFTER the lower-priority template + # wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or + # $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content + # + # Note: Scripts only support "replace" and "wrap" strategies. - type: "template" name: "spec-template" file: "templates/spec-template.md" @@ -45,6 +54,26 @@ provides: # description: "Custom plan template" # replaces: "plan-template" + # COMPOSITION EXAMPLES: + # The `file` field points to the content file (can differ from the + # convention path `templates/.md`). The `name` field identifies + # which template to compose with in the priority stack. + # + # Append additional sections to an existing template: + # - type: "template" + # name: "spec-template" + # file: "templates/spec-addendum.md" + # description: "Add compliance section to spec template" + # strategy: "append" + # + # Wrap a command with preamble/sign-off: + # - type: "command" + # name: "speckit.specify" + # file: "commands/specify-wrapper.md" + # description: "Wrap specify command with compliance checks" + # strategy: "wrap" + # # In the wrapper file, use {CORE_TEMPLATE} where the original content goes + # OVERRIDE EXTENSION TEMPLATES: # Presets sit above extensions in the resolution stack, so you can # override templates provided by any installed extension. diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index b41d17dec3..cad10bdb39 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -320,8 +320,9 @@ try: with open(os.environ['SPECKIT_REGISTRY']) as f: data = json.load(f) presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): - print(pid) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) except Exception: sys.exit(1) " 2>/dev/null); then @@ -373,3 +374,225 @@ except Exception: return 1 } +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} + diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 0d6544aaf4..d799e4f7e7 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -287,6 +287,21 @@ function Test-DirHasFiles { } } +# Find a usable Python 3 executable (python3, python, or py -3). +# Returns the command/arguments as an array, or $null if none found. +function Get-Python3Command { + if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { + $ver = & python --version 2>&1 + if ($ver -match 'Python 3') { return @('python') } + } + if (Get-Command py -ErrorAction SilentlyContinue) { + $ver = & py -3 --version 2>&1 + if ($ver -match 'Python 3') { return @('py', '-3') } + } + return $null +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) @@ -315,6 +330,7 @@ function Resolve-Template { $presets = $registryData.presets if ($presets) { $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | ForEach-Object { $_.Name } } @@ -354,3 +370,206 @@ function Resolve-Template { return $null } +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +function Resolve-TemplateContent { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Collect all layers (highest priority first) + $layerPaths = @() + $layerStrategies = @() + + # Priority 1: Project overrides (always "replace") + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { + $layerPaths += $override + $layerStrategies += 'replace' + } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + $pyCmd = Get-Python3Command + if (-not $pyCmd) { + # Check if any preset has strategy fields that would be ignored + foreach ($pid in $sortedPresets) { + $mf = Join-Path $presetsDir "$pid/preset.yml" + if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) { + Write-Warning "No Python 3 found; preset composition strategies will be ignored" + break + } + } + } + $yamlWarned = $false + foreach ($presetId in $sortedPresets) { + # Read strategy and file path from preset manifest + $strategy = 'replace' + $manifestFilePath = '' + $manifest = Join-Path $presetsDir "$presetId/preset.yml" + if ((Test-Path $manifest) -and $pyCmd) { + try { + # Use Python to parse YAML manifest for strategy and file path + $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + $pyStderrFile = [System.IO.Path]::GetTempFileName() + $stratResult = & $pyCmd[0] @pyArgs -c @" +import sys +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(sys.argv[1]) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +"@ $manifest $TemplateName 2>$pyStderrFile + if ($stratResult) { + $parts = $stratResult.Trim() -split "`t", 2 + $strategy = $parts[0].ToLowerInvariant() + if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] } + } + if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') { + Write-Warning "PyYAML not available; composition strategies may be ignored" + $yamlWarned = $true + } + Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue + } catch { + $strategy = 'replace' + if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + } + } + # Try manifest file path first, then convention path + $candidate = $null + if ($manifestFilePath) { + # Reject absolute paths and parent traversal + if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') { + $manifestFilePath = '' + } + } + if ($manifestFilePath) { + $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" + if (Test-Path $mf) { $candidate = $mf } + } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } + if ($candidate) { + $layerPaths += $candidate + $layerStrategies += $strategy + } + } + } else { + # Fallback: alphabetical directory order (no registry or parse failure) + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + } + + # Priority 3: Extension-provided templates (always "replace") + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + + # Priority 4: Core templates (always "replace") + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { + $layerPaths += $core + $layerStrategies += 'replace' + } + + if ($layerPaths.Count -eq 0) { return $null } + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if ($layerStrategies[0] -eq 'replace') { + return (Get-Content $layerPaths[0] -Raw) + } + + # Check if any layer uses a non-replace strategy + $hasComposition = $false + foreach ($s in $layerStrategies) { + if ($s -ne 'replace') { $hasComposition = $true; break } + } + + if (-not $hasComposition) { + return (Get-Content $layerPaths[0] -Raw) + } + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + $baseIdx = -1 + for ($i = 0; $i -lt $layerPaths.Count; $i++) { + if ($layerStrategies[$i] -eq 'replace') { + $baseIdx = $i + break + } + } + if ($baseIdx -lt 0) { return $null } + + $content = Get-Content $layerPaths[$baseIdx] -Raw + + for ($i = $baseIdx - 1; $i -ge 0; $i--) { + $path = $layerPaths[$i] + $strat = $layerStrategies[$i] + $layerContent = Get-Content $path -Raw + + switch ($strat) { + 'replace' { $content = $layerContent } + 'prepend' { $content = "$layerContent`n`n$content" } + 'append' { $content = "$content`n`n$layerContent" } + 'wrap' { + if (-not $layerContent.Contains('{CORE_TEMPLATE}')) { + throw "Wrap strategy missing {CORE_TEMPLATE} placeholder" + } + $content = $layerContent.Replace('{CORE_TEMPLATE}', $content) + } + default { throw "Unknown strategy: $strat" } + } + } + + return $content +} \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 743ceb9954..77611128b5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2762,14 +2762,58 @@ def preset_resolve( raise typer.Exit(1) resolver = PresetResolver(project_root) - result = resolver.resolve_with_source(template_name) - - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") else: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") @preset_app.command("info") diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 43cbfbe08c..726b0fd2a6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -8,7 +8,7 @@ import os from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re @@ -652,6 +652,49 @@ def register_commands_for_all_agents( 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: diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 5f28be7204..ed33f992c3 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -109,6 +109,9 @@ class PresetCompatibilityError(PresetError): VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} +VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} +# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) +VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} class PresetManifest: @@ -207,6 +210,28 @@ def _validate(self): "must be a relative path within the preset directory" ) + # Validate strategy field (optional, defaults to "replace") + strategy = tmpl.get("strategy", "replace") + if not isinstance(strategy, str): + raise PresetValidationError( + f"Invalid strategy value: must be a string, " + f"got {type(strategy).__name__}" + ) + strategy = strategy.lower() + # Persist normalized value so downstream code sees lowercase + if "strategy" in tmpl: + tmpl["strategy"] = strategy + if strategy not in VALID_PRESET_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}': " + f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" + ) + if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}' for script: " + f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" + ) + # Validate template name format if tmpl["type"] == "command": # Commands use dot notation (e.g. speckit.specify) @@ -558,6 +583,10 @@ def _register_commands( file, and writes it to every detected agent directory using the CommandRegistrar from the agents module. + When a command uses a composition strategy (prepend, append, wrap), + the content is composed with the lower-priority command before + registration. + Args: manifest: Preset manifest preset_dir: Installed preset directory @@ -587,6 +616,50 @@ def _register_commands( if not filtered: return {} + # Handle composition strategies: resolve composed content for non-replace commands + resolver = PresetResolver(self.project_root) + composed_dir = None + commands_to_register = [] + for cmd in filtered: + strategy = cmd.get("strategy", "replace") + if strategy != "replace": + # Only pre-compose if this preset is the top composing layer. + # If a higher-priority replace already wins, skip composition + # here — reconciliation will write the correct content. + layers = resolver.collect_all_layers(cmd["name"], "command") + top_layer_is_ours = ( + layers and layers[0]["path"].is_relative_to(preset_dir) + ) + if top_layer_is_ours: + composed = resolver.resolve_content(cmd["name"], "command") + if composed is not None: + if composed_dir is None: + composed_dir = preset_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd['name']}.md" + composed_file.write_text(composed, encoding="utf-8") + commands_to_register.append({ + **cmd, + "file": f".composed/{cmd['name']}.md", + }) + else: + raise PresetValidationError( + f"Command '{cmd['name']}' uses '{strategy}' strategy " + f"but no base command layer exists to compose onto. " + f"Ensure a lower-priority preset, extension, or core " + f"command provides this command before using " + f"composition strategies." + ) + else: + # Not the top layer — register raw file; reconciliation + # will overwrite with the correct composed/winning content. + # Note: CommandRegistrar may process frontmatter strategy: wrap + # from the raw file (legacy compat), but reconciliation runs + # immediately after install and corrects the final output. + commands_to_register.append(cmd) + else: + commands_to_register.append(cmd) + try: from .agents import CommandRegistrar except ImportError: @@ -594,7 +667,7 @@ def _register_commands( registrar = CommandRegistrar() return registrar.register_commands_for_all_agents( - filtered, manifest.id, preset_dir, self.project_root + commands_to_register, manifest.id, preset_dir, self.project_root ) def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: @@ -611,231 +684,402 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) - def _replay_wraps_for_command(self, cmd_name: str) -> None: - """Recompose and rewrite agent files for a wrap-strategy command. - - Collects all installed presets that declare cmd_name in their - wrap_commands registry field, sorts them so the highest-precedence - preset (lowest priority number) wraps outermost, then writes the - fully composed output to every agent directory. + def _reconcile_composed_commands(self, command_names: List[str]) -> None: + """Re-resolve and re-register composed commands from the full stack. - Called after every install and remove to keep agent files correct - regardless of installation order. + After install or remove, recompute the effective content for each + command name that participates in composition, and write the winning + content to the agent directories. This ensures command files always + reflect the current priority stack rather than depending on + install/remove order. Args: - cmd_name: Full command name (e.g. "speckit.specify") + command_names: List of command names to reconcile """ + if not command_names: + return + try: from .agents import CommandRegistrar except ImportError: return - # Collect enabled presets that wrap this command, sorted ascending - # (lowest priority number = highest precedence = outermost). - wrap_presets = [] - for pack_id, metadata in self.registry.list_by_priority(include_disabled=False): - if cmd_name not in metadata.get("wrap_commands", []): - continue - pack_dir = self.presets_dir / pack_id - if not pack_dir.is_dir(): - continue # corrupted state — skip - wrap_presets.append((pack_id, pack_dir)) + resolver = PresetResolver(self.project_root) + registrar = CommandRegistrar() - if not wrap_presets: - return + # Cache registry and manifests outside the loop to avoid + # repeated filesystem reads for each command name. + presets_by_priority = list(self.registry.list_by_priority()) - # Derive short name for core resolution fallback. - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue - resolver = PresetResolver(self.project_root) - core_file = ( - resolver.resolve_core(cmd_name, "command") - or resolver.resolve_extension_command_via_manifest(cmd_name) - or ( - resolver.resolve_extension_command_via_manifest(short_name) - if short_name != cmd_name - else None + # If the top layer is replace, it wins entirely — lower layers + # are irrelevant regardless of their strategies. + top_is_replace = layers[0]["strategy"] == "replace" + has_composition = not top_is_replace and any( + layer["strategy"] != "replace" for layer in layers ) - or resolver.resolve_core(short_name, "command") - ) - if core_file is None: - return - - registrar = CommandRegistrar() - core_frontmatter, core_body = registrar.parse_frontmatter( - core_file.read_text(encoding="utf-8") - ) - replay_aliases: List[str] = [] - seen_aliases: set[str] = set() - - # Apply wraps innermost-first (reverse of ascending list). - accumulated_body = core_body - outermost_frontmatter = {} - outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset - for pack_id, pack_dir in reversed(wrap_presets): - manifest_path = pack_dir / "preset.yml" - cmd_file: Optional[Path] = None - if manifest_path.exists(): - try: - manifest = PresetManifest(manifest_path) - except (PresetValidationError, KeyError, TypeError, ValueError): - manifest = None - if manifest is not None: - for template in manifest.templates: - if template.get("type") != "command" or template.get("name") != cmd_name: - continue - file_rel = template.get("file") - if isinstance(file_rel, str): - rel_path = Path(file_rel) - if not rel_path.is_absolute(): - try: - preset_root = pack_dir.resolve() - candidate = (preset_root / rel_path).resolve() - candidate.relative_to(preset_root) - except (OSError, ValueError): - candidate = None - if candidate is not None: - cmd_file = candidate - aliases = template.get("aliases", []) - if not isinstance(aliases, list): - aliases = [] - for alias in aliases: - if isinstance(alias, str) and alias not in seen_aliases: - replay_aliases.append(alias) - seen_aliases.add(alias) + if not has_composition: + # Pure replace — the top layer wins. + top_layer = layers[0] + top_path = top_layer["path"] + # Try to find which preset owns this layer + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + manifest = resolver._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + self._register_for_non_skill_agents( + registrar, [tmpl], manifest.id, pack_dir + ) + registered = True + break break - if cmd_file is None: - cmd_file = pack_dir / "commands" / f"{cmd_name}.md" - if not cmd_file.exists(): - continue - wrap_fm, wrap_body = registrar.parse_frontmatter( - cmd_file.read_text(encoding="utf-8") - ) - accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body) - outermost_frontmatter = wrap_fm # last iteration = outermost preset - outermost_pack_id = pack_id - - # Build final frontmatter: outermost preset wins; fall back to core for - # scripts/agent_scripts if the outermost preset does not define them. - final_frontmatter = dict(outermost_frontmatter) - final_frontmatter.pop("strategy", None) - for key in ("scripts", "agent_scripts"): - if key not in final_frontmatter and key in core_frontmatter: - final_frontmatter[key] = core_frontmatter[key] - - composed_content = ( - registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body - ) - - self._replay_skill_override(cmd_name, composed_content, outermost_pack_id) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp_path = Path(tmpdir) - cmd_dir = tmp_path / "commands" - cmd_dir.mkdir() - (cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8") - registrar._ensure_configs() - for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): - if agent_config.get("extension") == "/SKILL.md": - continue - agent_dir = self.project_root / agent_config["dir"] - if not agent_dir.exists(): - continue - try: - registrar.register_commands( - agent_name, - [{ - "name": cmd_name, - "file": f"commands/{cmd_name}.md", - "aliases": replay_aliases, - }], - f"preset:{outermost_pack_id}", - tmp_path, + if not registered: + # Top layer is a non-preset source (extension, core, or + # project override). Register directly from the layer path. + source = layers[0]["source"] + if source.startswith("extension:"): + # Use extension's own registration to preserve context formatting + ext_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = self.project_root / ".specify" / "extensions" / ext_id + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest + ext_manifest = ExtensionManifest(ext_manifest_path) + # Filter to only the command being reconciled + matching_cmds = [ + c for c in ext_manifest.commands + if c.get("name") == cmd_name + ] + if matching_cmds: + registrar.register_commands_for_non_skill_agents( + matching_cmds, ext_id, ext_dir, + self.project_root, + context_note=f"\n\n\n", + ) + registered = True + except Exception: + # Extension registration failed; fall back to + # generic path-based registration below. + pass + if not registered: + source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source + self._register_command_from_path( + registrar, cmd_name, top_path, + source_id=source_id, + ) + else: + # Composed command — resolve from full stack + composed = resolver.resolve_content(cmd_name, "command") + if composed is None: + # Composition no longer possible (e.g. base layer removed). + # Unregister any stale command file from non-skill agents. + import warnings + warnings.warn( + f"Cannot compose command '{cmd_name}': no base layer. " + f"Stale command files may remain.", + stacklevel=2, + ) + registrar._ensure_configs() + # Include aliases from the top layer's manifest + cmd_names_to_unregister = [cmd_name] + for _pid, _meta in presets_by_priority: + _pd = self.presets_dir / _pid + _m = resolver._get_manifest(_pd) + if _m: + for _t in _m.templates: + if _t.get("name") == cmd_name and _t.get("type") == "command": + for alias in _t.get("aliases", []): + if isinstance(alias, str): + cmd_names_to_unregister.append(alias) + break + registrar.unregister_commands( + {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"}, self.project_root, ) - except ValueError: continue - def _replay_skill_override( + # Write to the highest-priority preset's .composed dir + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if not manifest: + continue + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + composed_dir = pack_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + self._register_for_non_skill_agents( + registrar, + [{**tmpl, "file": f".composed/{cmd_name}.md"}], + manifest.id, pack_dir, + ) + registered = True + break + else: + continue + break + if not registered: + # No preset owns this composed command — write to a + # shared .composed dir and register from the top layer. + shared_composed = self.presets_dir / ".composed" + shared_composed.mkdir(parents=True, exist_ok=True) + composed_file = shared_composed / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + source = layers[0]["source"] + if source.startswith("extension:"): + source_id = source.split(":", 1)[1].split(" ", 1)[0] + else: + source_id = source + self._register_command_from_path( + registrar, cmd_name, composed_file, + source_id=source_id, + ) + + def _register_command_from_path( self, + registrar: Any, cmd_name: str, - composed_content: str, - outermost_pack_id: str, + cmd_path: Path, + source_id: str = "reconciled", ) -> None: - """Rewrite any active SKILL.md override for a replayed wrap command.""" - skills_dir = self._get_skills_dir() - if not skills_dir: - return + """Register a single command from a file path (non-preset source). - from . import SKILL_DESCRIPTIONS, load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + Used by reconciliation when the winning layer is an extension, + core template, or project override rather than a preset. - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = init_opts.get("ai") - if not isinstance(selected_ai, str): + Args: + registrar: CommandRegistrar instance + cmd_name: Command name + cmd_path: Path to the command file + source_id: Source attribution for rendered output + """ + if not cmd_path.exists(): return + cmd_tmpl: Dict[str, Any] = { + "name": cmd_name, + "type": "command", + "file": cmd_path.name, + } + # Load aliases from extension manifest when the winning layer is an extension + if source_id and not source_id.startswith("preset:"): + try: + from .extensions import ExtensionManifest + for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): + if not ext_dir.is_dir(): + continue + if cmd_path.is_relative_to(ext_dir): + manifest_path = ext_dir / "extension.yml" + if manifest_path.exists(): + ext_manifest = ExtensionManifest(manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == cmd_name: + aliases = cmd.get("aliases", []) + if isinstance(aliases, list) and aliases: + cmd_tmpl["aliases"] = aliases + break + break + except Exception: + pass # best-effort alias loading + self._register_for_non_skill_agents( + registrar, [cmd_tmpl], source_id, cmd_path.parent + ) - registrar = CommandRegistrar() - integration = get_integration(selected_ai) - agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) - create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md" - - skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) - target_skill_names: List[str] = [] - if (skills_dir / skill_name).is_dir(): - target_skill_names.append(skill_name) - if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): - target_skill_names.append(legacy_skill_name) - if not target_skill_names and create_missing_skills: - missing_skill_dir = skills_dir / skill_name - if not missing_skill_dir.exists(): - target_skill_names.append(skill_name) - if not target_skill_names: + def _register_for_non_skill_agents( + self, + registrar: Any, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + ) -> None: + """Register commands for non-skill agents during reconciliation. + + Skill-based agents (``/SKILL.md`` layout) are handled separately: + - On removal: ``_unregister_skills()`` restores from core/extension, + then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the + next winning preset so SKILL.md files get proper frontmatter and + descriptions. + - On install: ``_register_skills()`` writes formatted SKILL.md, then + ``_reconcile_skills()`` ensures the actual priority winner is used. + + Writing raw command content to skill agents would produce invalid + SKILL.md files (missing skill frontmatter, descriptions, etc.). + """ + registrar.register_commands_for_non_skill_agents( + commands, source_id, source_dir, self.project_root + ) + + class _FilteredManifest: + """Wrapper that exposes only selected command templates from a manifest. + + Used by _reconcile_skills to avoid overwriting skills for commands + that aren't being reconciled. + """ + + def __init__(self, manifest: "PresetManifest", cmd_names: set): + self._manifest = manifest + self._cmd_names = cmd_names + + def __getattr__(self, name: str): + return getattr(self._manifest, name) + + @property + def templates(self) -> List[Dict[str, Any]]: + return [ + t for t in self._manifest.templates + if t.get("name") in self._cmd_names + ] + + def _reconcile_skills(self, command_names: List[str]) -> None: + """Re-register skills for commands whose winning layer changed. + + After a preset is removed, finds the next preset in the priority + stack that provides each command and re-runs skill registration + for that preset so SKILL.md files reflect the current winner. + + Args: + command_names: List of command names to reconcile skills for + """ + if not command_names: return - raw_short_name = cmd_name - if raw_short_name.startswith("speckit."): - raw_short_name = raw_short_name[len("speckit."):] - short_name = raw_short_name.replace(".", "-") - skill_title = self._skill_title_from_command(cmd_name) - - frontmatter, body = registrar.parse_frontmatter(composed_content) - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get( - short_name, - original_desc or f"Spec-kit workflow command: {short_name}", - ) - body = registrar.resolve_skill_placeholders( - selected_ai, dict(frontmatter), body, self.project_root - ) + resolver = PresetResolver(self.project_root) + skills_dir = self._get_skills_dir() - for target_skill_name in target_skill_names: - skill_subdir = skills_dir / target_skill_name - if skill_subdir.exists() and not skill_subdir.is_dir(): + # Cache registry once to avoid repeated filesystem reads + presets_by_priority = list(self.registry.list_by_priority()) + + # Group command names by winning preset to batch _register_skills calls + # while only registering skills for the specific commands being reconciled. + preset_cmds: Dict[str, List[str]] = {} + non_preset_skills: List[tuple] = [] + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: continue - skill_subdir.mkdir(parents=True, exist_ok=True) - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai, - target_skill_name, - enhanced_desc, - f"preset:{outermost_pack_id}", - ) - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {skill_title} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content(skill_content) - (skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8") + + # Re-create the skill directory only if it was previously managed + # (i.e., listed in some preset's registered_skills). This avoids + # creating new skill dirs that _register_skills would normally skip. + if skills_dir: + skill_name, _ = self._skill_names_for_command(cmd_name) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + # Check if any preset previously registered this skill + was_managed = False + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + if skill_name in meta.get("registered_skills", []): + was_managed = True + break + if was_managed: + skill_subdir.mkdir(parents=True, exist_ok=True) + + top_path = layers[0]["path"] + # Find the preset that owns the winning layer + found_preset = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + preset_cmds.setdefault(pack_id, []).append(cmd_name) + found_preset = True + break + if not found_preset: + # Winner is a non-preset source (core/extension/override). + # Track the winning layer path for skill restoration. + skill_name, _ = self._skill_names_for_command(cmd_name) + non_preset_skills.append((skill_name, cmd_name, layers[0])) + + # Restore skills for commands whose winner is non-preset. + if non_preset_skills and skills_dir: + # Separate override-backed skills from core/extension-backed ones. + # _unregister_skills can rmtree the skill dir, so overrides must + # be handled directly (create dir + write) without that call. + core_ext_skills = [] + override_skills = [] + for item in non_preset_skills: + if item[2]["source"] == "project override": + override_skills.append(item) + else: + core_ext_skills.append(item) + + if core_ext_skills: + self._unregister_skills( + [s[0] for s in core_ext_skills], self.presets_dir + ) + + for skill_name, cmd_name, top_layer in override_skills: + skill_subdir = skills_dir / skill_name + skill_subdir.mkdir(parents=True, exist_ok=True) + skill_file = skill_subdir / "SKILL.md" + try: + from .agents import CommandRegistrar + from . import SKILL_DESCRIPTIONS, load_init_options + registrar = CommandRegistrar() + content = top_layer["path"].read_text(encoding="utf-8") + fm, body = registrar.parse_frontmatter(content) + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + desc = SKILL_DESCRIPTIONS.get( + short_name.replace(".", "-"), + fm.get("description", f"Command: {short_name}"), + ) + init_opts = load_init_options(self.project_root) + selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else "" + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, fm, body, self.project_root + ) + fm_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, desc, + f"override:{cmd_name}", + ) + fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(cmd_name) + skill_content = ( + f"---\n{fm_text}\n---\n\n" + f"# Speckit {skill_title} Skill\n\n{body}\n" + ) + # Apply integration post-processing (e.g. Claude flags) + from .integrations import get_integration + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + skill_file.write_text(skill_content, encoding="utf-8") + except Exception: + pass # best-effort override skill restoration + + # Register skills only for the specific commands being reconciled, + # not all commands in each winning preset's manifest. + for pack_id, cmds in preset_cmds.items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + # Filter manifest to only the commands being reconciled + cmds_set = set(cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + self._register_skills(filtered_manifest, pack_dir) def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -1016,6 +1260,12 @@ def _register_skills( if not source_file.exists(): continue + # Use composed content if available (written by _register_commands + # for commands with non-replace strategies), otherwise the original. + composed_file = preset_dir / ".composed" / f"{cmd_name}.md" + if composed_file.exists(): + source_file = composed_file + # Derive the short command name (e.g. "specify" from "speckit.specify") raw_short_name = cmd_name if raw_short_name.startswith("speckit."): @@ -1257,43 +1507,81 @@ def install_from_directory( shutil.copytree(source_dir, dest_dir) - # Register command overrides with AI agents - registered_commands = self._register_commands(manifest, dest_dir) - - # Update corresponding skills when --ai-skills was previously used - registered_skills = self._register_skills(manifest, dest_dir) - - # Detect wrap commands before registry.add() so a read failure doesn't - # leave a partially-committed registry entry. - wrap_commands = [] - try: - from .agents import CommandRegistrar as _CR - _registrar = _CR() - for cmd_tmpl in manifest.templates: - if cmd_tmpl.get("type") != "command": - continue - cmd_file = dest_dir / cmd_tmpl["file"] - if not cmd_file.exists(): - continue - cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8")) - if cmd_fm.get("strategy") == "wrap": - wrap_commands.append(cmd_tmpl["name"]) - except ImportError: - pass - + # Pre-register the preset so that composition resolution can see it + # in the priority stack when resolving composed command content. self.registry.add(manifest.id, { "version": manifest.version, "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - "wrap_commands": wrap_commands, + "registered_commands": {}, + "registered_skills": [], }) - for cmd_name in wrap_commands: - self._replay_wraps_for_command(cmd_name) + registered_commands: Dict[str, List[str]] = {} + registered_skills: List[str] = [] + try: + # Register command overrides with AI agents and persist the result + # immediately so cleanup can recover even if installation stops + # before later phases complete. + registered_commands = self._register_commands(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_commands": registered_commands, + }) + + # Update corresponding skills when --ai-skills was previously used + # and persist that result as well. + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_skills": registered_skills, + }) + except Exception: + # Roll back all side effects. Note: if _register_commands or + # _register_skills raised mid-way (e.g. I/O error after writing + # some files), registered_commands/registered_skills may be empty + # and some agent command files could be orphaned. Removing dest_dir + # (which contains .composed/) and the registry entry ensures the + # preset system is consistent even if orphaned files remain. + if registered_commands: + self._unregister_commands(registered_commands) + if registered_skills: + self._unregister_skills(registered_skills, dest_dir) + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + except OSError: + pass # best-effort cleanup; don't mask the original error + self.registry.remove(manifest.id) + raise + + # Reconcile all affected commands from the full priority stack so that + # install order doesn't determine the winning command file. + # Apply the same extension-installed filter as _register_commands to + # avoid reconciling extension commands when the extension isn't installed. + extensions_dir = self.project_root / ".specify" / "extensions" + cmd_names = [] + for t in manifest.templates: + if t.get("type") != "command": + continue + name = t["name"] + parts = name.split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + cmd_names.append(name) + if cmd_names: + try: + self._reconcile_composed_commands(cmd_names) + self._reconcile_skills(cmd_names) + except Exception as exc: + import warnings + warnings.warn( + f"Post-install reconciliation failed for {manifest.id}: {exc}. " + f"Agent command files may not reflect the current priority stack.", + stacklevel=2, + ) return manifest @@ -1369,16 +1657,31 @@ def remove(self, pack_id: str) -> bool: # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] registered_commands = metadata.get("registered_commands", {}) if metadata else {} - wrap_commands = metadata.get("wrap_commands", []) if metadata else [] pack_dir = self.presets_dir / pack_id - # _unregister_skills must run before directory deletion (reads preset files) + # Collect ALL command names before filtering for reconciliation, + # so commands registered only for skill-based agents are also reconciled. + # Also include aliases from the manifest as a safety net for registries + # populated by older versions that may not track aliases. + removed_cmd_names = set() + for cmd_names in registered_commands.values(): + removed_cmd_names.update(cmd_names) + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + removed_cmd_names.add(alias) + except PresetValidationError: + # Invalid manifest — skip alias extraction; primary command + # names from registered_commands are still unregistered. + pass + if registered_skills: self._unregister_skills(registered_skills, pack_dir) - # When _unregister_skills has already handled skill-agent files, strip - # those entries from registered_commands to avoid double-deletion. - # (When registered_skills is empty, skill-agent entries in - # registered_commands are the only deletion path for those files.) try: from .agents import CommandRegistrar except ImportError: @@ -1390,43 +1693,29 @@ def remove(self, pack_id: str) -> bool: if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" } - # Delete the preset directory before mutating the registry so a - # filesystem failure cannot leave files on disk without a registry entry. + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) + if pack_dir.exists(): shutil.rmtree(pack_dir) - # Remove from registry before replaying so _replay_wraps_for_command sees - # the post-removal registry state. self.registry.remove(pack_id) - # Separate wrap commands from non-wrap commands in registered_commands. - non_wrap_commands = { - agent_name: [c for c in cmd_names if c not in wrap_commands] - for agent_name, cmd_names in registered_commands.items() - } - non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v} - - # Unregister non-wrap command files from AI agents. - if non_wrap_commands: - self._unregister_commands(non_wrap_commands) - - # For each wrapped command, either re-compose remaining wraps or delete. - for cmd_name in wrap_commands: - remaining = [ - pid for pid, meta in self.registry.list().items() - if cmd_name in meta.get("wrap_commands", []) - ] - if remaining: - self._replay_wraps_for_command(cmd_name) - else: - # No wrap presets remain — delete the agent file entirely. - wrap_agent_commands = { - agent_name: [c for c in cmd_names if c == cmd_name] - for agent_name, cmd_names in registered_commands.items() - } - wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v} - if wrap_agent_commands: - self._unregister_commands(wrap_agent_commands) + # Reconcile: if other presets still provide these commands, + # re-resolve from the remaining stack so the next layer takes effect. + if removed_cmd_names: + try: + self._reconcile_composed_commands(list(removed_cmd_names)) + self._reconcile_skills(list(removed_cmd_names)) + except Exception as exc: + import warnings + warnings.warn( + f"Post-removal reconciliation failed for {pack_id}: {exc}. " + f"Agent command files may be stale; reinstall affected presets " + f"or run 'specify preset add' to refresh.", + stacklevel=2, + ) return True @@ -2036,6 +2325,21 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" + self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: + """Get a cached preset manifest, parsing it on first access.""" + key = str(pack_dir) + if key not in self._manifest_cache: + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + self._manifest_cache[key] = PresetManifest(manifest_path) + except PresetValidationError: + self._manifest_cache[key] = None + else: + self._manifest_cache[key] = None + return self._manifest_cache[key] def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -2079,6 +2383,19 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions + @staticmethod + def _core_stem(template_name: str) -> Optional[str]: + """Extract the stem for core command lookup. + + Commands use dot notation (e.g. ``speckit.specify``), but core + command files are named by stem (e.g. ``specify.md``). Returns + the stem if *template_name* follows the ``speckit.`` pattern, + or ``None`` otherwise. + """ + if template_name.startswith("speckit."): + return template_name[len("speckit."):] + return None + def resolve( self, template_name: str, @@ -2156,6 +2473,12 @@ def resolve( core = self.templates_dir / "commands" / f"{template_name}.md" if core.exists(): return core + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + core = self.templates_dir / "commands" / f"{stem}.md" + if core.exists(): + return core elif template_type == "script": core = self.templates_dir / "scripts" / f"{template_name}{ext}" if core.exists(): @@ -2173,6 +2496,10 @@ def resolve( candidate = _core_pack / "templates" / f"{template_name}.md" elif template_type == "command": candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" elif template_type == "script": candidate = _core_pack / "scripts" / f"{template_name}{ext}" else: @@ -2186,6 +2513,10 @@ def resolve( candidate = repo_root / "templates" / f"{template_name}.md" elif template_type == "command": candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" elif template_type == "script": candidate = repo_root / "scripts" / f"{template_name}{ext}" else: @@ -2317,3 +2648,428 @@ def resolve_with_source( continue return {"path": resolved_str, "source": "core"} + + def collect_all_layers( + self, + template_name: str, + template_type: str = "template", + ) -> List[Dict[str, Any]]: + """Collect all layers in the priority stack for a template. + + Returns layers from highest priority (checked first) to lowest priority. + Each layer is a dict with 'path', 'source', and 'strategy' keys. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + List of layer dicts ordered highest-to-lowest priority. + """ + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + ext = ".md" + if template_type == "script": + ext = ".sh" + + layers: List[Dict[str, Any]] = [] + + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: + for subdir in subdirs: + if subdir: + candidate = base_dir / subdir / f"{template_name}{ext}" + else: + candidate = base_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + return None + + # Priority 1: Project-local overrides (always "replace" strategy) + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + layers.append({ + "path": override, + "source": "project override", + "strategy": "replace", + }) + + # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + # Read strategy and manifest file path from preset manifest + strategy = "replace" + manifest_file_path = None + manifest_has_strategy = False + manifest_found_entry = False + manifest = self._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if (tmpl.get("name") == template_name + and tmpl.get("type") == template_type): + strategy = tmpl.get("strategy", "replace") + manifest_has_strategy = "strategy" in tmpl + manifest_file_path = tmpl.get("file") + manifest_found_entry = True + break + # Use manifest file path if specified, otherwise convention-based + # lookup — but only when the manifest doesn't exist or doesn't + # list this template, so preset.yml stays authoritative. + candidate = None + if manifest_file_path: + manifest_candidate = pack_dir / manifest_file_path + if manifest_candidate.exists(): + candidate = manifest_candidate + # Explicit file path that doesn't exist: skip convention + # fallback to avoid masking typos or picking up unintended files. + elif not manifest_found_entry: + # Manifest doesn't list this template — check convention paths + candidate = _find_in_subdirs(pack_dir) + if candidate: + # Legacy fallback: if manifest doesn't explicitly declare a + # strategy, check the command file's frontmatter for any valid + # strategy. Skip when the manifest entry includes strategy key + # (even if it's "replace") to avoid overriding explicit declarations. + if not manifest_has_strategy and strategy == "replace" and template_type == "command": + try: + cmd_content = candidate.read_text(encoding="utf-8") + lines = cmd_content.splitlines(keepends=True) + if lines and lines[0].rstrip("\r\n") == "---": + fence_end = -1 + for fi, fline in enumerate(lines[1:], start=1): + if fline.rstrip("\r\n") == "---": + fence_end = fi + break + if fence_end > 0: + fm_text = "".join(lines[1:fence_end]) + fm_data = yaml.safe_load(fm_text) + if isinstance(fm_data, dict): + fm_strategy = fm_data.get("strategy") + if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: + strategy = fm_strategy.lower() + except (yaml.YAMLError, OSError): + # Best-effort legacy frontmatter parsing: keep default + # strategy ("replace") when content is unreadable/invalid. + pass + version = metadata.get("version", "?") if metadata else "?" + layers.append({ + "path": candidate, + "source": f"{pack_id} v{version}", + "strategy": strategy, + }) + + # Priority 3: Extension-provided templates (always "replace") + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + # Try convention-based lookup first + candidate = _find_in_subdirs(ext_dir) + # If not found and this is a command, check extension manifest + if candidate is None and template_type == "command": + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest, ValidationError as ExtValidationError + ext_manifest = ExtensionManifest(ext_manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == template_name: + cmd_file = cmd.get("file") + if cmd_file: + c = ext_dir / cmd_file + if c.exists(): + candidate = c + break + except (ExtValidationError, yaml.YAMLError): + # Invalid extension manifest — fall back to + # convention-based lookup (already attempted above). + pass + if candidate: + if ext_meta: + version = ext_meta.get("version", "?") + source = f"extension:{ext_id} v{version}" + else: + source = f"extension:{ext_id} (unregistered)" + layers.append({ + "path": candidate, + "source": source, + "strategy": "replace", + }) + + # Priority 4: Core templates (always "replace") + core = None + if template_type == "template": + c = self.templates_dir / f"{template_name}.md" + if c.exists(): + core = c + elif template_type == "command": + c = self.templates_dir / "commands" / f"{template_name}.md" + if c.exists(): + core = c + else: + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + c = self.templates_dir / "commands" / f"{stem}.md" + if c.exists(): + core = c + elif template_type == "script": + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): + core = c + if core: + layers.append({ + "path": core, + "source": "core", + "strategy": "replace", + }) + else: + # Priority 5: Bundled core_pack (wheel install) or repo-root + # templates (source-checkout), matching resolve()'s tier-5 fallback. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled: + layers.append({ + "path": bundled, + "source": "core (bundled)", + "strategy": "replace", + }) + + return layers + + def _find_bundled_core( + self, + template_name: str, + template_type: str, + ext: str, + ) -> Optional[Path]: + """Find a core template from the bundled pack or source checkout. + + Mirrors the tier-5 fallback logic in ``resolve()`` so that + ``collect_all_layers()`` can locate base layers even when + ``.specify/templates/`` doesn't contain the core file. + """ + try: + from specify_cli import _locate_core_pack + except ImportError: + return None + + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) + + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = Path(__file__).parent.parent.parent + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c + return None + + def resolve_content( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[str]: + """Resolve a template name and return composed content. + + Walks the priority stack and composes content using strategies: + - replace (default): highest-priority content wins entirely + - prepend: content is placed before lower-priority content + - append: content is placed after lower-priority content + - wrap: content contains {CORE_TEMPLATE} placeholder replaced + with lower-priority content (or $CORE_SCRIPT for scripts) + + Composition is recursive — multiple composing presets chain. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Composed content string, or None if not found + """ + layers = self.collect_all_layers(template_name, template_type) + if not layers: + return None + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if layers[0]["strategy"] == "replace": + return layers[0]["path"].read_text(encoding="utf-8") + + # Composition: build content bottom-up from the effective base. + # The base is the nearest replace layer scanning from highest priority + # downward. Only layers above the base contribute to composition. + # + # layers is ordered highest-priority first. We process in reverse. + reversed_layers = list(reversed(layers)) + + # Find the effective base: scan from highest priority (layers[0]) downward + # to find the nearest replace layer. Only compose layers above that base. + # layers is highest-priority first; reversed_layers is lowest first. + base_layer_idx = None # index in layers[] (highest-priority first) + for idx, layer in enumerate(layers): + if layer["strategy"] == "replace": + base_layer_idx = idx + break + + if base_layer_idx is None: + return None # no replace base found + + # Convert to reversed_layers index + base_reversed_idx = len(layers) - 1 - base_layer_idx + content = layers[base_layer_idx]["path"].read_text(encoding="utf-8") + # Compose only the layers above the base (higher priority = lower index in layers, + # higher index in reversed_layers). Process bottom-up from base+1. + start_idx = base_reversed_idx + 1 + + # For command composition, strip frontmatter from each layer to avoid + # leaking YAML metadata into the composed body. The highest-priority + # layer's frontmatter will be reattached at the end. + is_command = template_type == "command" + top_frontmatter_text = None + base_frontmatter_text = None + + def _split_frontmatter(text: str) -> tuple: + """Return (frontmatter_block_with_fences, body) or (None, text). + + Uses line-based fence detection (fence must be ``---`` on its + own line) to avoid false matches on ``---`` inside YAML values. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return None, text + + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + + if fence_end == -1: + return None, text + + fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") + body = "".join(lines[fence_end + 1:]) + return fm_block, body + + if is_command: + fm, body = _split_frontmatter(content) + if fm: + top_frontmatter_text = fm + base_frontmatter_text = fm + content = body + + # Apply composition layers from bottom to top + for layer in reversed_layers[start_idx:]: + layer_content = layer["path"].read_text(encoding="utf-8") + strategy = layer["strategy"] + + if is_command: + fm, layer_body = _split_frontmatter(layer_content) + layer_content = layer_body + # Track the highest-priority frontmatter seen; + # replace layers reset both top and base frontmatter since + # they replace the entire command including metadata. + if strategy == "replace": + top_frontmatter_text = fm + base_frontmatter_text = fm + elif fm: + top_frontmatter_text = fm + + if strategy == "replace": + content = layer_content + elif strategy == "prepend": + content = layer_content + "\n\n" + content + elif strategy == "append": + content = content + "\n\n" + layer_content + elif strategy == "wrap": + if template_type == "script": + placeholder = "$CORE_SCRIPT" + else: + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise PresetValidationError( + f"Wrap strategy in '{layer['source']}' is missing " + f"the {placeholder} placeholder. The wrapper must " + f"contain {placeholder} to indicate where the " + f"lower-priority content should be inserted." + ) + content = layer_content.replace(placeholder, content) + + # Reattach the highest-priority frontmatter for commands, + # inheriting scripts/agent_scripts from the base if missing + # and stripping the strategy key (internal-only, not for agent output). + if is_command and top_frontmatter_text: + def _parse_fm_yaml(fm_block: str) -> dict: + """Parse YAML from a frontmatter block (with --- fences).""" + lines = fm_block.splitlines() + # Parse only interior lines (between --- fences) + if len(lines) >= 2: + yaml_lines = lines[1:-1] + else: + yaml_lines = [] + try: + return yaml.safe_load("\n".join(yaml_lines)) or {} + except yaml.YAMLError: + return {} + + top_fm = _parse_fm_yaml(top_frontmatter_text) + + # Inherit scripts/agent_scripts from base frontmatter if missing + if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: + base_fm = _parse_fm_yaml(base_frontmatter_text) + for key in ("scripts", "agent_scripts"): + if key not in top_fm and key in base_fm: + top_fm[key] = base_fm[key] + + # Strip strategy key — it's an internal composition directive, + # not meant for rendered agent command files + top_fm.pop("strategy", None) + + if top_fm: + top_frontmatter_text = ( + "---\n" + + yaml.safe_dump(top_fm, sort_keys=False).strip() + + "\n---" + ) + else: + # Empty frontmatter — omit rather than emitting {} + top_frontmatter_text = None + + if top_frontmatter_text: + content = top_frontmatter_text + "\n\n" + content + + return content diff --git a/tests/test_presets.py b/tests/test_presets.py index d913c3b195..64bdc1f0b5 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3666,647 +3666,736 @@ def _make_wrap_preset_dir( return preset_dir -class TestReplayWrapsForCommand: - """Tests for PresetManager._replay_wraps_for_command().""" - def test_replay_no_op_when_no_wrap_presets(self, project_dir): - """replay does nothing when no presets declare wrap_commands for the command.""" - manager = PresetManager(project_dir) - # Should not raise - manager._replay_wraps_for_command("speckit.specify") - - def test_replay_no_op_when_core_missing(self, project_dir, temp_dir): - """replay exits gracefully when resolve_core returns None.""" - from specify_cli.agents import CommandRegistrar - import copy - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.nonexistent-cmd", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - import shutil as _shutil - _shutil.copytree(preset_dir, installed) +class TestCompositionStrategyValidation: + """Test strategy field validation in PresetManifest.""" - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.nonexistent-cmd"], - }) + def test_valid_replace_strategy(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) + def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - # No core file exists for this command — replay should return without writing - manager._replay_wraps_for_command("speckit.nonexistent-cmd") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + def test_valid_append_strategy(self, temp_dir, valid_pack_data): + """Test that append strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "append" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "append" - assert not (agent_dir / "speckit.nonexistent-cmd.md").exists() + def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" - def test_replay_single_preset_writes_composed_output(self, project_dir, temp_dir): - """Single wrap preset: replay writes pre + core + post to agent dirs.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_default_strategy_is_replace(self, pack_dir): + """Test that omitting strategy defaults to replace (key is absent).""" + manifest = PresetManifest(pack_dir / "preset.yml") + # Strategy key should not be present in the manifest data + assert "strategy" not in manifest.templates[0] + # But consumers should treat missing strategy as "replace" + assert manifest.templates[0].get("strategy", "replace") == "replace" + + def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): + """Test that invalid strategy values are rejected.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy"): + PresetManifest(manifest_path) - # Core template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that prepend strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - # Install preset-a - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that append strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "append", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "wrap", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "replace", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for commands.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "core body" in written - assert "[post-a]" in written - assert "{CORE_TEMPLATE}" not in written - assert "strategy" not in written +class TestResolveContent: + """Test PresetResolver.resolve_content() composition.""" - def test_replay_uses_manifest_command_file_mapping(self, project_dir, temp_dir): - """Replay reads wrapper files from preset.yml instead of assuming command-name paths.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_resolve_content_core_template(self, project_dir): + """Test resolve_content returns core template when no composition.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir( - temp_dir, - "preset-a", - "speckit.specify", - "pre-a", - "post-a", - file_rel="commands/custom-wrapper.md", - ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_resolve_content_nonexistent(self, project_dir): + """Test resolve_content returns None for nonexistent template.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("nonexistent") + assert content is None + def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with default replace strategy.""" manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory( + _create_pack(temp_dir, valid_pack_data, "replace-pack", + "# Replaced Content\n"), + "0.1.5" + ) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Replaced Content" in content + assert "Core Spec Template" not in content + + def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with append strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - def test_replay_resolves_extension_core_via_manifest_mapping(self, project_dir, temp_dir): - """Replay finds extension core commands whose manifest file differs from command name.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + assert "Appended Section" in content + # Core should come first, appended after + assert content.index("Core Spec Template") < content.index("Appended Section") + + def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with prepend strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_dir = temp_dir / "prepend-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") - ext_dir = project_dir / ".specify" / "extensions" / "selftest" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "selftest.md").write_text( - "---\ndescription: selftest core\n---\n\nEXTENSION-CORE\n" - ) - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.selftest.extension\n" - " file: commands/selftest.md\n" - " description: Selftest command\n" - ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - preset_dir = _make_wrap_preset_dir( - temp_dir, "preset-a", "speckit.selftest.extension", "pre-a", "post-a" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Security Header" in content + assert "Core Spec Template" in content + # Prepended content should come first + assert content.index("Security Header") < content.index("Core Spec Template") + + def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for templates.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "wrap-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.selftest.extension"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] } - try: - manager._replay_wraps_for_command("speckit.selftest.extension") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.selftest.extension.md").read_text() - assert "[pre-a]" in written - assert "EXTENSION-CORE" in written - assert "[post-a]" in written - - def test_replay_priority_order_lower_number_outermost(self, project_dir, temp_dir): - """Two wrap presets: lower priority number = outermost wrapper.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-outer", "preset-inner"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) manager = PresetManager(project_dir) - # preset-outer has priority 1 (highest precedence = outermost) - # preset-inner has priority 10 (lowest precedence = innermost) - for pid, pri in (("preset-outer", 1), ("preset-inner", 10)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - # Outermost (preset-outer, p=1) wraps everything; innermost (preset-inner, p=10) is next - outer_pre = written.index("[pre-preset-outer]") - inner_pre = written.index("[pre-preset-inner]") - core_pos = written.index("CORE") - inner_post = written.index("[post-preset-inner]") - outer_post = written.index("[post-preset-outer]") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_replay_install_order_independent(self, project_dir, temp_dir): - """Nesting order is determined by priority, not install order.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-a", "preset-b"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") manager = PresetManager(project_dir) - # preset-a priority=5 (outermost), preset-b priority=10 (innermost) - # Install in reverse order to verify install order doesn't affect nesting - for pid, pri in (("preset-b", 10), ("preset-a", 5)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - a_pre = written.index("[pre-preset-a]") - b_pre = written.index("[pre-preset-b]") - core_pos = written.index("CORE") - b_post = written.index("[post-preset-b]") - a_post = written.index("[post-preset-a]") - # preset-a (p=5) is outermost regardless of install order - assert a_pre < b_pre < core_pos < b_post < a_post - - def test_replay_updates_skill_outputs(self, project_dir, temp_dir): - """Replay also rewrites SKILL.md-backed agent outputs.""" - import json - import shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) - - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) - ) - - manager._replay_wraps_for_command("speckit.specify") - - written = (skill_subdir / "SKILL.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written - - def test_replay_applies_integration_post_processing_to_skill(self, project_dir, temp_dir): - """_replay_skill_override must call post_process_skill_content, matching _register_skills.""" - import json - import shutil as _shutil + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" ) - manager._replay_wraps_for_command("speckit.specify") - - # ClaudeIntegration.post_process_skill_content injects these flags. - # Their presence proves the integration hook ran during replay. - written = (skill_subdir / "SKILL.md").read_text() - assert "disable-model-invocation: false" in written, ( - "_replay_skill_override must call post_process_skill_content " - "(same as _register_skills)" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") -class TestInstallRemoveWrapLifecycle: - """Tests for wrap_commands stored on install and replayed on remove.""" - - def _setup_agent(self, project_dir, registrar, agent_configs_dict): - """Register a test markdown agent and return its commands dir.""" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - agent_configs_dict["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - return agent_dir + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") - def test_install_stores_wrap_commands_in_registry(self, project_dir, temp_dir): - """install_from_directory stores wrap_commands in the registry entry.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore\n") - - preset_src = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre", "post") + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - meta = manager.registry.get("preset-a") - assert "wrap_commands" in meta - assert "speckit.specify" in meta["wrap_commands"] - - def test_install_replay_produces_correct_nested_output(self, project_dir, temp_dir): - """After installing two wrap presets, agent file contains correctly nested output.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] } - try: - manager = PresetManager(project_dir) - # Install outermost first (priority=5), then innermost (priority=10) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - # Rename to avoid id conflict with fixture - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - outer_pre = written.index("OUTER-PRE") - inner_pre = written.index("INNER-PRE") - core_pos = written.index("CORE") - inner_post = written.index("INNER-POST") - outer_post = written.index("OUTER-POST") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_remove_replays_remaining_wraps(self, project_dir, temp_dir): - """Removing one wrap preset re-composes the remaining wraps correctly.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + manager.install_from_directory(rep_dir, "0.1.5", priority=1) - written = (agent_dir / "speckit.specify.md").read_text() - # Only inner wrap remains — should be: INNER-PRE + CORE + INNER-POST, no OUTER - assert "INNER-PRE" in written - assert "CORE" in written - assert "INNER-POST" in written - assert "OUTER-PRE" not in written - assert "OUTER-POST" not in written - - def test_wrap_aliases_are_replayed_and_removed(self, project_dir, temp_dir): - """Replay preserves wrap aliases across install/remove lifecycle changes.""" - from specify_cli.agents import CommandRegistrar - import copy + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir( - temp_dir, - "preset-outer", - "speckit.specify", - "OUTER-PRE", - "OUTER-POST", - aliases=["speckit.alias"], - ) - inner_src = _make_wrap_preset_dir( - temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST" - ) - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - - alias_file = agent_dir / "speckit.alias.md" - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "INNER-PRE" in written - assert "INNER-POST" in written - assert "OUTER-POST" in written - - manager.remove("preset-inner") - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "OUTER-POST" in written - assert "INNER-PRE" not in written - assert "INNER-POST" not in written - - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" - assert not (agent_dir / "speckit.alias.md").exists() + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" - def test_remove_last_wrap_preset_deletes_agent_file(self, project_dir, temp_dir): - """Removing the only wrap preset deletes the agent command file.""" - from specify_cli.agents import CommandRegistrar - import copy + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager = PresetManager(project_dir) - src = _make_wrap_preset_dir(temp_dir, "preset-only", "speckit.specify", "PRE", "POST") - manager.install_from_directory(src, "0.1.0", priority=10) - assert (agent_dir / "speckit.specify.md").exists() - manager.remove("preset-only") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") - assert not (agent_dir / "speckit.specify.md").exists() - - def test_remove_keeps_registry_entry_when_directory_delete_fails(self, project_dir, monkeypatch): - """A failed preset directory delete must not leave files untracked by the registry.""" manager = PresetManager(project_dir) - pack_dir = manager.presets_dir / "preset-a" - pack_dir.mkdir(parents=True) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": [], - }) + manager.install_from_directory(pack_dir, "0.1.5") - def fail_rmtree(_path): - raise OSError("locked") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" - monkeypatch.setattr(shutil, "rmtree", fail_rmtree) - with pytest.raises(OSError, match="locked"): - manager.remove("preset-a") +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" - assert manager.registry.is_installed("preset-a") - assert pack_dir.exists() + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) - def test_non_wrap_commands_unaffected_by_wrap_lifecycle(self, project_dir, temp_dir): - """wrap_commands is empty for a preset with no strategy:wrap commands.""" - from specify_cli.agents import CommandRegistrar - import copy - import yaml as _yaml + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) - # Create a preset with a non-wrap command - preset_dir = temp_dir / "non-wrap-preset" - cmd_dir = preset_dir / "commands" - cmd_dir.mkdir(parents=True) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "non-wrap-preset", "name": "Non-wrap", "version": "1.0.0", - "description": "no wrap", "author": "test", - "repository": "https://example.com", "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [ - {"type": "command", "name": "speckit.specify", - "file": "commands/speckit.specify.md", "description": "override"}, - ]}, - "tags": [], + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", } - (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: plain override\n---\n\nplain body\n" + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" ) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) - meta = manager.registry.get("non-wrap-preset") - assert meta.get("wrap_commands", []) == [] - written = (agent_dir / "speckit.specify.md").read_text() - assert "plain body" in written + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir From 89fc554ce57f02288b1ad9ad660f551ddb3cbe45 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:12:09 -0500 Subject: [PATCH 309/321] chore: release 0.8.0, begin 0.8.1.dev0 development (#2333) * chore: bump version to 0.8.0 * chore: begin 0.8.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd75c336aa..64ee3081d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ +## [0.8.0] - 2026-04-23 + +### Changed + +- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133) +- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324) +- docs(install): add pipx as alternative installation method (#2288) +- Add Memory MD community extension (#2327) +- Update version-guard to v1.2.0 (#2321) +- fix: `--force` now overwrites shared infra files during init and upgrade (#2320) +- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322) + ## [0.7.5] - 2026-04-22 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6b76d46f99..f505f89456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.6.dev0" +version = "0.8.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 5a52b7623e5138935a1f59ba10d6e985d5e8c516 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:24:29 +0200 Subject: [PATCH 310/321] feat: Preset screenwriting (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Add screenwriting preset to community catalog - Preset ID: screenwriting - Version: 1.0.0 - Author: Andreas Daumann - Description: Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Speckit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 20 +++++++++++++++++- presets/catalog.community.json | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88332922d5..823d49f8a7 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,25 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX ## 🎨 Community Presets -Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page. +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay. | 22 templates, 27 commands, 1 script | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), stage plays and tutorials/educational. Adapts the Speckit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands | 1 script | [spec-kit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | +| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | +| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | + +This table highlights a curated subset of community presets. For the complete list of currently available presets, see [`presets/catalog.community.json`](presets/catalog.community.json). +To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). ## 🚶 Community Walkthroughs diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 0e0194b27d..c9c23637bd 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -194,6 +194,44 @@ "experimental" ] }, + "screenwriting": { + "name": "Screenwriting", + "id": "screenwriting", + "version": "1.0.0", + "description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-screenwriting", + "download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-screenwriting", + "documentation": "https://github.com/adaumann/speckit-preset-screenwriting/blob/main/screenwriting/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 26, + "commands": 32, + "scripts": 1 + }, + "tags": [ + "writing", + "screenplay", + "scriptwriting", + "film", + "tv", + "fountain", + "fountain-format", + "beat-sheet", + "teleplay", + "drama", + "comedy", + "storytelling", + "tutorial", + "education" + ], + "created_at": "2026-04-23T08:00:00Z", + "updated_at": "2026-04-23T08:00:00Z" + }, "toc-navigation": { "name": "Table of Contents Navigation", "id": "toc-navigation", From 6bf4ebbe333701cd7a75b48f5404553d93a830c0 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Thu, 23 Apr 2026 20:32:30 +0100 Subject: [PATCH 311/321] feat: register jira preset in community catalog (#2224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: register jira preset in community catalog Adds luno/spec-kit-preset-jira — overrides speckit.taskstoissues to create Jira issues instead of GitHub Issues. See #2223 for context on why this is a preset rather than an extension. Co-Authored-By: Claude Opus 4.6 * fix: use immutable tag URL and sort jira preset alphabetically - Change download_url from heads/main to refs/tags/v1.0.0 for reproducible installs - Move jira entry to correct alphabetical position in presets object Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Ed Harrod <1381991+echarrod@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- presets/catalog.community.json | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index c9c23637bd..caf28e5041 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-15T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -141,7 +141,34 @@ ], "created_at": "2026-04-09T08:00:00Z", "updated_at": "2026-04-19T08:00:00Z" - }, + }, + "jira": { + "name": "Jira Issue Tracking", + "id": "jira", + "version": "1.0.0", + "description": "Overrides speckit.taskstoissues to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools.", + "author": "luno", + "repository": "https://github.com/luno/spec-kit-preset-jira", + "download_url": "https://github.com/luno/spec-kit-preset-jira/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/luno/spec-kit-preset-jira", + "documentation": "https://github.com/luno/spec-kit-preset-jira/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "jira", + "atlassian", + "issue-tracking", + "preset" + ], + "created_at": "2026-04-15T00:00:00Z", + "updated_at": "2026-04-15T00:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From 13d88d22a64b77aa6ee481d079c35d74fa10bacb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:13:36 -0500 Subject: [PATCH 312/321] fix: replace xargs trim with sed to handle quotes in descriptions (#2351) xargs re-parses stdin as shell tokens, causing 'unterminated quote' errors when feature descriptions contain apostrophes, double quotes, or backslashes. Replace with sed-based whitespace trim that preserves input verbatim. Add regression tests for special characters in descriptions (core and extension scripts), plus a negative test for whitespace-only input. Fixes #2339 --- .../git/scripts/bash/create-new-feature.sh | 2 +- scripts/bash/create-new-feature.sh | 2 +- tests/test_timestamp_branches.py | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 286aaf7634..f7aa31610e 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -95,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Error: Feature description cannot be empty or contain only whitespace" >&2 exit 1 diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 1879647026..c3537704f6 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty (e.g., user passed only whitespace) -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Error: Feature description cannot be empty or contain only whitespace" >&2 exit 1 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 39228d9455..c99f675081 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -1257,3 +1257,67 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): break else: pytest.fail("FEATURE_DIR not found in PowerShell output") + + +# ── Description Quoting Tests (issue #2339) ────────────────────────────────── + + +@requires_bash +class TestDescriptionQuoting: + """Descriptions with quotes, apostrophes, and backslashes must not break the script. + + Regression tests for https://github.com/github/spec-kit/issues/2339 + """ + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_core_script_handles_special_chars(self, git_repo: Path, description: str): + """Core create-new-feature.sh succeeds with special characters in description.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", description) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str): + """Extension create-new-feature.sh succeeds with special characters in description.""" + script = ( + ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + ) + result = subprocess.run( + ["bash", str(script), "--dry-run", "--short-name", "feat", description], + cwd=ext_git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + def test_whitespace_only_still_rejected(self, git_repo: Path): + """Whitespace-only descriptions must still be rejected after trimming.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ") + assert result.returncode != 0 + assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower() + + def test_plain_description_still_works(self, git_repo: Path): + """Plain description without special characters continues to work.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature") + assert result.returncode == 0, result.stderr From 7f708b9e6f629b81fa98fefb0e0297edea955396 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:36:23 -0500 Subject: [PATCH 313/321] chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.0.0 to 8.1.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/cec208311dfd045dd5311c1add060b2062131d57...08807647e7069bb48b6ef5acd8ec9567f424441b) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44b0269887..7354dd8e28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python uses: actions/setup-python@v6 @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From 6413414907d892c62031e863c237afc6de5bbdb2 Mon Sep 17 00:00:00 2001 From: Valentyn Date: Fri, 24 Apr 2026 17:48:50 +0300 Subject: [PATCH 314/321] Update product-forge to v1.5.1 in community catalog (#2352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update product-forge to v1.5.0 in community catalog - Extension ID: product-forge - Version: 1.1.1 → 1.5.0 - Author: VaiYav - Description: updated to reflect v1.5 features (portfolio, lite mode, monorepo, optional V-Model) - Commands: 10 → 29 - Tags: refreshed to reflect current surface area - download_url pinned to v1.5.0 release tag - updated_at bumped to 2026-04-24 Release: https://github.com/VaiYav/speckit-product-forge/releases/tag/v1.5.0 * Bump product-forge to v1.5.1 (docs patch) Follow-up to v1.5.0 that surfaces the optional V-Model dependency (leocamello/spec-kit-v-model ≥ 0.5.0) in README Requirements, config-template.yml, and docs/config.md. Docs-only patch — no behavioural change. Release: https://github.com/VaiYav/speckit-product-forge/releases/tag/v1.5.1 --- README.md | 2 +- extensions/catalog.community.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 823d49f8a7..120291a918 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ The following community-contributed extensions are available in [`catalog.commun | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | -| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | +| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ecfcbef2c7..469e3ddf9b 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-04-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1424,10 +1424,10 @@ "product-forge": { "name": "Product Forge", "id": "product-forge", - "description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test", + "description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model", "author": "VaiYav", - "version": "1.1.1", - "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip", + "version": "1.5.1", + "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip", "repository": "https://github.com/VaiYav/speckit-product-forge", "homepage": "https://github.com/VaiYav/speckit-product-forge", "documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md", @@ -1437,21 +1437,21 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 10, + "commands": 29, "hooks": 0 }, "tags": [ "process", - "research", - "product-spec", "lifecycle", - "testing" + "monorepo", + "v-model", + "portfolio" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-28T00:00:00Z", - "updated_at": "2026-03-28T00:00:00Z" + "updated_at": "2026-04-24T15:52:00Z" }, "qa": { "name": "QA Testing Extension", From 52c0a5f88fa9f5902e82ea406f1745f94d54d85d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:04:14 -0500 Subject: [PATCH 315/321] fix: resolve command references per integration type (dot vs hyphen) (#2354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve command references per integration type (dot vs hyphen) Replace hardcoded /speckit. references in templates with __SPECKIT_COMMAND___ placeholders that are resolved at setup time based on the integration type: - Markdown/TOML/YAML agents: separator='.' → /speckit.plan - Skills agents: separator='-' → /speckit-plan Changes: - Add resolve_command_refs() static method to IntegrationBase - Add invoke_separator class attribute (. for base, - for skills) - Wire into process_template() as step 8 - Update _install_shared_infra() to process page templates - Replace /speckit.* in 5 command templates and 3 page templates - Add unit tests for resolve_command_refs (positive + negative) - Add integration tests verifying on-disk content for all agents - Add end-to-end CLI tests for Claude (skills) and Copilot (markdown) Fixes #2347 * review: use effective_invoke_separator() for Copilot skills mode Address PR review feedback: instead of bleeding _skills_mode knowledge into the CLI layer, add effective_invoke_separator() method to IntegrationBase that accepts parsed_options. CopilotIntegration overrides it to return "-" when skills mode is requested. The CLI layer simply asks the integration for its separator — no hasattr or _skills_mode coupling. Also adds tests for the new method on both base and Copilot, plus an end-to-end test for 'specify init --integration copilot --integration-options --skills' verifying page templates get hyphen refs. * fix: build_command_invocation preserves full suffix for extension commands Previously rsplit('.', 1)[-1] on 'speckit.git.commit' yielded just 'commit', producing /speckit.commit instead of /speckit.git.commit (or /speckit-git-commit for skills). Fix: strip only the 'speckit.' prefix when present, then join remaining segments with the appropriate separator. Updated in IntegrationBase, SkillsIntegration, and CopilotIntegration. Added tests for extension commands in build_command_invocation across all three. * fix: Copilot dispatch_command() preserves full extension command suffix dispatch_command() had the same rsplit('.', 1)[-1] bug as build_command_invocation() — speckit.git.commit would dispatch as /speckit-commit instead of /speckit-git-commit in skills mode, or --agent speckit.commit instead of speckit.git.commit in default mode. --- src/specify_cli/__init__.py | 54 +++++--- src/specify_cli/integrations/base.py | 54 +++++++- .../integrations/copilot/__init__.py | 22 ++- templates/checklist-template.md | 4 +- templates/commands/analyze.md | 8 +- templates/commands/checklist.md | 2 +- templates/commands/clarify.md | 8 +- templates/commands/implement.md | 2 +- templates/commands/specify.md | 10 +- templates/plan-template.md | 14 +- templates/tasks-template.md | 2 +- tests/integrations/test_base.py | 128 +++++++++++++++++ tests/integrations/test_cli.py | 130 ++++++++++++++++++ .../test_integration_base_markdown.py | 1 + .../test_integration_base_skills.py | 16 +++ .../test_integration_base_toml.py | 1 + .../test_integration_base_yaml.py | 1 + tests/integrations/test_integration_claude.py | 2 + .../integrations/test_integration_copilot.py | 28 ++++ tests/integrations/test_integration_forge.py | 1 + .../integrations/test_integration_generic.py | 1 + 21 files changed, 434 insertions(+), 55 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 77611128b5..1c3e63ec03 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -723,6 +723,7 @@ def _install_shared_infra( script_type: str, tracker: StepTracker | None = None, force: bool = False, + invoke_separator: str = ".", ) -> bool: """Install shared infrastructure files into *project_path*. @@ -730,12 +731,17 @@ def _install_shared_infra( bundled core_pack or source checkout. Tracks all installed files in ``speckit.manifest.json``. + Page templates are processed to resolve ``__SPECKIT_COMMAND___`` + placeholders using *invoke_separator* (``"."`` for markdown agents, + ``"-"`` for skills agents). + When *force* is ``True``, existing files are overwritten with the latest bundled versions. When ``False`` (default), only missing files are added and existing ones are skipped. Returns ``True`` on success. """ + from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest core = _locate_core_pack() @@ -786,7 +792,11 @@ def _install_shared_infra( if dst.exists() and not force: skipped_files.append(str(dst.relative_to(project_path))) else: - shutil.copy2(f, dst) + content = f.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator + ) + dst.write_text(content, encoding="utf-8") rel = dst.relative_to(project_path).as_posix() manifest.record_existing(rel) @@ -1295,7 +1305,7 @@ def init( # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker, force=force) + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -2072,9 +2082,16 @@ def integration_install( selected_script = _resolve_script_type(project_root, script) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2082,11 +2099,6 @@ def integration_install( integration.key, project_root, version=get_speckit_version() ) - # Build parsed options from --integration-options - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, manifest, @@ -2356,9 +2368,16 @@ def integration_switch( opts.pop("context_file", None) save_init_options(project_root, opts) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2368,10 +2387,6 @@ def integration_switch( target_integration.key, project_root, version=get_speckit_version() ) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(target_integration, integration_options) - try: target_integration.setup( project_root, manifest, @@ -2465,8 +2480,15 @@ def integration_upgrade( selected_script = _resolve_script_type(project_root, script) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is up to date; --force overwrites existing files. - _install_shared_infra(project_root, selected_script, force=force) + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2474,10 +2496,6 @@ def integration_upgrade( console.print(f"Upgrading integration: [cyan]{key}[/cyan]") new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a3d8a42aa2..f3b74b0c05 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -84,6 +84,9 @@ class IntegrationBase(ABC): context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + invoke_separator: str = "." + """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + # -- Markers for managed context section ------------------------------ CONTEXT_MARKER_START = "" @@ -96,6 +99,18 @@ def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return the invoke separator for the given options. + + Subclasses whose separator depends on runtime options (e.g. + Copilot in ``--skills`` mode) should override this method. + The default implementation ignores *parsed_options* and returns + the class-level ``invoke_separator``. + """ + return self.invoke_separator + def build_exec_args( self, prompt: str, @@ -122,11 +137,12 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: agents or ``"/speckit-specify my-feature"`` for skills agents. *command_name* may be a full dotted name like - ``"speckit.specify"`` or a bare stem like ``"specify"``. + ``"speckit.specify"``, an extension command like + ``"speckit.git.commit"``, or a bare stem like ``"specify"``. """ stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] invocation = f"/speckit.{stem}" if args: @@ -597,6 +613,24 @@ def remove_context_section(self, project_root: Path) -> bool: return True + @staticmethod + def resolve_command_refs(content: str, separator: str = ".") -> str: + """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + + Each placeholder encodes a command name in upper-case with + underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *separator* to join the segments: + + * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` + * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + """ + return re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + content, + ) + @staticmethod def process_template( content: str, @@ -604,6 +638,7 @@ def process_template( script_type: str, arg_placeholder: str = "$ARGUMENTS", context_file: str = "", + invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -615,6 +650,7 @@ def process_template( 5. Replace ``__AGENT__`` with *agent_name* 6. Replace ``__CONTEXT_FILE__`` with *context_file* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 8. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -684,6 +720,9 @@ def process_template( content = CommandRegistrar.rewrite_project_relative_paths(content) + # 8. Replace __SPECKIT_COMMAND___ with invocation strings + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + return content def setup( @@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase): ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ + invoke_separator = "-" + def build_exec_args( self, prompt: str, @@ -1311,10 +1352,10 @@ def skills_dest(self, project_root: Path) -> Path: def build_command_invocation(self, command_name: str, args: str = "") -> str: """Skills use ``/speckit-`` (hyphenated directory name).""" stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] - invocation = f"/speckit-{stem}" + invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @@ -1395,6 +1436,7 @@ def setup( processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 5c4d0e5410..c7456ce7f0 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase): # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + @classmethod def options(cls) -> list[IntegrationOption]: return [ @@ -145,9 +155,9 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: """ if self._skills_mode: stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] - invocation = f"/speckit-{stem}" + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @@ -175,8 +185,8 @@ def dispatch_command( import subprocess stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] # Detect skills mode from project layout when not set via setup() skills_mode = self._skills_mode @@ -189,7 +199,7 @@ def dispatch_command( ) if skills_mode: - prompt = f"/speckit-{stem}" + prompt = "/speckit-" + stem.replace(".", "-") if args: prompt = f"{prompt} {args}" else: diff --git a/templates/checklist-template.md b/templates/checklist-template.md index 806657da09..9752c130ec 100644 --- a/templates/checklist-template.md +++ b/templates/checklist-template.md @@ -4,13 +4,13 @@ **Created**: [DATE] **Feature**: [Link to spec.md or relevant documentation] -**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. +**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. +## [0.8.1] - 2026-04-24 + +### Changed + +- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349) +- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336) +- docs: move community presets table to docs site, add missing entries (#2341) +- docs(presets): add lean preset README and enrich catalog metadata (#2340) +- fix: resolve command references per integration type (dot vs hyphen) (#2354) +- Update product-forge to v1.5.1 in community catalog (#2352) +- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345) +- fix: replace xargs trim with sed to handle quotes in descriptions (#2351) +- feat: register jira preset in community catalog (#2224) +- feat: Preset screenwriting (#2332) +- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333) + ## [0.8.0] - 2026-04-23 ### Changed diff --git a/pyproject.toml b/pyproject.toml index f505f89456..7dc4efac7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.1.dev0" +version = "0.8.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From d65de534234e5cf813eef7e74be1db754326c82c Mon Sep 17 00:00:00 2001 From: "booster-pack.dev" Date: Fri, 24 Apr 2026 18:28:38 +0000 Subject: [PATCH 321/321] =?UTF-8?q?fix:=20update=20CLAUDE.md=20=E2=80=94?= =?UTF-8?q?=20reflect=20v0.8.1=20sync=20reality,=20link=20re-trim=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale 'Commits behind: 252+' claim (now 0) - Replace 'Do NOT auto-merge' with current policy (bulk syncs OK under CTO review) - Replace 'Commands cut ~80%' with accurate template line counts + re-trim tracker - Link fellowship-dev/pylot#239 as follow-up for terse-template re-trimming Addresses review finding from double-check of PR #2. --- CLAUDE.md | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49810965d6..1791a1e1ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # spec-kit — Fellowship Fork -This is a fork of [github/spec-kit](https://github.com/github/spec-kit), intentionally diverged. We maintain our own flavor: tighter line limits, no prose, bash scripts included. +This is a fork of [github/spec-kit](https://github.com/github/spec-kit). We maintain our own flavor: tighter line limits, no prose, bash scripts included. ## Fork Status - **Upstream**: `github/spec-kit` -- **Commits behind upstream**: 252+ (intentional — we diverged significantly) -- **Do NOT auto-merge upstream** — changes must be cherry-picked manually and reviewed +- **Synced to**: v0.8.1 (2026-04-24) — 0 commits behind upstream +- **Upstream syncs**: bulk syncs are permitted under CTO review; cherry-picks for smaller updates ## Consumer Repos @@ -27,17 +27,25 @@ Consumer repos copy commands into `.claude/commands/` (e.g., `speckit.specify.md - If a consumer repo is drifted, the sync task opens a PR to update it - To update a consumer repo manually: copy updated command files into `.claude/commands/` -## Upstream Cherry-Picks +## Upstream Syncs -- The tooling crew also checks `github/spec-kit` weekly for potentially relevant commits -- A GitHub issue is created on this repo listing recommendations -- Cherry-picks are **manual only** — review carefully before applying -- Never auto-merge from upstream +- The tooling crew checks `github/spec-kit` weekly for relevant commits +- Small fixes/features: cherry-pick manually and review +- Major version bumps: bulk sync PR under CTO review is acceptable +- Consumer repos need a re-sync PR after any template changes here ## What Changed vs Upstream -- Commands cut ~80%: original ~1,400 lines → this fork ~250 lines -- Strict line limits: spec ≤50, plan ≤50, tasks ≤40 -- No prose: tasks are checkboxes, specs are bullets, plans are tables -- Bash scripts included for branch creation and plan setup -- Generic constitution template included +Templates are currently at upstream v0.8.1 line counts (re-trimming tracked in [fellowship-dev/pylot#239](https://github.com/fellowship-dev/pylot/issues/239)): + +| Template | Current | Terse target | +|----------|---------|--------------| +| `specify.md` | 327 lines | ≤50 lines | +| `plan.md` | 152 lines | ≤50 lines | +| `tasks.md` | 203 lines | ≤40 lines | +| `implement.md` | 201 lines | ≤40 lines | +| `analyze.md` | 252 lines | ≤40 lines | + +Fork additions preserved from pre-v0.8.1: +- Bash scripts for branch creation and plan setup +- Generic constitution template