From 4eda983950964b1af1f09ca10c7bb342be3af9f9 Mon Sep 17 00:00:00 2001 From: LuoHui1 <3053763193@qq.com> Date: Thu, 18 Jun 2026 22:40:32 +0800 Subject: [PATCH 01/42] fix: count worktree branches in git extension numbering (#3054) * fix: count worktree branches in git extension numbering * fix: preserve literal plus branch prefixes --- .../scripts/bash/create-new-feature-branch.sh | 2 +- .../powershell/create-new-feature-branch.ps1 | 2 +- tests/extensions/git/test_git_extension.py | 60 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 6fd3835659..b6480f40fe 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -127,7 +127,7 @@ 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 + git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number } # Extract the highest sequential feature number from a list of ref names (one per line). diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 90ea51d19b..47f93e1866 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -88,7 +88,7 @@ function Get-HighestNumberFromBranches { $branches = git branch -a 2>$null if ($LASTEXITCODE -eq 0 -and $branches) { $cleanNames = $branches | ForEach-Object { - $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + $_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', '' } return Get-HighestNumberFromNames -Names $cleanNames } diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 142463683c..431af6df10 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -89,6 +89,17 @@ def _write_config(project: Path, content: str) -> Path: return config_path +def _add_sibling_worktree(project: Path, path: Path, branch: str) -> None: + """Add a sibling worktree so `git branch -a` marks it with `+`.""" + subprocess.run( + ["git", "worktree", "add", "-q", "-b", branch, str(path), "HEAD"], + cwd=project, + check=True, + capture_output=True, + text=True, + ) + + # Git identity env vars for CI runners without global git config _GIT_ENV = { "GIT_AUTHOR_NAME": "Test User", @@ -312,6 +323,40 @@ def test_increments_from_existing_specs(self, tmp_path: Path): data = json.loads(result.stdout) assert data["FEATURE_NUM"] == "003" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): + """Branches checked out in sibling worktrees still reserve their prefix.""" + project = _setup_project(tmp_path / "project") + _add_sibling_worktree(project, tmp_path / "sibling-worktree", "007-worktree-feature") + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "008-next" + assert data["FEATURE_NUM"] == "008" + + def test_dry_run_preserves_literal_plus_branch_prefix(self, tmp_path: Path): + """A literal leading plus in a branch name is not a git worktree marker.""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "branch", "+007-plus-prefix"], + cwd=project, + check=True, + ) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-next" + assert data["FEATURE_NUM"] == "001" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.sh works without git (outputs branch name, skips branch creation).""" project = _setup_project(tmp_path, git=False) @@ -351,6 +396,21 @@ def test_creates_branch_sequential(self, tmp_path: Path): data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): + """Branches checked out in sibling worktrees still reserve their prefix.""" + project = _setup_project(tmp_path / "project") + _add_sibling_worktree(project, tmp_path / "sibling-worktree", "007-worktree-feature") + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "next", "Next feature", + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "008-next" + assert data["FEATURE_NUM"] == "008" + def test_creates_branch_timestamp(self, tmp_path: Path): """Extension create-new-feature-branch.ps1 creates timestamp branch.""" project = _setup_project(tmp_path) From 98ee02a98b44bfdc444734d30fd04d4af517c3eb Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Fri, 19 Jun 2026 16:28:45 +0100 Subject: [PATCH 02/42] feat(claude): run /analyze in a forked subagent (#2511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * claude: run /analyze in a forked subagent /analyze is explicitly read-only and produces a compact analysis report from heavy artefact reads (spec.md, plan.md, tasks.md). It matches the canonical use case for context: fork — bulk inputs that collapse to a short summary, no need for conversation history. Forking keeps the artefact contents out of the main conversation context, which is the concern raised in #752. Done as a per-command opt-in via FORK_CONTEXT_COMMANDS so other spec-kit commands (which are interactive or have side effects) are unaffected. Refs #752 * claude: apply per-command frontmatter on every skill-generation path argument-hint and fork context were injected only in setup(), so skills produced via post_process_skill_content() directly (presets, extensions) lost them - e.g. a preset overriding speckit-analyze dropped context: fork. Move the per-command injection into post_process_skill_content(), deriving the command stem from the frontmatter name, so all generation paths stay consistent. setup() now just calls post_process_skill_content(). Co-Authored-By: Claude Opus 4.8 (1M context) * claude: drop redundant post-process loop from setup SkillsIntegration.setup() already runs post_process_skill_content() on every SKILL.md before writing it, and that method now applies the argument-hint and fork-context injection. The per-file re-process loop in ClaudeIntegration.setup() was therefore a no-op, so inherit the base setup() directly. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../integrations/claude/__init__.py | 86 ++++++++-------- tests/integrations/test_integration_claude.py | 98 ++++++++++++++++++- 2 files changed, 142 insertions(+), 42 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 6a7d483db3..0df388172d 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from pathlib import Path from typing import Any from ..base import SkillsIntegration -from ..manifest import IntegrationManifest from ..._utils import dump_frontmatter # Mapping of command template stem → argument-hint text shown inline @@ -23,6 +21,15 @@ "taskstoissues": "Optional filter or label for GitHub issues", } +# Per-command frontmatter overrides for skills that should run in a forked +# subagent context. Read-only analysis commands are good candidates: the +# heavy reads (spec/plan/tasks artefacts) collapse to a short summary, +# so isolating them keeps the main conversation context clean. +# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = { + "analyze": {"context": "fork", "agent": "general-purpose"}, +} + class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -148,50 +155,47 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) - def post_process_skill_content(self, content: str) -> str: - """Inject Claude-specific frontmatter flags and hook notes.""" - updated = super().post_process_skill_content(content) - updated = self._inject_frontmatter_flag(updated, "user-invocable") - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") - return updated + @staticmethod + def _skill_stem_from_content(content: str) -> str | None: + """Derive the command stem (e.g. ``analyze``) from a skill's frontmatter. - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Install Claude skills, then inject argument-hints.""" - created = super().setup(project_root, manifest, parsed_options, **opts) - - 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": + Reads the ``name:`` field of the first frontmatter block and strips + the ``speckit-`` prefix. Returns ``None`` when no name is present. + """ + dash_count = 0 + for line in content.splitlines(): + stripped = line.rstrip("\r\n") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break continue + if dash_count == 1 and stripped.startswith("name:"): + name = stripped[len("name:"):].strip().strip('"').strip("'") + if name.startswith("speckit-"): + return name[len("speckit-"):] + return name or None + return None - content_bytes = path.read_bytes() - content = content_bytes.decode("utf-8") + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags, hook notes, and any + per-command frontmatter. - updated = content + Applied by every skill-generation path (setup, presets, extensions), + so command-specific frontmatter (argument-hint, fork context) stays + consistent however the SKILL.md was produced. + """ + updated = super().post_process_skill_content(content) + updated = self._inject_frontmatter_flag(updated, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") - # 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-"):] + stem = self._skill_stem_from_content(updated) + if stem: 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 + fork_config = FORK_CONTEXT_COMMANDS.get(stem) + if fork_config: + for key, value in fork_config.items(): + updated = self._inject_frontmatter_flag(updated, key, value) + return updated diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index e8350114a7..c7ecef95d0 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -10,7 +10,7 @@ from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase, SkillsIntegration -from specify_cli.integrations.claude import ARGUMENT_HINTS +from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from specify_cli.integrations.manifest import IntegrationManifest @@ -536,6 +536,102 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p assert agy.post_process_skill_content(content) == content +class TestClaudeForkContext: + """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" + + def test_analyze_skill_runs_in_forked_subagent(self, tmp_path): + """speckit-analyze must opt into context: fork + agent.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + assert analyze_skill.exists() + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + + def test_other_skills_do_not_fork(self, tmp_path): + """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" + 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: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in FORK_CONTEXT_COMMANDS: + continue + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert "context" not in parsed, ( + f"{f.parent.name}: must not have context frontmatter" + ) + assert "agent" not in parsed, ( + f"{f.parent.name}: must not have agent frontmatter" + ) + + def test_fork_flags_inside_frontmatter(self, tmp_path): + """context/agent must appear in the frontmatter, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3 + frontmatter = parts[1] + body = parts[2] + assert "context: fork" in frontmatter + assert "agent: general-purpose" in frontmatter + assert "context: fork" not in body + assert "agent: general-purpose" not in body + + def test_fork_injection_idempotent(self, tmp_path): + """Re-running setup must not duplicate the fork frontmatter keys.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + assert content.count("context: fork") == 1 + assert content.count("agent: general-purpose") == 1 + + def test_fork_context_injected_via_post_process(self): + """Preset/extension generators call post_process_skill_content directly, + bypassing setup(); fork context must be injected there too.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"] + + def test_post_process_no_fork_for_other_skills(self): + """Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent.""" + i = get_integration("claude") + content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_post_process_fork_idempotent(self): + """Re-running post_process must not duplicate fork frontmatter keys.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + once = i.post_process_skill_content(content) + twice = i.post_process_skill_content(once) + assert once == twice + assert twice.count("context: fork") == 1 + assert twice.count("agent: general-purpose") == 1 + + class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" From a75edec05434daefae67e01ba8134e5a3dd1baaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:49:42 -0500 Subject: [PATCH 03/42] chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#3064) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.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/add-community-extension.lock.yml | 12 ++++++------ .github/workflows/add-community-preset.lock.yml | 12 ++++++------ .github/workflows/bug-assess.lock.yml | 8 ++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release-trigger.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/add-community-extension.lock.yml b/.github/workflows/add-community-extension.lock.yml index 35518ebe62..399c92049a 100644 --- a/.github/workflows/add-community-extension.lock.yml +++ b/.github/workflows/add-community-extension.lock.yml @@ -33,7 +33,7 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -162,7 +162,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false sparse-checkout: | @@ -434,7 +434,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 @@ -1332,7 +1332,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false # --- Threat Detection --- @@ -1658,7 +1658,7 @@ jobs: await main(); - name: Checkout repository (trusted default branch for comment events) if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.repository.default_branch }} token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -1666,7 +1666,7 @@ jobs: fetch-depth: 0 - name: Checkout repository if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/add-community-preset.lock.yml b/.github/workflows/add-community-preset.lock.yml index 9c6e78397d..9aec9914f1 100644 --- a/.github/workflows/add-community-preset.lock.yml +++ b/.github/workflows/add-community-preset.lock.yml @@ -33,7 +33,7 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -162,7 +162,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false sparse-checkout: | @@ -434,7 +434,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 @@ -1332,7 +1332,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false # --- Threat Detection --- @@ -1658,7 +1658,7 @@ jobs: await main(); - name: Checkout repository (trusted default branch for comment events) if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.repository.default_branch }} token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -1666,7 +1666,7 @@ jobs: fetch-depth: 0 - name: Checkout repository if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bug-assess.lock.yml b/.github/workflows/bug-assess.lock.yml index 002446c2d1..d6c84e9aab 100644 --- a/.github/workflows/bug-assess.lock.yml +++ b/.github/workflows/bug-assess.lock.yml @@ -32,7 +32,7 @@ # - GITHUB_TOKEN # # Custom actions used: -# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -161,7 +161,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Checkout .github and .agents folders - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false sparse-checkout: | @@ -430,7 +430,7 @@ jobs: echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" } >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 @@ -1277,7 +1277,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Checkout repository for patch context if: needs.agent.outputs.has_patch == 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false # --- Threat Detection --- diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 34c3f3dc80..33f72006a2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,7 +19,7 @@ jobs: language: [ 'actions', 'python' ] steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 919eed52ef..1d987c7a46 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # Fetch all history for git info diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a4b1bf7d5a..59a02702a1 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 1 diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index dffc2386f3..4b3082f9d5 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b35bc30ec2..89afa864dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e5d03ab48..4d3169197a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 @@ -34,7 +34,7 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 From 46ade96a27539f7e8b9c869d86bab4de80cd8f7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:57:50 -0500 Subject: [PATCH 04/42] Update Multi-Model Review extension to v0.1.2 (#3066) Update multi-model-review extension submitted by @formin to: - extensions/catalog.community.json (version, download_url, updated_at) - docs/community/extensions.md community extensions table Closes #3065 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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 687255b9a3..78b7ceab48 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-17T00:00:00Z", + "updated_at": "2026-06-18T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2063,8 +2063,8 @@ "id": "multi-model-review", "description": "Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review.", "author": "formin", - "version": "0.1.1", - "download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.1.zip", + "version": "0.1.2", + "download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.2.zip", "repository": "https://github.com/formin/multi-model-review", "homepage": "https://github.com/formin/multi-model-review", "documentation": "https://github.com/formin/multi-model-review/blob/main/README.md", @@ -2108,7 +2108,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-04T02:51:52Z", - "updated_at": "2026-06-09T00:00:00Z" + "updated_at": "2026-06-18T00:00:00Z" }, "multi-sites": { "name": "Multi-Sites Spec Kit", From a17a658bbdf652cd2a7f2e3e217303c528616e9f Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Fri, 19 Jun 2026 19:05:42 +0200 Subject: [PATCH 05/42] feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) * feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror. * fix: guard SPECIFY_INIT_DIR with stale core scripts * docs: clarify SPECIFY_FEATURE_DIRECTORY precedence wording * fix: normalize trailing slash in PowerShell SPECIFY_INIT_DIR resolver Resolve-Path preserves a trailing separator from its input, so a SPECIFY_INIT_DIR ending in a slash returned a root that didn't match the bash resolver (whose `cd && pwd` strips it). That broke test_ps_trailing_slash_tolerated on the CI runners, which do have pwsh. Trim it with TrimEndingDirectorySeparator (no-op on a bare root or a path with no trailing separator). Also fix the misleading test comment: the PowerShell mirror runs on the CI ubuntu/windows runners (they ship pwsh), it is not skipped there. * test: normalize bash path expectations on Windows * docs: clarify SPECIFY_INIT_DIR root helpers --- CHANGELOG.md | 3 +- docs/reference/core.md | 4 + .../scripts/bash/create-new-feature-branch.sh | 14 +- .../powershell/create-new-feature-branch.ps1 | 11 +- scripts/bash/common.sh | 41 +- scripts/bash/create-new-feature.sh | 2 +- scripts/powershell/common.ps1 | 42 ++ tests/extensions/git/test_git_extension.py | 67 +++ tests/test_init_dir.py | 467 ++++++++++++++++++ 9 files changed, 644 insertions(+), 7 deletions(-) create mode 100644 tests/test_init_dir.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d9493bec..3fea62fd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ +- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) + ## [0.11.2] - 2026-06-18 ### Changed @@ -1823,4 +1825,3 @@ ### Changed - Update release.yml - diff --git a/docs/reference/core.md b/docs/reference/core.md index c5d9d1b4ec..0b6ad5b14e 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -50,8 +50,12 @@ specify init my-project --integration copilot --preset compliance | Variable | Description | | ----------------- | ------------------------------------------------------------------------ | +| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. | +| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | | `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. | +> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. + ## Check Installed Tools ```bash diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index b6480f40fe..d638b048c9 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -235,9 +235,19 @@ if [ "$_common_loaded" != "true" ]; then exit 1 fi -# Resolve repository root +# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the +# minimal git-common.sh was loaded, or an older core common.sh without the +# resolver was loaded, refuse rather than silently falling back to the wrong root. +if [ -n "${SPECIFY_INIT_DIR:-}" ] && ! type resolve_specify_init_dir >/dev/null 2>&1; then + echo "Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.sh with resolve_specify_init_dir), which were not found." >&2 + exit 1 +fi + +# Resolve repository root. When the core scripts are present, get_repo_root +# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive / +# CI use) and hard-fails on an invalid value with no silent fallback. if type get_repo_root >/dev/null 2>&1; then - REPO_ROOT=$(get_repo_root) + REPO_ROOT=$(get_repo_root) || exit 1 elif git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) elif [ -n "$_PROJECT_ROOT" ]; then diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 47f93e1866..65358df0ba 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -197,7 +197,16 @@ if (-not $commonLoaded) { throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } -# Resolve repository root +# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the +# minimal git-common.ps1 was loaded, or an older core common.ps1 without the +# resolver was loaded, refuse rather than silently falling back to the wrong root. +if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) { + throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found." +} + +# Resolve repository root. When the core scripts are present, Get-RepoRoot +# honors SPECIFY_INIT_DIR (the explicit project override for non-interactive / +# CI use) and hard-fails on an invalid value with no silent fallback. if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { $repoRoot = Get-RepoRoot } elseif ($projectRoot) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 3ea66a652d..70ab89b013 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -24,9 +24,42 @@ find_specify_root() { return 1 } +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute +# project root, or prints an error and returns 1. Strict by design: the path +# must exist and contain .specify/, with no silent fallback to cwd or the +# script-location default (which would silently write to the wrong project). +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. +resolve_specify_init_dir() { + local init_root + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH + # (which would also echo to stdout and corrupt the captured path). + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 + return 1 + fi + if [[ ! -d "$init_root/.specify" ]]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 + return 1 + fi + printf '%s\n' "$init_root" +} + # Get repository root, prioritizing .specify directory # This prevents using a parent repository when spec-kit is initialized in a subdirectory get_repo_root() { + # Explicit project override wins (see resolve_specify_init_dir). + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + resolve_specify_init_dir + return + fi + # First, look for .specify directory (spec-kit's own marker) local specify_root if specify_root=$(find_specify_root); then @@ -119,8 +152,12 @@ _persist_feature_json() { } get_feature_paths() { - local repo_root=$(get_repo_root) - local current_branch=$(get_current_branch) + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in + # get_repo_root propagates as a hard error instead of being masked by `local`. + local repo_root + repo_root=$(get_repo_root) || return 1 + local current_branch + current_branch=$(get_current_branch) # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 023ac6b5a6..c9609764f7 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -123,7 +123,7 @@ clean_branch_name() { SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -REPO_ROOT=$(get_repo_root) +REPO_ROOT=$(get_repo_root) || exit 1 cd "$REPO_ROOT" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35406d3f66..f56fc26577 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -24,9 +24,51 @@ function Find-SpecifyRoot { } } +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root, +# or writes an error and exits 1. Strict by design: the path must exist and +# contain .specify/, with no silent fallback. (An empty string is falsy, so the +# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.) +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. +function Resolve-SpecifyInitDir { + $initDir = $env:SPECIFY_INIT_DIR + # Normalize: relative paths resolve against the current directory. + if (-not [System.IO.Path]::IsPathRooted($initDir)) { + $initDir = Join-Path (Get-Location).Path $initDir + } + $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue + # Resolve-Path also succeeds for files, so check the resolved path is a + # directory; otherwise a file value would slip through to the less accurate + # "not a Spec Kit project" error below. + if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)") + exit 1 + } + # Resolve-Path echoes back any trailing separator from the input; trim it so + # the returned root matches the bash resolver, whose `cd && pwd` never yields + # one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path + # that already has no trailing separator. + $initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path) + if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) { + [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot") + exit 1 + } + return $initRoot +} + # Get repository root, prioritizing .specify directory # This prevents using a parent repository when spec-kit is initialized in a subdirectory function Get-RepoRoot { + # Explicit project override wins (see Resolve-SpecifyInitDir). + if ($env:SPECIFY_INIT_DIR) { + return (Resolve-SpecifyInitDir) + } + # First, look for .specify directory (spec-kit's own marker) $specifyRoot = Find-SpecifyRoot if ($specifyRoot) { diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 431af6df10..3d40aef4ee 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -382,6 +382,36 @@ def test_dry_run(self, tmp_path: Path): assert data.get("DRY_RUN") is True assert not (project / "specs" / data["BRANCH_NAME"]).exists() + def test_specify_init_dir_without_core_errors(self, tmp_path: Path): + """With no core scripts (only git-common.sh loaded), a set SPECIFY_INIT_DIR + hard-errors instead of silently falling back to the walk-up project root.""" + project = _setup_project(tmp_path, git=False) + # Simulate a no-core install: drop core common.sh so only git-common.sh loads. + (project / "scripts" / "bash" / "common.sh").unlink() + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "x", "X feature", + env_extra={"SPECIFY_INIT_DIR": str(project)}, + ) + assert result.returncode != 0 + assert "requires updated Spec Kit core scripts" in result.stderr + + def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path): + """With an older core common.sh, a set SPECIFY_INIT_DIR must hard-error + instead of calling the stale get_repo_root that ignores the override.""" + project = _setup_project(tmp_path, git=False) + (project / "scripts" / "bash" / "common.sh").write_text( + "#!/usr/bin/env bash\nget_repo_root() { pwd; }\n", + encoding="utf-8", + ) + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "x", "X feature", + env_extra={"SPECIFY_INIT_DIR": str(tmp_path / "missing")}, + ) + assert result.returncode != 0 + assert "requires updated Spec Kit core scripts" in result.stderr + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestCreateFeaturePowerShell: @@ -437,6 +467,43 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): assert "BRANCH_NAME" in data assert "FEATURE_NUM" in data + def test_specify_init_dir_without_core_errors(self, tmp_path: Path): + """With no core scripts (only git-common.ps1 loaded), a set SPECIFY_INIT_DIR + hard-errors instead of silently falling back to the walk-up project root.""" + project = _setup_project(tmp_path, git=False) + (project / "scripts" / "powershell" / "common.ps1").unlink() + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1" + env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(project)} + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"], + cwd=project, + capture_output=True, + text=True, + env=env, + ) + assert result.returncode != 0 + assert "requires updated Spec Kit core scripts" in result.stderr + + def test_specify_init_dir_with_stale_core_errors(self, tmp_path: Path): + """With an older core common.ps1, a set SPECIFY_INIT_DIR must hard-error + instead of calling the stale Get-RepoRoot that ignores the override.""" + project = _setup_project(tmp_path, git=False) + (project / "scripts" / "powershell" / "common.ps1").write_text( + "function Get-RepoRoot { return (Get-Location).Path }\n", + encoding="utf-8", + ) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1" + env = {**os.environ, **_GIT_ENV, "SPECIFY_INIT_DIR": str(tmp_path / "missing")} + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "-ShortName", "x", "X feature"], + cwd=project, + capture_output=True, + text=True, + env=env, + ) + assert result.returncode != 0 + assert "requires updated Spec Kit core scripts" in result.stderr + # ── auto-commit.sh Tests ───────────────────────────────────────────────────── diff --git a/tests/test_init_dir.py b/tests/test_init_dir.py new file mode 100644 index 0000000000..1d13cd21f3 --- /dev/null +++ b/tests/test_init_dir.py @@ -0,0 +1,467 @@ +"""Tests for the SPECIFY_INIT_DIR project-root override. + +SPECIFY_INIT_DIR lets a non-interactive / CI caller target a member project from +outside its directory (e.g. a monorepo root) without `cd`. It names the project +root — the directory *containing* `.specify/` — and is strict: it must exist and +contain `.specify/`, otherwise the resolver hard-errors with no silent fallback to +cwd or the git toplevel. + +See proposals/monorepo-support and github/spec-kit discussion #2834. +""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +GIT_CREATE_FEATURE_SH = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh" +) + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") +_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL + + +def _clean_env() -> dict[str, str]: + """Inherited env minus all SPECIFY_* vars, so a developer/CI override + (SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, …) cannot leak into the + subprocess and make these resolution tests flaky.""" + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _make_project(root: Path, name: str) -> Path: + """Create //.specify (the minimal Spec Kit project marker).""" + proj = root / name + (proj / ".specify").mkdir(parents=True) + return proj + + +def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """Source the real common.sh and run a function, from a given cwd/env.""" + return subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && {func_call}'], + cwd=cwd, + capture_output=True, + text=True, + check=False, + env=env, + ) + + +def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """Dot-source the real common.ps1 and run PowerShell, from a given cwd/env.""" + return subprocess.run( + [_PS_EXE, "-NoProfile", "-Command", f'. "{COMMON_PS}"; {script}'], + cwd=cwd, + capture_output=True, + text=True, + check=False, + env=env, + ) + + +def _feature_dir_line(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + return line.split("=", 1)[1].strip("'\"") + return None + + +def _bash_path(path: Path) -> str: + """Return the path format emitted by Bash `pwd`. + + Git-for-Windows Bash reports absolute paths as /c/... while pathlib reports + them as C:\\..., so Bash stdout comparisons need an expected value in Bash's + own path shape. + """ + if os.name != "nt": + return str(path) + + resolved = path.resolve() + path_str = str(resolved).replace("\\", "/") + if resolved.drive.endswith(":"): + return f"/{resolved.drive[0].lower()}{path_str[len(resolved.drive):]}" + return path_str + + +requires_pwsh = pytest.mark.skipif( + not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available" +) + + +# ── Bash: positive cases ──────────────────────────────────────────────────── + + +@requires_bash +def test_valid_path_resolves_from_outside(tmp_path: Path) -> None: + """P1: a valid project path resolves correctly when run from elsewhere.""" + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)} + result = _bash("get_repo_root", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == _bash_path(web) + + +@requires_bash +def test_relative_path_normalized_against_cwd(tmp_path: Path) -> None: + """P2: a relative SPECIFY_INIT_DIR is resolved against the current directory.""" + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"} + result = _bash("get_repo_root", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == _bash_path(web) + + +@requires_bash +def test_trailing_slash_tolerated(tmp_path: Path) -> None: + """P3: a trailing slash is collapsed by normalization.""" + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"} + result = _bash("get_repo_root", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == _bash_path(web) + + +@requires_bash +def test_precedence_over_cwd_project(tmp_path: Path) -> None: + """P4: feature resolution happens inside the *target* project, not cwd. + + cwd is itself a valid Spec Kit project; SPECIFY_INIT_DIR must redirect + resolution to the target project, so a relative SPECIFY_FEATURE_DIRECTORY + normalizes under the target root, not cwd. + """ + cwd_proj = _make_project(tmp_path, "cwd_proj") + (cwd_proj / "specs" / "001-cwd").mkdir(parents=True) + web = _make_project(tmp_path, "web") + + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE_DIRECTORY": "specs/001-demo", + } + result = _bash("get_feature_paths", cwd=cwd_proj, env=env) + assert result.returncode == 0, result.stderr + assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "001-demo") + assert _bash_path(cwd_proj) not in result.stdout + + +@requires_bash +def test_composes_with_feature_directory_override(tmp_path: Path) -> None: + """P5: SPECIFY_INIT_DIR (project axis) composes with SPECIFY_FEATURE_DIRECTORY + (feature axis); a relative feature dir normalizes under the *target* root.""" + web = _make_project(tmp_path, "web") + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE_DIRECTORY": "specs/003-x", + } + result = _bash("get_feature_paths", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "003-x") + + +@requires_bash +def test_composes_with_target_feature_json(tmp_path: Path) -> None: + """P6: the target project's .specify/feature.json is honored.""" + web = _make_project(tmp_path, "web") + (web / ".specify" / "feature.json").write_text( + '{"feature_directory": "specs/004-fj"}' + ) + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)} + result = _bash("get_feature_paths", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "004-fj") + + +# ── Bash: negative / contract cases ───────────────────────────────────────── + + +@requires_bash +def test_unset_preserves_cwd_walk(tmp_path: Path) -> None: + """N1: with SPECIFY_INIT_DIR unset, resolution walks up from cwd as before.""" + web = _make_project(tmp_path, "web") + sub = web / "src" / "deep" + sub.mkdir(parents=True) + result = _bash("get_repo_root", cwd=sub, env=_clean_env()) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == _bash_path(web) + + +@requires_bash +def test_empty_string_treated_as_unset(tmp_path: Path) -> None: + """N2: an empty SPECIFY_INIT_DIR behaves as unset (not as "."). + + Run from a deep subdirectory so the two interpretations diverge: + empty-as-unset walks up to the project root; empty-as-"." would resolve to + the cwd (which has no .specify/) and error. Asserting the walk-up result + genuinely guards against a regression to "." semantics. + """ + web = _make_project(tmp_path, "web") + sub = web / "src" / "deep" + sub.mkdir(parents=True) + env = {**_clean_env(), "SPECIFY_INIT_DIR": ""} + result = _bash("get_repo_root", cwd=sub, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == _bash_path(web) + + +@requires_bash +def test_invalid_init_dir_fails_feature_paths_chain(tmp_path: Path) -> None: + """N5: an invalid SPECIFY_INIT_DIR hard-fails the load-bearing call site + (get_feature_paths), not just get_repo_root — this is what the decl/assign + split guards against (a `local x=$(get_repo_root)` would mask the failure + and emit a FEATURE_DIR under the wrong root). SPECIFY_FEATURE_DIRECTORY is + set so a feature dir *is* resolvable — only the propagation stops a + wrong-root FEATURE_DIR, so a revert to the masked form fails this test.""" + web = _make_project(tmp_path, "web") # valid project at cwd + missing = tmp_path / "does_not_exist" + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(missing), + "SPECIFY_FEATURE_DIRECTORY": "specs/001-x", + } + result = _bash("get_feature_paths", cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert "FEATURE_DIR=" not in result.stdout + + +@requires_bash +def test_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None: + """N3: a non-existent path hard-errors — even from inside a valid project, + proving there is no silent fallback to the cwd walk-up or git root.""" + web = _make_project(tmp_path, "web") # valid project at cwd + missing = tmp_path / "does_not_exist" + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)} + result = _bash("get_repo_root", cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert _bash_path(web) not in result.stdout + + +@requires_bash +def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None: + """N4: a path that exists but lacks .specify/ hard-errors, no fallback.""" + web = _make_project(tmp_path, "web") # valid project at cwd + nodot = tmp_path / "nodot" + nodot.mkdir() + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)} + result = _bash("get_repo_root", cwd=web, env=env) + assert result.returncode != 0 + assert "not a Spec Kit project" in result.stderr + assert _bash_path(web) not in result.stdout + + +@requires_bash +def test_file_path_errors_no_fallback(tmp_path: Path) -> None: + """N4b: a path that exists but is a file (not a directory) hard-errors with + the existing-directory message, with no fallback.""" + web = _make_project(tmp_path, "web") # valid project at cwd + a_file = tmp_path / "afile" + a_file.write_text("x") + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)} + result = _bash("get_repo_root", cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert _bash_path(web) not in result.stdout + + +# ── Bash: bundled Git extension entrypoint ────────────────────────────────── + + +def _bash_git_create( + args: list[str], cwd: Path, env: dict[str, str] +) -> subprocess.CompletedProcess: + """Run the bundled git extension's create-new-feature-branch.sh (the real + /speckit.specify before_specify entrypoint).""" + return subprocess.run( + ["bash", str(GIT_CREATE_FEATURE_SH), *args], + cwd=cwd, + capture_output=True, + text=True, + check=False, + env=env, + ) + + +def _json_line(stdout: str) -> dict | None: + for line in stdout.splitlines(): + line = line.strip() + if line.startswith("{"): + return json.loads(line) + return None + + +@requires_bash +def test_git_ext_create_feature_numbers_from_target(tmp_path: Path) -> None: + """P8: the git extension's feature creation numbers from the SPECIFY_INIT_DIR + project, not the cwd project.""" + (tmp_path / "specs" / "008-cwd").mkdir(parents=True) # cwd project's specs + web = _make_project(tmp_path, "web") + (web / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (web / ".specify" / "templates" / "spec-template.md").write_text("# Spec: [FEATURE]\n") + (web / "specs" / "005-existing").mkdir(parents=True) + + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)} + result = _bash_git_create(["--json", "next thing"], cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + data = _json_line(result.stdout) + assert data is not None and data["FEATURE_NUM"] == "006" # 005 in web → 006, not 009 + + +@requires_bash +def test_git_ext_create_feature_invalid_init_dir_errors(tmp_path: Path) -> None: + """N7: the git extension hard-errors on an invalid SPECIFY_INIT_DIR with no + fallback to the cwd/git-toplevel project.""" + web = _make_project(tmp_path, "web") # valid project at cwd + (web / "specs" / "001-cwd").mkdir(parents=True) + missing = tmp_path / "does_not_exist" + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)} + result = _bash_git_create(["--json", "x"], cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + assert _json_line(result.stdout) is None + + +# ── PowerShell mirror (skipped only when no PowerShell is installed; the CI +# ubuntu/windows runners ship pwsh, so these DO run there) ───────────────── + + +@requires_pwsh +def test_ps_valid_path_resolves_from_outside(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)} + result = _ps("Get-RepoRoot", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(web) + + +@requires_pwsh +def test_ps_relative_path_normalized_against_cwd(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"} + result = _ps("Get-RepoRoot", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(web) + + +@requires_pwsh +def test_ps_trailing_slash_tolerated(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"} + result = _ps("Get-RepoRoot", cwd=tmp_path, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(web) + + +@requires_pwsh +def test_ps_unset_preserves_cwd_walk(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + sub = web / "src" / "deep" + sub.mkdir(parents=True) + result = _ps("Get-RepoRoot", cwd=sub, env=_clean_env()) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(web) + + +@requires_pwsh +def test_ps_precedence_over_cwd_project(tmp_path: Path) -> None: + cwd_proj = _make_project(tmp_path, "cwd_proj") + (cwd_proj / "specs" / "001-cwd").mkdir(parents=True) + web = _make_project(tmp_path, "web") + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE_DIRECTORY": "specs/001-demo", + } + result = _ps( + '$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"', + cwd=cwd_proj, + env=env, + ) + assert result.returncode == 0, result.stderr + # PowerShell Join-Path keeps the embedded "/" of the relative feature dir + # while pathlib uses the platform separator; compare separator-insensitively + # so the Windows CI runner (where pwsh runs) matches. + feature_dir = _feature_dir_line(result.stdout) + assert feature_dir is not None, result.stdout + assert feature_dir.replace("\\", "/") == (web / "specs" / "001-demo").as_posix() + assert str(cwd_proj) not in result.stdout + + +@requires_pwsh +def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + env = { + **_clean_env(), + "SPECIFY_INIT_DIR": str(web), + "SPECIFY_FEATURE_DIRECTORY": "specs/003-x", + } + result = _ps( + '$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"', + cwd=tmp_path, + env=env, + ) + assert result.returncode == 0, result.stderr + # Separator-insensitive: PowerShell Join-Path keeps the embedded "/". + feature_dir = _feature_dir_line(result.stdout) + assert feature_dir is not None, result.stdout + assert feature_dir.replace("\\", "/") == (web / "specs" / "003-x").as_posix() + + +@requires_pwsh +def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + sub = web / "src" / "deep" + sub.mkdir(parents=True) + env = {**_clean_env(), "SPECIFY_INIT_DIR": ""} + result = _ps("Get-RepoRoot", cwd=sub, env=env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(web) + + +@requires_pwsh +def test_ps_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + missing = tmp_path / "does_not_exist" + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)} + result = _ps("Get-RepoRoot", cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr + + +@requires_pwsh +def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None: + web = _make_project(tmp_path, "web") + nodot = tmp_path / "nodot" + nodot.mkdir() + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)} + result = _ps("Get-RepoRoot", cwd=web, env=env) + assert result.returncode != 0 + assert "not a Spec Kit project" in result.stderr + + +@requires_pwsh +def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None: + """A file path resolves via Resolve-Path but is not a directory; the resolver + must reject it with the existing-directory message, not not-a-project.""" + web = _make_project(tmp_path, "web") + a_file = tmp_path / "afile" + a_file.write_text("x") + env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)} + result = _ps("Get-RepoRoot", cwd=web, env=env) + assert result.returncode != 0 + assert "does not point to an existing directory" in result.stderr From fd42fb15f4525aeffcd547ccc3c2436bcc6d21b6 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 19 Jun 2026 22:38:51 +0500 Subject: [PATCH 06/42] fix(taskstoissues): skip tasks that already have a GitHub issue (#2992) * fix(taskstoissues): skip tasks that already have a GitHub issue Re-running /speckit-taskstoissues created a duplicate issue for every task because the command never checked for existing ones. Add a deduplication step before issue creation: list the repo's issues (state all) via the GitHub MCP server, collect the task IDs already present in issue titles, and skip any task that already has a matching issue. Issue titles are now prefixed with the task ID (e.g. T001:) so they can be matched on later runs, and list_issues is added to the command's MCP tools. Fixes #2968 * fix(taskstoissues): correct list_issues usage and issue title format Address Copilot review: - list_issues has no 'all' state; omitting state returns both open and closed issues. Use cursor-based pagination (after/endCursor) to fetch every page before building the dedup set. - task lines already start with their ID, so reuse the task text as the issue title instead of prefixing the ID again (which produced 'T001: T001 ...'). * fix(taskstoissues): match task IDs anywhere in titles and define one canonical title Address follow-up Copilot review: - task lines start with a markdown checkbox (- [ ] T001 ...), so the creation step now strips the checkbox and [P]/[US#] markers and writes a single canonical title 'T001: '. - dedup now scans each issue title for a T token anywhere in the title, so existing issues titled 'T001 ...', 'T001: ...' or '[T001] ...' are all matched. * fix(taskstoissues): use word-boundary task ID match and request perPage 100 Address Copilot review: - match issue titles against \bT\d{3}\b so tokens like ST001 or T0010 are not matched by mistake (task IDs are T + 3 digits). - request perPage: 100 on list_issues to reduce pagination calls. * fix(taskstoissues): bound issue pagination to the tasks being processed Address Copilot review: extract the task IDs from tasks.md first, then paginate list_issues only until every task ID has been matched (or pages run out), instead of fetching the repo's entire issue history. Keeps the call count bounded on repos with large issue backlogs. --- templates/commands/taskstoissues.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index b24e84ee14..b3093baa03 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -1,6 +1,6 @@ --- description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. -tools: ['github/github-mcp-server/issue_write'] +tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_write'] scripts: sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks @@ -62,7 +62,10 @@ git config --get remote.origin.url > [!CAUTION] > ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL -1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. +1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by three digits, e.g. `T001`). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3}\b` (word boundaries so tokens like `ST001` or `T0010` are not matched by mistake; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. +1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: `, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`). + - **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`). + - Only create issues for tasks that do not yet have a matching issue. > [!CAUTION] > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL From d9370d909dfecd7e0d0a4e23dcaa321c8d72760c Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Fri, 19 Jun 2026 19:41:02 +0200 Subject: [PATCH 07/42] fix: isolate per-extension failures so one bad extension can't drop the rest (#2951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: isolate per-extension failures in register_enabled_extensions_for_agent The per-extension loop had no error isolation: if registering one enabled extension raised (e.g. an OSError writing a command file), the loop aborted and the exception propagated, so every subsequent enabled extension was silently skipped. Callers wrap the whole call in a single best-effort try/except, so the wholesale abort surfaced as one warning while the command still exited 0 — leaving the agent with only a prefix of its extensions. Wrap the per-extension body in try/except: warn (naming the extension) and continue, so one bad extension can no longer drop the others. Add a regression test that forces the first-iterated extension to raise and asserts the rest still register. Closes #2950 * fix(extensions): preserve command registry when skills fail * fix: clarify skill registration warning --- src/specify_cli/extensions.py | 92 +++++++++++++++++++++++----------- tests/test_extension_skills.py | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 28 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index ecc26fa877..42ba2fe888 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1716,37 +1716,73 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: continue ext_dir = self.extensions_dir / ext_id - updates: Dict[str, Any] = {} - if agent_config and not skills_mode_active: - registered = registrar.register_commands_for_agent( - agent_name, manifest, ext_dir, self.project_root - ) - registered_commands = metadata.get("registered_commands", {}) - if not isinstance(registered_commands, dict): - registered_commands = {} - new_registered = copy.deepcopy(registered_commands) - if registered: - new_registered[agent_name] = registered + # Isolate per-extension failures: one extension that fails to + # register (e.g. an OSError writing a command file) must not abort + # registration of the remaining enabled extensions for this agent. + try: + updates: Dict[str, Any] = {} + + if agent_config and not skills_mode_active: + registered = registrar.register_commands_for_agent( + agent_name, manifest, ext_dir, self.project_root + ) + registered_commands = metadata.get("registered_commands", {}) + if not isinstance(registered_commands, dict): + registered_commands = {} + new_registered = copy.deepcopy(registered_commands) + if registered: + new_registered[agent_name] = registered + else: + # Registration returned empty list (e.g., corrupted + # manifest pointing at missing command files). Clear + # stale entry so later cleanup doesn't try to remove + # files that were never written. + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered + + try: + registered_skills = self._register_extension_skills(manifest, ext_dir) + except Exception as skills_err: + # Skills are a companion artifact. If command registration + # already succeeded, still persist it so later cleanup can + # find those command files. + from . import _print_cli_warning + + _print_cli_warning( + "register extension skills for", + "extension", + ext_id, + skills_err, + continuing=( + "Continuing with available registration results for this " + "extension and the remaining extensions." + ), + ) else: - # Registration returned empty list (e.g., corrupted - # manifest pointing at missing command files). Clear - # stale entry so later cleanup doesn't try to remove - # files that were never written. - new_registered.pop(agent_name, None) - if new_registered != registered_commands: - updates["registered_commands"] = new_registered - - registered_skills = self._register_extension_skills(manifest, ext_dir) - if registered_skills: - existing_skills = self._valid_name_list( - metadata.get("registered_skills", []) - ) - merged_skills = list(dict.fromkeys(existing_skills + registered_skills)) - updates["registered_skills"] = merged_skills + if registered_skills: + existing_skills = self._valid_name_list( + metadata.get("registered_skills", []) + ) + merged_skills = list(dict.fromkeys(existing_skills + registered_skills)) + updates["registered_skills"] = merged_skills - if updates: - self.registry.update(ext_id, updates) + if updates: + self.registry.update(ext_id, updates) + except Exception as ext_err: + # Best-effort per extension: warn and move on so a single bad + # extension cannot silently drop the others. See #2950. + from . import _print_cli_warning + + _print_cli_warning( + "register extension artifacts for", + "extension", + ext_id, + ext_err, + continuing="Continuing with the remaining extensions.", + ) + continue def list_installed(self) -> List[Dict[str, Any]]: """List all installed extensions with metadata. diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index bc95917150..8ef675e51e 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1036,6 +1036,93 @@ def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration( assert metadata["registered_skills"] == [] assert (project_dir / ".github" / "agents").is_dir() + def test_one_failing_extension_does_not_abort_the_rest( + self, project_dir, temp_dir, monkeypatch + ): + """A single failing extension must not block registration of the others. + + Regression for #2950: ``register_enabled_extensions_for_agent`` iterates + enabled extensions; before the per-extension isolation, the first one + that raised (e.g. an OSError writing a command file) aborted the loop and + the exception propagated, so every later extension was silently skipped. + """ + from specify_cli.extensions import CommandRegistrar + + _create_init_options(project_dir, ai="claude", ai_skills=False) + manager = ExtensionManager(project_dir) + # Two enabled extensions; the first one iterated ("aaa-fail") will raise. + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="aaa-fail"), "0.1.0", + register_commands=False, + ) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="bbb-ok"), "0.1.0", + register_commands=False, + ) + + original = CommandRegistrar.register_commands_for_agent + + def flaky(self, agent_name, manifest, ext_dir, project_root, link_outputs=False): + if manifest.id == "aaa-fail": + raise OSError("simulated command-file write failure") + return original( + self, agent_name, manifest, ext_dir, project_root, + link_outputs=link_outputs, + ) + + monkeypatch.setattr(CommandRegistrar, "register_commands_for_agent", flaky) + + # Must not propagate, despite the first extension failing. + manager.register_enabled_extensions_for_agent("claude") + + # The healthy extension was still registered for the agent... + ok_meta = manager.registry.get("bbb-ok") + assert "claude" in ok_meta["registered_commands"], ( + "a later extension must still register after an earlier one fails (#2950)" + ) + # ...and the failing one was not. + fail_meta = manager.registry.get("aaa-fail") + assert "claude" not in fail_meta.get("registered_commands", {}) + + def test_skill_registration_failure_preserves_registered_commands( + self, project_dir, temp_dir, monkeypatch, capsys + ): + """Persist successful command registration even if skills fail. + + If command files are written but skill generation raises, the command + registry must still be updated so later unregister/cleanup can find the + command files. + """ + _create_init_options(project_dir, ai="claude", ai_skills=False) + manager = ExtensionManager(project_dir) + manager.install_from_directory( + _create_extension_dir(temp_dir, ext_id="skill-fail"), "0.1.0", + register_commands=False, + ) + + def fail_skills(self, manifest, ext_dir, link_outputs=False): + raise OSError("simulated skill directory failure") + + monkeypatch.setattr( + ExtensionManager, "_register_extension_skills", fail_skills + ) + + manager.register_enabled_extensions_for_agent("claude") + + metadata = manager.registry.get("skill-fail") + assert metadata is not None + assert metadata["registered_commands"] == { + "claude": [ + "speckit.skill-fail.hello", + "speckit.skill-fail.world", + ] + } + assert metadata["registered_skills"] == [] + + captured = capsys.readouterr() + assert "register extension skills for extension 'skill-fail'" in captured.out + assert "Continuing with available registration results" in captured.out + def test_existing_agent_command_path_file_is_not_detected( self, project_dir, temp_dir ): From 4ef8f62db544569d4d9e9ecd5be9104a61792921 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:19:30 -0500 Subject: [PATCH 08/42] docs: strengthen agent disclosure to cover commits and per-round comments (#3071) Expand the AGENTS.md PR-review section into a continuous disclosure policy. Disclosure is no longer a one-time PR-body event: - Commits: require an Assisted-by: (autonomous|supervised) trailer on every agent-authored commit; ban hiding agent authorship behind the operator's git identity; preserve tool-generated Co-authored-by lines. - Comments: re-state agent identity each review round. - Anti-patterns: forbid replying "Done"/pushing fixes seconds after a review trigger without disclosure, and claiming human review for automated commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4f0c9912a8..d21db4f426 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,15 +423,37 @@ When an issue exists, include its number immediately after the prefix — this i --- -## Responding to PR Review Comments +## Agent Disclosure for PRs, Comments, and Commits + +Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship. + +### Commits + +- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example: + + ``` + Assisted-by: GitHub Copilot (model: , autonomous) + ``` + + Use `supervised` instead of `autonomous` only when a human actually authored or line-by-line reviewed the change before it was committed. +- **Never push solo-authored commits that hide agent authorship behind the operator's git identity.** If an agent generated the change, the trailer must say so even when the commit is attributed to a human account. +- Preserve any tool-generated `Co-authored-by:` trailers (e.g. Copilot Autofix) — do not strip them to make a commit look hand-written. + +### Comments - If you are an agent working on behalf of a human, **disclose your identity in your PR comment** — name the agent (and model, if applicable) and the human you are acting for (e.g., "Posted on behalf of @user by GitHub Copilot (model: <name-if-known>)"). +- **Re-state agent identity in each review-round summary comment.** A prior PR-body disclosure does not cover later comments or commits. - Post **one** top-level summary comment per review round listing what changed and the commit SHA. Do not reply on every individual comment. - Reply inline only when context is needed (disagreement, deferral, non-obvious fix). Keep it to a sentence or two. - **Never click "Resolve conversation"** — that belongs to the reviewer or PR author. - No emoji, no celebratory framing, no checklist mirroring the reviewer's items, no restating what the reviewer wrote. - Re-request review once per round (when all feedback is addressed), not after every intermediate push. +### Anti-patterns (do not do these) + +- **Do not** reply "Done" or push a "fix" within seconds/minutes of a review event without disclosing that the response or commit was agent-generated. Speed of turnaround is not a substitute for attestation — a near-instant tested code change is itself a signal of automation and must be disclosed as such. +- **Do not** claim "reviewed, tested, and understood by me" for commits that were authored and pushed automatically in response to a review trigger. If the loop is automated, disclose it as automated. + --- ## Common Pitfalls From c2204871eccdbf86b27e0b29b1d7004b6eb572a0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:24:23 -0500 Subject: [PATCH 09/42] chore: release 0.11.3, begin 0.11.4.dev0 development (#3072) * chore: bump version to 0.11.3 * chore: begin 0.11.4.dev0 development * 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 | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fea62fd7b..66d82ebe3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,20 @@ +## [0.11.3] - 2026-06-19 + +### Changed + +- docs: strengthen agent disclosure to cover commits and per-round comments (#3071) +- fix: isolate per-extension failures so one bad extension can't drop the rest (#2951) +- fix(taskstoissues): skip tasks that already have a GitHub issue (#2992) - feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) +- Update Multi-Model Review extension to v0.1.2 (#3066) +- chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#3064) +- feat(claude): run /analyze in a forked subagent (#2511) +- fix: count worktree branches in git extension numbering (#3054) +- Add Token Economy extension to community catalog (#3049) +- chore: release 0.11.2, begin 0.11.3.dev0 development (#3059) ## [0.11.2] - 2026-06-18 diff --git a/pyproject.toml b/pyproject.toml index e9aff41f65..8e0dfc3191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.3.dev0" +version = "0.11.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 487af97864901462874f18f1c7f8d8adec0b7ddd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:07:20 -0500 Subject: [PATCH 10/42] feat: add `specify bundle` command (#3070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: dogfood Spec Kit — bundler SDD artifacts + constitution Scaffold Spec Kit (--integration copilot) and run the full SDD workflow against the `specify bundle` subcommand feature: - spec.md (4 user stories, 31 FRs, 8 success criteria) + clarifications - plan.md, research.md, data-model.md, contracts/, quickstart.md - tasks.md (43 dependency-ordered tasks, organized by user story) - Spec Kit Constitution v1.0.0 (code quality, testing, UX, performance, dependency/security principles) derived from deep codebase analysis - plan Constitution Check + tasks grounded against the ratified principles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): add `specify bundle` subcommand for role-based setups Implements the Spec Kit Bundler as a `specify bundle ...` subcommand group that calls existing primitive machinery in-process with zero new dependencies, per the v1.0.0 constitution (Principles I-V). Adds the `specify_cli.bundler` package (models, services, lib helpers) and the `commands/bundle` Typer group wiring search, info, list, install, update, remove, validate, build, init, and catalog list/add/remove (with --json and --offline). Includes manifest/catalog schemas, version + integration-clash gating, discovery-only refusal, idempotent install with atomic rollback, non-collateral removal, and offline-first catalog resolution. Ships an 82-test suite (contract/unit/integration), four sample role bundles (product-manager, business-analyst, security-researcher, developer), README "Bundles" docs, and an AGENTS.md pitfall on the test-venv gotcha. Marks tasks T001-T043 complete and records follow-ups T044 (live in-process primitive dispatch) and T045 (install from a local artifact path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(contributing): document running the full test suite via project .venv Add a "Running the full test suite" subsection under Automated checks covering `uv pip install -e ".[test]"` + `.venv/bin/python -m pytest`, with the shared/global editable-install contamination caveat that mirrors the AGENTS.md pitfall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): wire real in-process primitive install + local-artifact install Closes the two follow-ups left after the initial bundler landing. T044 — DefaultPrimitiveInstaller now performs real installs through existing machinery instead of raising "use the primitive command" errors: - presets/extensions install via their reusable managers (install_from_directory / install_from_zip); bundled assets install fully offline, catalog assets are fetched only when the network is allowed. - workflows/steps delegate to the existing `workflow add` / `workflow step add` command callables in-process (project root as cwd), avoiding any duplicated download/validation logic (Principle I). - `--offline` is threaded through DefaultPrimitiveInstaller(allow_network=…) so network-only kinds refuse with an actionable message rather than silently reaching out. T045 — `specify bundle install` now accepts a local path (a built .zip artifact, a bundle directory, or a bundle.yml) and installs directly without consulting the catalog stack; bundle-ids still resolve via the stack. Adds 13 tests (routing, offline gating, local-source resolution, and an end-to-end offline build → install → list → remove of the bundled agent-context extension). Bundler suite: 95 passing; ruff clean. Marks T044 and T045 complete in tasks.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): append Phase 8 convergence tasks from converge assessment Ran the converge command: assessed the codebase against spec.md, plan.md, tasks.md, and the v1.0.0 constitution. Appended 7 traceable gap-closure tasks (T046–T052) as a new "Phase 8: Convergence" section. Append-only — no existing tasks were modified and no application code was changed. Findings: 1 CRITICAL (Constitution III — bundle group undocumented under docs/reference/), 3 HIGH (FR-005/SC-007 validate references; FR-009/SC-002 info expansion; FR-012 install-time init), 3 MEDIUM (FR-013 integration precedence; FR-020 surface overlaps; FR-028 update refresh). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement Phase 8 convergence tasks (T046–T052) Close the gaps the converge command found between the bundler spec/plan/ constitution and the code: - T046: add docs/reference/bundles.md documenting the full `specify bundle` command group; link it from docs/reference/overview.md (Constitution III). - T047: wire a reference checker into `bundle validate` (services/references.py); online runs fail and name unresolved component references, offline runs warn. - T048: expand `bundle info` to enumerate the full component set (versions, preset priority/strategy) plus the bundle integration — info == install. - T049/T050: `bundle install`/`bundle init` now scaffold an uninitialized project via the existing `specify init` machinery, choosing the integration by precedence (override → bundle-declared → Copilot + OS default script type). - T051: surface foreseeable component overlaps during info and install. - T052: `bundle update` refreshes already-installed components via a new refresh path in install_bundle, preserving primitive-level overrides. Adds unit/contract/integration coverage (107 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * converge: append Phase 9 (T053) — surface bundle trust indicator Re-run of converge after Phase 8. The seven Phase 8 tasks are verified closed. One residual partial gap remains: the `verified`/trust indicator (FR-010, FR-027) is exposed only in `bundle info --json`, absent from `bundle search` (the primary discovery surface) and `bundle info` text. Appended as a single new task for implement to complete. Append-only; no code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement T053 — surface bundle trust indicator in discovery `bundle search` (text + JSON) and `bundle info` (text + JSON) now expose each catalog entry's verification/trust level (verified vs community), so users can judge a bundle's trust before installing, per FR-010 / FR-027. Previously `verified` was only present in `bundle info --json`. Adds contract coverage; 108 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: dogfood Spec Kit — bundler SDD artifacts + constitution Scaffold Spec Kit (--integration copilot) and run the full SDD workflow against the `specify bundle` subcommand feature: - spec.md (4 user stories, 31 FRs, 8 success criteria) + clarifications - plan.md, research.md, data-model.md, contracts/, quickstart.md - tasks.md (43 dependency-ordered tasks, organized by user story) - Spec Kit Constitution v1.0.0 (code quality, testing, UX, performance, dependency/security principles) derived from deep codebase analysis - plan Constitution Check + tasks grounded against the ratified principles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): add `specify bundle` subcommand for role-based setups Implements the Spec Kit Bundler as a `specify bundle ...` subcommand group that calls existing primitive machinery in-process with zero new dependencies, per the v1.0.0 constitution (Principles I-V). Adds the `specify_cli.bundler` package (models, services, lib helpers) and the `commands/bundle` Typer group wiring search, info, list, install, update, remove, validate, build, init, and catalog list/add/remove (with --json and --offline). Includes manifest/catalog schemas, version + integration-clash gating, discovery-only refusal, idempotent install with atomic rollback, non-collateral removal, and offline-first catalog resolution. Ships an 82-test suite (contract/unit/integration), four sample role bundles (product-manager, business-analyst, security-researcher, developer), README "Bundles" docs, and an AGENTS.md pitfall on the test-venv gotcha. Marks tasks T001-T043 complete and records follow-ups T044 (live in-process primitive dispatch) and T045 (install from a local artifact path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(contributing): document running the full test suite via project .venv Add a "Running the full test suite" subsection under Automated checks covering `uv pip install -e ".[test]"` + `.venv/bin/python -m pytest`, with the shared/global editable-install contamination caveat that mirrors the AGENTS.md pitfall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bundler): wire real in-process primitive install + local-artifact install Closes the two follow-ups left after the initial bundler landing. T044 — DefaultPrimitiveInstaller now performs real installs through existing machinery instead of raising "use the primitive command" errors: - presets/extensions install via their reusable managers (install_from_directory / install_from_zip); bundled assets install fully offline, catalog assets are fetched only when the network is allowed. - workflows/steps delegate to the existing `workflow add` / `workflow step add` command callables in-process (project root as cwd), avoiding any duplicated download/validation logic (Principle I). - `--offline` is threaded through DefaultPrimitiveInstaller(allow_network=…) so network-only kinds refuse with an actionable message rather than silently reaching out. T045 — `specify bundle install` now accepts a local path (a built .zip artifact, a bundle directory, or a bundle.yml) and installs directly without consulting the catalog stack; bundle-ids still resolve via the stack. Adds 13 tests (routing, offline gating, local-source resolution, and an end-to-end offline build → install → list → remove of the bundled agent-context extension). Bundler suite: 95 passing; ruff clean. Marks T044 and T045 complete in tasks.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): append Phase 8 convergence tasks from converge assessment Ran the converge command: assessed the codebase against spec.md, plan.md, tasks.md, and the v1.0.0 constitution. Appended 7 traceable gap-closure tasks (T046–T052) as a new "Phase 8: Convergence" section. Append-only — no existing tasks were modified and no application code was changed. Findings: 1 CRITICAL (Constitution III — bundle group undocumented under docs/reference/), 3 HIGH (FR-005/SC-007 validate references; FR-009/SC-002 info expansion; FR-012 install-time init), 3 MEDIUM (FR-013 integration precedence; FR-020 surface overlaps; FR-028 update refresh). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement Phase 8 convergence tasks (T046–T052) Close the gaps the converge command found between the bundler spec/plan/ constitution and the code: - T046: add docs/reference/bundles.md documenting the full `specify bundle` command group; link it from docs/reference/overview.md (Constitution III). - T047: wire a reference checker into `bundle validate` (services/references.py); online runs fail and name unresolved component references, offline runs warn. - T048: expand `bundle info` to enumerate the full component set (versions, preset priority/strategy) plus the bundle integration — info == install. - T049/T050: `bundle install`/`bundle init` now scaffold an uninitialized project via the existing `specify init` machinery, choosing the integration by precedence (override → bundle-declared → Copilot + OS default script type). - T051: surface foreseeable component overlaps during info and install. - T052: `bundle update` refreshes already-installed components via a new refresh path in install_bundle, preserving primitive-level overrides. Adds unit/contract/integration coverage (107 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * converge: append Phase 9 (T053) — surface bundle trust indicator Re-run of converge after Phase 8. The seven Phase 8 tasks are verified closed. One residual partial gap remains: the `verified`/trust indicator (FR-010, FR-027) is exposed only in `bundle info --json`, absent from `bundle search` (the primary discovery surface) and `bundle info` text. Appended as a single new task for implement to complete. Append-only; no code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement T053 — surface bundle trust indicator in discovery `bundle search` (text + JSON) and `bundle info` (text + JSON) now expose each catalog entry's verification/trust level (verified vs community), so users can judge a bundle's trust before installing, per FR-010 / FR-027. Previously `verified` was only present in `bundle info --json`. Adds contract coverage; 108 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review — annotations, Windows paths, HTTPS, errors, reproducible builds Resolves automated review feedback on github/spec-kit#3070: - validator: drop redundant string-quoting on ReferenceChecker's `str | None` return so the annotation evaluates as a real union under `from __future__ import annotations`. - adapters: normalize Windows drive-letter paths (e.g. C:\...) to the local-file branch so offline file catalogs resolve on Windows. - adapters: enforce HTTPS (HTTP only for localhost) and require a host on remote catalog URLs before any network call, mirroring specify_cli.catalogs URL validation (MITM/downgrade protection). - adapters: pass `origin` to loads_json for local files and HTTP payloads so JSON parse errors name the real source instead of . - manifest: parse component `priority` defensively, raising an actionable BundlerError on non-integer values instead of a raw ValueError. - packager: write zip members with a fixed timestamp + permissions so identical inputs yield byte-for-byte identical artifacts (genuinely reproducible builds), and strengthen the determinism test accordingly. Adds regression tests for priority validation, plain-HTTP/host rejection, and byte-level artifact reproducibility (111 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 2 — nested output dir + file:// URLs - packager: when --output points inside the bundle directory, exclude the whole output subtree from collection so previously-built artifacts are never re-packaged (prevents broken reproducibility and unbounded growth). - adapters: resolve file:// catalog URLs via url2pathname and preserve netloc, so Windows file URLs (file:///C:/...) and UNC shares (file://server/share) resolve correctly instead of dropping the host or producing /C:/x. Adds regression tests for nested-output exclusion and file:// resolution (113 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 3 — discovery UX + hardening - bundle search/info: fall back to the built-in/user catalog stack instead of requiring a Spec Kit project, so discovery works in a fresh directory (and the README/quickstart examples now match actual behavior). install still auto-initializes a project as before. - packager: traverse with os.walk(followlinks=False) and prune symlinked directories before descending, so a symlink-to-dir can no longer pull in out-of-tree files (which previously turned "skip symlinks" into a hard ensure_within() failure and did extra filesystem work). - records: parse contributed-component priority defensively, raising an actionable BundlerError on a corrupt records file instead of leaking a raw ValueError/traceback. - installer: give install_bundle's manifest parameter an explicit BundleManifest | None type for a clearer, safer service API. Adds regression tests for project-less search/info, symlinked-dir pruning, and corrupt-priority records (117 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 4 + markdownlint exclusions Review fixes: - bundle info: expand the manifest regardless of install policy so discovery-only bundles remain inspectable (only install is refused). - _download_manifest: handle local .zip download_url by extracting bundle.yml (via _local_manifest_source), and add a real remote HTTPS fetch path using the shared authenticated, redirect-validated open_url client (HTTPS enforced on the initial URL and every redirect; offline still refuses). - _run_init: thread the --offline flag through to the init callback so `bundle install/init --offline` never performs network init. - conflict.ConflictReport: use field(default_factory=list) and drop the None + __post_init__ workaround. - CatalogSource.from_dict: parse priority defensively, raising an actionable BundlerError naming the source + offending value instead of a raw ValueError. markdownlint: - Exclude .specify/, .github/, and specs/ (and their subdirectories) from markdownlint so the in-flight dogfooding scaffolding doesn't trip the linter. Adds regression tests for discovery-only info, local-zip download_url, and non-integer catalog priority (120 bundler tests pass; ruff clean; the PR's own markdown lints clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address PR review round 5 + ignore generated files in whitespace check Review fixes: - packager: exclude any prior build artifact for this bundle (matching -*.zip), not just the current output path, so older artifacts next to bundle.yml are never re-packaged. - docs(bundles): correct the note — `search` and `info` work without a project (they fall back to the built-in/user catalog stack); only list/update/remove/ catalog require an initialized project. CI / generated files: - .gitattributes: mark the generated dogfooding scaffolding (.specify/**, the speckit .github agent/prompt files, copilot-instructions.md, specs/**) with -whitespace so `git diff --check` (the Lint workflow's whitespace gate) stops flagging emitted trailing whitespace. These files are produced by `specify init` and are scrubbed before merge. Adds a regression test for prior-artifact exclusion (121 bundler tests pass; ruff clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): collision-resistant catalog ids, canonical local paths, explicit uninstalled result Addresses review round 6 (PR #3070): - catalog_config._derive_id now combines host label with the URL path stem so multiple catalogs from the same host get distinct, stable default ids. - add_source canonicalizes local file paths to absolute before persisting, so project config no longer depends on the caller's cwd. - InstallResult gains a dedicated `uninstalled` list; remove_bundle no longer overloads `installed` for removals, and the CLI prints from `uninstalled`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): confine config writes, guard indeterminate integration, fix validate docs Addresses review round 7 (PR #3070): - save_records and catalog_config._write now pass within=project_root to dump_json/dump_yaml, refusing symlinked .specify paths that escape the project (defense-in-depth, matching the rest of the codebase). - resolve_install_plan now fails when a bundle pins an integration but the project's active integration cannot be determined and no explicit --integration override was given, instead of silently adopting the bundle's required integration (FR-019 guard). CLI passes integration_explicit. - docs/reference/bundles.md: corrected the validate semantics to describe the actual best-effort online behavior (unreachable catalogs warn, not fail). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): Windows path handling + review round 8 hardening Fix Windows CI failures: - is_safe_relpath now rejects POSIX-absolute (/abs) and Windows drive-absolute (C:\x, UNC) paths on every OS, instead of passing them through on Windows where os.path.isabs('/abs') is False and Path('/abs').parts yields '\\'. - _download_manifest treats a Windows drive-letter download_url (C:\bundle.yml, which urlparse reads as scheme 'c') as a local file, fixing the empty component set in `bundle info` on Windows. Address review round 8 (PR #3070): - Bundled workflows now install under --offline (locate via _locate_bundled_workflow) instead of being refused unconditionally. - bundle update preserves the original installed_at timestamp on refresh (import find_record; reuse the existing record's timestamp). - _derive_id lowercases the host label so 'Example.com' and 'example.com' produce the same deterministic id. - CatalogEntry.from_dict validates 'tags' is a list and 'verified' is a real boolean, raising BundlerError on invalid untrusted shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): normalize SemVer prerelease spellings before version parsing Addresses review round 9 (PR #3070): parse_version and is_semver now apply the same prerelease normalization (mirroring specify_cli._version._normalize_tag) so SemVer spellings like 1.2.3-rc1 / 1.2.3-alpha1 validate and compare consistently across is_semver, parse_version, and satisfies. Leading 'v' is also stripped. Keeps the manifest validator and constraint checks in agreement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): no collateral removal + enforce manifest-pinned versions Addresses review round 10 (PR #3070): - install_bundle records only the components this bundle actually contributed: freshly-installed components, plus pre-existing ones already owned by this bundle (refresh) or a sibling bundle (shared/refcounted). A component that is installed on disk but tracked by no bundle was installed independently and is no longer attributed, so `bundle remove` won't uninstall it (FR-022). - preset/extension/workflow install paths now verify the active catalog's advertised version matches the manifest-pinned component.version before downloading/installing, raising BundlerError on mismatch so bundles stay reproducible. When a catalog advertises no version the pin can't be enforced and installation proceeds. Added regression tests: independent pre-existing component survives removal; version-mismatch refusal (helper + workflow path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892) * feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root Resolve an explicit SPECIFY_INIT_DIR project override once in the core get_repo_root / Get-RepoRoot, so a non-interactive / CI caller can target a member project (the directory containing .specify/) from a monorepo root without cd. Strict by design: the path must exist and contain .specify/, otherwise it hard-errors with no silent fallback. - Single resolver in core; the git feature-branch script inherits it by sourcing core, with no per-extension copies. - PS resolver verifies the resolved path is a directory (Resolve-Path also succeeds for files) so a file value errors as "not an existing directory". - get_feature_paths splits decl/assignment so a SPECIFY_INIT_DIR failure propagates instead of being masked by `local`. - create-new-feature-branch: when core is absent (only git-common loaded) and SPECIFY_INIT_DIR is set, hard-error rather than silently using the git root. - Document SPECIFY_INIT_DIR and SPECIFY_FEATURE_DIRECTORY in the core reference. - Tests for valid/relative/trailing-slash/file/missing/no-.specify targets, feature-axis composition, the no-core guard, and a PowerShell mirror. * fix: guard SPECIFY_INIT_DIR with stale core scripts * docs: clarify SPECIFY_FEATURE_DIRECTORY precedence wording * fix: normalize trailing slash in PowerShell SPECIFY_INIT_DIR resolver Resolve-Path preserves a trailing separator from its input, so a SPECIFY_INIT_DIR ending in a slash returned a root that didn't match the bash resolver (whose `cd && pwd` strips it). That broke test_ps_trailing_slash_tolerated on the CI runners, which do have pwsh. Trim it with TrimEndingDirectorySeparator (no-op on a bare root or a path with no trailing separator). Also fix the misleading test comment: the PowerShell mirror runs on the CI ubuntu/windows runners (they ship pwsh), it is not skipped there. * test: normalize bash path expectations on Windows * docs: clarify SPECIFY_INIT_DIR root helpers * chore: sync dogfooded .specify core scripts with SPECIFY_INIT_DIR Mirror the SPECIFY_INIT_DIR resolver (resolve_specify_init_dir in common.sh) into the committed dogfooding .specify/scripts/bash copies so the git extension's create-new-feature-branch.sh finds an up-to-date common.sh instead of failing with "requires updated Spec Kit core scripts". Fixes the test_init_dir.py CI failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): harden remote catalog fetch and config parsing - adapters: route catalog HTTP fetches through the shared authenticated client (authentication.http.open_url) so auth.json tokens apply and the Authorization header is stripped on cross-host/downgrade redirects. Reject any redirect that leaves HTTPS via a redirect_validator and re-validate the final URL after redirects, closing the urlopen auto-redirect MITM/downgrade gap. - catalog_config._read: raise an actionable BundlerError when the config top level is not a mapping, 'catalogs' is not a list, or an entry is not a mapping, instead of letting list() produce a downstream AttributeError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): tighten record read confinement, policy gate, and precedence Addresses review 4534504799: - records.load_records: confine the read via ensure_within(project_root, ...) so a symlinked/traversal-escaping .specify cannot read arbitrary files outside the project (matches the write path's within= guard). - catalog_config._slug: lowercase so derived catalog ids are deterministic across platforms and case-variant duplicates can't slip past the case-sensitive dup check. - installer.install_bundle: reword the docstring's misleading "atomic on failure" claim to describe the real scoped guarantee (record written only on full success; rollback limited to newly-installed components). - bundle update: enforce the source install_policy like install, refusing to update from a discovery-only source (FR-025). - catalog source precedence: the CLI now passes ~/.specify as the user config dir so project > user > built-in precedence is actually reachable (previously the user scope was silently ignored). - .gitattributes: scope the specs whitespace exemption to the generated dogfooding feature dir (specs/001-spec-kit-bundler/**) instead of all of specs/**. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): no collateral refresh, catalog id integrity, loud info Addresses review 4534571362: - installer: in refresh mode (bundle update) only re-apply already- installed components that this bundle (or a sibling) owns. Components installed independently and tracked by no bundle are now skipped, never refreshed, so update cannot make collateral changes (FR-022). - catalog.load_catalog_payload: validate each entry's own id is present and matches its enclosing bundles key, rejecting catalogs that would otherwise list a spoofed or unresolvable id. - bundle info: stop swallowing manifest download failures. If the manifest can't be resolved (e.g. --offline against an https download_url or a download failure), surface the error and exit non-zero instead of silently degrading to catalog `provides` counts, preserving the "info == what install applies" guarantee. Added regressions: refresh leaves independently-installed components untouched, catalog id key/field mismatch + missing id rejection, and info exits non-zero when the manifest is unresolvable offline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): confine catalog-config and integration-marker reads Addresses review 4534716790: two more state reads bypassed the symlink/path-escape confinement that records and the write paths already enforce. - catalog_config._read: validate the config path with ensure_within(project_root, ...) before exists()/read, so a symlinked .specify resolving outside project_root is rejected instead of read. - lib.project.active_integration: confine the .specify/integration.json read the same way; an out-of-tree escape is treated as "not determinable" (returns None) rather than followed. Added regressions covering both via a symlinked .specify pointing outside the project root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate manifest tags, disambiguate derived ids by full host Addresses review 4534768419: - manifest.from_dict: reject a non-list `tags` (e.g. a bare string) instead of splitting it character-by-character, matching the catalog parser and the schema contract (tags = list of strings). - catalog_config._derive_id: derive ids from the full host (TLD included) so example.com and example.net no longer collide on the same id. Updated the affected id assertions. - CHANGELOG: call out the new `specify bundle` command group in the unreleased section (the PR's headline user-facing feature). - .gitattributes: clarify the specs whitespace exemption — the dogfooding feature dir is scrubbed before merge (not retained), so it doesn't weaken checks for kept docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(gitattributes): retain whitespace exemption for constitution.md The project constitution (.specify/memory/constitution.md) is the one dogfooding artifact carried forward past the pre-merge scrub. Give it its own standalone whitespace exemption so it survives removal of the broader .specify/** generated-scaffolding exemption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): accurate uninstall count, confine catalog read, safe bundle id Addresses review 4534812056: - installer.remove_bundle: only count a component as uninstalled when installer.remove() actually ran; components already absent on disk are reported as skipped, keeping the uninstalled count accurate. - catalog.load_source_stack: confine the project-scoped .specify config read with ensure_within, so a symlinked .specify/ resolving outside the project root is refused (consistent with the bundler's other guarded reads). - manifest: enforce a filesystem-safe slug for bundle.id in structural validation; packager.build_bundle adds an ensure_within defense-in-depth check so a crafted id can never push the artifact outside the output dir. Also reverts the CHANGELOG entry (the changelog is updated separately). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate requires/provides shapes in manifest and catalog Addresses review 4534855443: - manifest: validate requires.tools and requires.mcp as list-of-strings via a shared _parse_str_list helper (also reused for tags), so a bare string like `tools: docker` is rejected with an actionable BundlerError instead of being split character-by-character. - catalog.CatalogEntry.from_dict: validate that `requires` and `provides` are mappings before accessing them, so an untrusted catalog payload with `requires: "..."` raises a named BundlerError rather than escaping as a raw AttributeError traceback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): require README.md when building a bundle artifact Addresses review 4534938014: build_bundle now fails early with an actionable error when README.md is missing, matching the documented artifact contract (manifest + README) instead of silently producing a bundle with no human-facing description. Also reverts CHANGELOG.md to the upstream/main copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate record shapes; drop stale install --refresh claim Addresses review 4534969692: - records.InstalledBundleRecord.from_dict: hard-error when contributed_components is not a list, instead of iterating a corrupt bare string character-by-character. - records.load_records: validate the top-level 'bundles' field is a list and fail with a clear BundlerError when a corrupt file makes it a mapping/string. - PR description: remove the inaccurate "supports --refresh" note from `bundle install` (refresh is the `bundle update` path); docs already omit it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): refuse symlinked .specify, reject bad url schemes, IPv6 ids Addresses review 4534997724: - lib.project.find_project_root: a symlinked .specify is no longer accepted as a project root (is_dir() follows symlinks), matching the confinement the rest of the CLI applies and avoiding confusing downstream failures. - catalog_config.add_source: reject unsupported url schemes (ssh://, ftp://, ...) up front instead of silently treating them as local paths; local paths containing ':' but not '://' are still allowed. - catalog_config._derive_id: derive the host via urlparse().hostname so IPv6 literals, credentials, and ports no longer corrupt the derived id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): strict semver, narrow artifact skip, preserve priority 0 Addresses review 4535084048: - versioning.is_semver: enforce a full MAJOR.MINOR.PATCH SemVer (with optional pre-release/build) via a dedicated regex, instead of accepting any packaging.version.Version-parseable string (e.g. "1", "1.0"). This makes BundleManifest.structural_errors() reject non-semver versions. - packager: narrow the prior-artifact skip pattern to semver-named zips (-.zip) so legitimate assets like -assets.zip are still packaged. - primitives (preset + extension install): use an explicit `is None` check so an intentional priority of 0 is preserved instead of being replaced by the default. Adds regressions: non-semver rejection ("1"/"1.0"/"1.2.3.4"), asset-not- excluded vs semver-artifact-excluded, and priority-0 pass-through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): artifact regex for prerelease+build; clarify integration/priority docs Addresses review 4535132279: - packager: the prior-artifact skip regex now matches semver names carrying both a prerelease and build-metadata segment (e.g. 1.0.0-rc1+build5), so such an existing artifact is excluded rather than re-packaged — keeping builds bounded/deterministic, consistent with is_semver(). - docs/reference/bundles.md: correct the install integration wording. --integration selects the integration when initializing a new project and confirms the target when a pinned bundle's active integration can't be determined; it does NOT override a bundle that targets a specific integration (a mismatch aborts with no changes). - examples/security-researcher README: reword the preset priority note in terms of the numeric comparison (ascending priority order) to avoid inverting the meaning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): --integration can't bypass clash guard; honest rollback docs Addresses review 4535159341: - bundle install: for an already-initialized project, the project's recorded active integration is now authoritative. --integration no longer overrides it (which let a copilot project install a claude-pinned bundle via `--integration claude`, bypassing the FR-019 clash guard). The override still selects the integration at init time and confirms the target only when the active integration cannot be determined. - docs/reference/bundles.md: reword the install guarantee to match the implementation — no provenance record is written unless the install fully succeeds, and rollback of this run's components is best-effort (removal errors are swallowed, so partial on-disk state may remain). Dropped the inaccurate "atomic / rolls back everything" claim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate component kind/id when loading records Addresses review 4535194606: _component_from_dict now rejects a contributed component whose 'kind' is not a supported component kind or whose 'id' is empty, raising a BundlerError that explicitly flags the records file as corrupt. Previously such a record loaded successfully and only failed later (e.g. in primitive_manager() during bundle remove/update) with a less actionable error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): address review 4535234003 (7 findings) - versioning: tolerate an uppercase `V` prefix in `_normalize_semver` and `is_semver`, mirroring specify_cli._version tag normalization (V -> v) so `V1.2.3` parses and validates consistently. - validator: import BundlerError and narrow the speckit_version constraint except clause to `BundlerError` only, so programming errors are no longer masked behind an "invalid constraint" message. - bundle update: accept `--integration` and thread it through resolve_install_plan the same way `bundle install` does (override used only when the active integration can't be auto-detected), so integration-pinned bundles can be updated where `.specify/integration.json` is missing/unreadable. - bundle validate: fold reference warnings into `report.warnings` so the ValidationReport is the single warning channel at the CLI layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(bundler): make update --integration help assertion ANSI-safe Rich can split the "--integration" option label with ANSI escape codes between the two leading dashes, so the literal substring check failed under CI's terminal settings. Match the un-split option word instead, mirroring how test_bundle_help_lists_all_commands checks bare command names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): preserve exec bits in artifacts; document install-time pins Addresses review 4535280786: - packager.build_bundle: no longer forces every ZIP member to 0644, which stripped the executable bit from bundled scripts (e.g. extension hook scripts) and could break them after extraction. Permissions are now normalized reproducibly to 0755 when the source file has any execute bit set, otherwise 0644 — identical inputs still yield byte-for-byte identical artifacts. - installer.install_bundle + docs/reference/bundles.md: document that version pins are enforced install-time only. Because primitive is_installed checks are id-based (not version-aware), an already-present component is skipped during install without comparing its on-disk version to the manifest pin; pins are guaranteed applied only on a real install or `bundle update` refresh. Added a regression asserting executable sources map to 0755 and plain files to 0644 in the built artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(bundler): skip exec-bit packager test on Windows Windows filesystems do not carry Unix execute bits, so chmod(0o755) is a no-op and the source file reports no execute bit — the packager then correctly stores the member as 0644. The assertion that an executable source maps to 0755 is only meaningful on POSIX, so skip it on nt rather than asserting platform-specific behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): normalize prerelease spellings inside version constraints Addresses review 4535327154: parse_version() normalized SemVer prerelease spellings (e.g. 1.2.3-rc1 -> 1.2.3rc1) but parse_constraint() passed the constraint to packaging.SpecifierSet unmodified, so ">=1.2.3-rc1" raised InvalidSpecifier even though the same spelling is accepted for installed versions. parse_constraint() now normalizes the version portion of each comma-separated clause via the shared _normalize_semver helper, so prerelease handling is consistent across versions and constraints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(bundler): validate schema versions and required record identity fields Addresses review 4535351596: - records.load_records: validate the on-disk 'schema_version' (required; forward-compatible across same-major minor bumps) and fail fast with an actionable error on a missing/unknown version, rather than silently parsing a possibly-incompatible format and risking incorrect bundle attribution/removal. - records.InstalledBundleRecord.from_dict: treat missing 'bundle_id' or 'version' as corruption and raise BundlerError, instead of coercing them to empty strings that let later list/remove/update operations behave unpredictably. - catalog_config._read: validate 'schema_version' when present (same-major compatibility) and fail fast on an unsupported version so an incompatible future config shape can't be mis-parsed into a wrong effective catalog stack. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(bundler): scrub generated dogfooding scaffold before merge The bundler feature was developed by dogfooding Spec Kit on itself. Now that the work is complete, remove all generated scaffolding so it does not land in the repository on merge: - specs/001-spec-kit-bundler/** (spec, plan, research, data-model, contracts, quickstart, tasks, checklists) - .specify/** (extensions, integrations, scripts, templates, workflows, feature/init/integration metadata) - .github/agents/speckit.*.agent.md, .github/prompts/speckit.*.prompt.md, and .github/copilot-instructions.md (Copilot integration scaffold) Retained: .specify/memory/constitution.md — the single dogfooding artifact carried forward — with its whitespace exemption in .gitattributes. .gitattributes and .markdownlint-cli2.jsonc are reverted to the upstream baseline (plus the constitution whitespace exemption), dropping the now-moot exemptions for the removed scaffold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Pascal THUET --- .gitattributes | 6 +- .specify/memory/constitution.md | 214 +++++ AGENTS.md | 12 +- CONTRIBUTING.md | 18 + README.md | 52 ++ docs/reference/bundles.md | 156 ++++ docs/reference/overview.md | 6 + examples/bundles/business-analyst/README.md | 22 + examples/bundles/business-analyst/bundle.yml | 33 + examples/bundles/developer/README.md | 22 + examples/bundles/developer/bundle.yml | 33 + examples/bundles/product-manager/README.md | 22 + examples/bundles/product-manager/bundle.yml | 35 + .../bundles/security-researcher/README.md | 23 + .../bundles/security-researcher/bundle.yml | 33 + src/specify_cli/__init__.py | 7 + src/specify_cli/bundler/__init__.py | 19 + .../bundler/commands_impl/__init__.py | 2 + .../bundler/commands_impl/catalog_config.py | 191 ++++ src/specify_cli/bundler/lib/__init__.py | 2 + src/specify_cli/bundler/lib/project.py | 62 ++ src/specify_cli/bundler/lib/versioning.py | 99 +++ src/specify_cli/bundler/lib/yamlio.py | 119 +++ src/specify_cli/bundler/models/__init__.py | 2 + src/specify_cli/bundler/models/catalog.py | 258 ++++++ src/specify_cli/bundler/models/manifest.py | 263 ++++++ src/specify_cli/bundler/models/records.py | 229 +++++ src/specify_cli/bundler/services/__init__.py | 2 + src/specify_cli/bundler/services/adapters.py | 193 ++++ .../bundler/services/catalog_stack.py | 114 +++ src/specify_cli/bundler/services/conflict.py | 54 ++ src/specify_cli/bundler/services/installer.py | 210 +++++ src/specify_cli/bundler/services/packager.py | 145 +++ .../bundler/services/primitives.py | 345 ++++++++ .../bundler/services/references.py | 114 +++ src/specify_cli/bundler/services/resolver.py | 122 +++ src/specify_cli/bundler/services/validator.py | 60 ++ src/specify_cli/commands/bundle/__init__.py | 834 ++++++++++++++++++ tests/bundler_helpers.py | 125 +++ tests/contract/test_bundle_cli.py | 391 ++++++++ tests/contract/test_catalog_schema.py | 147 +++ tests/contract/test_manifest_schema.py | 126 +++ .../integration/test_bundler_catalog_stack.py | 79 ++ .../integration/test_bundler_init_install.py | 92 ++ .../integration/test_bundler_install_flow.py | 222 +++++ .../integration/test_bundler_local_install.py | 114 +++ tests/integration/test_bundler_offline.py | 78 ++ .../test_bundler_security_paths.py | 173 ++++ tests/unit/test_bundler_adapters.py | 71 ++ tests/unit/test_bundler_catalog_config.py | 181 ++++ tests/unit/test_bundler_conflict.py | 54 ++ tests/unit/test_bundler_packager.py | 193 ++++ tests/unit/test_bundler_primitives.py | 133 +++ tests/unit/test_bundler_records.py | 190 ++++ tests/unit/test_bundler_references.py | 41 + tests/unit/test_bundler_resolver.py | 81 ++ tests/unit/test_bundler_validator.py | 32 + tests/unit/test_bundler_versioning.py | 68 ++ 58 files changed, 6721 insertions(+), 3 deletions(-) create mode 100644 .specify/memory/constitution.md create mode 100644 docs/reference/bundles.md create mode 100644 examples/bundles/business-analyst/README.md create mode 100644 examples/bundles/business-analyst/bundle.yml create mode 100644 examples/bundles/developer/README.md create mode 100644 examples/bundles/developer/bundle.yml create mode 100644 examples/bundles/product-manager/README.md create mode 100644 examples/bundles/product-manager/bundle.yml create mode 100644 examples/bundles/security-researcher/README.md create mode 100644 examples/bundles/security-researcher/bundle.yml create mode 100644 src/specify_cli/bundler/__init__.py create mode 100644 src/specify_cli/bundler/commands_impl/__init__.py create mode 100644 src/specify_cli/bundler/commands_impl/catalog_config.py create mode 100644 src/specify_cli/bundler/lib/__init__.py create mode 100644 src/specify_cli/bundler/lib/project.py create mode 100644 src/specify_cli/bundler/lib/versioning.py create mode 100644 src/specify_cli/bundler/lib/yamlio.py create mode 100644 src/specify_cli/bundler/models/__init__.py create mode 100644 src/specify_cli/bundler/models/catalog.py create mode 100644 src/specify_cli/bundler/models/manifest.py create mode 100644 src/specify_cli/bundler/models/records.py create mode 100644 src/specify_cli/bundler/services/__init__.py create mode 100644 src/specify_cli/bundler/services/adapters.py create mode 100644 src/specify_cli/bundler/services/catalog_stack.py create mode 100644 src/specify_cli/bundler/services/conflict.py create mode 100644 src/specify_cli/bundler/services/installer.py create mode 100644 src/specify_cli/bundler/services/packager.py create mode 100644 src/specify_cli/bundler/services/primitives.py create mode 100644 src/specify_cli/bundler/services/references.py create mode 100644 src/specify_cli/bundler/services/resolver.py create mode 100644 src/specify_cli/bundler/services/validator.py create mode 100644 src/specify_cli/commands/bundle/__init__.py create mode 100644 tests/bundler_helpers.py create mode 100644 tests/contract/test_bundle_cli.py create mode 100644 tests/contract/test_catalog_schema.py create mode 100644 tests/contract/test_manifest_schema.py create mode 100644 tests/integration/test_bundler_catalog_stack.py create mode 100644 tests/integration/test_bundler_init_install.py create mode 100644 tests/integration/test_bundler_install_flow.py create mode 100644 tests/integration/test_bundler_local_install.py create mode 100644 tests/integration/test_bundler_offline.py create mode 100644 tests/integration/test_bundler_security_paths.py create mode 100644 tests/unit/test_bundler_adapters.py create mode 100644 tests/unit/test_bundler_catalog_config.py create mode 100644 tests/unit/test_bundler_conflict.py create mode 100644 tests/unit/test_bundler_packager.py create mode 100644 tests/unit/test_bundler_primitives.py create mode 100644 tests/unit/test_bundler_records.py create mode 100644 tests/unit/test_bundler_references.py create mode 100644 tests/unit/test_bundler_resolver.py create mode 100644 tests/unit/test_bundler_validator.py create mode 100644 tests/unit/test_bundler_versioning.py diff --git a/.gitattributes b/.gitattributes index 32749f294c..e2e6931b66 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ * text=auto eol=lf -.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace \ No newline at end of file +.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace +# The project constitution is the one dogfooding artifact carried forward. +# Keep it exempt from git's whitespace checks (git diff --check / CI) since its +# generated formatting is not hand-edited. +.specify/memory/constitution.md -whitespace diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 0000000000..0921f18ee6 --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,214 @@ + + +# Spec Kit Constitution + +Spec Kit (the `specify-cli` package and its bundled assets) is a local, offline-capable +developer CLI that bootstraps and operates Spec-Driven Development workflows for AI coding +agents. These principles are derived from the patterns the codebase already enforces. They +are binding on all changes — including the `specify bundle` subcommand and any future +command group, integration, extension, preset, or workflow. + +## Core Principles + +### I. Code Quality & Architectural Discipline + +The codebase follows a strict, registry-driven, layered architecture, and all changes MUST +preserve it. + +- **Separate the CLI surface from importable logic.** User-facing commands live in Typer + sub-apps (e.g. `commands/`, `*/_commands.py`); business logic lives in plain, importable + modules with no `@app.command()` decorators. New features MUST keep orchestration logic + testable independently of Typer. +- **Use the established extension pattern.** New agents/integrations MUST subclass one of the + standard base classes (`MarkdownIntegration`, `TomlIntegration`, `YamlIntegration`, + `SkillsIntegration`) and declare the required class attributes (`key`, `config`, + `registrar_config`, and `context_file` where applicable). Extending `IntegrationBase` + directly is permitted only when no base class fits, and the deviation MUST be justified. +- **Honor the single source of truth.** Built-ins are wired through the relevant registry + (e.g. `INTEGRATION_REGISTRY` via `_register_builtins()`), with imports and registrations + kept in alphabetical order. Duplicate keys MUST fail loudly rather than silently override. +- **Naming and typing are not optional.** Private modules/functions are `_`-prefixed and MUST + NOT be imported across package boundaries. Every new module begins with + `from __future__ import annotations` and uses modern type syntax (`dict[str, Any]`, + `str | None`); legacy `Dict`/`List`/`Optional` forms are rejected. +- **Package directories use underscores; keys keep their canonical (often hyphenated) form** + (e.g. package `kiro_cli/`, `key = "kiro-cli"`). For CLI-backed integrations the `key` MUST + match the executable name so `shutil.which(key)` resolves. + +**Rationale:** A registry-plus-base-class architecture is what lets dozens of integrations, +extensions, and workflows coexist with minimal coupling. Drift here multiplies maintenance +cost and breaks the "add one subclass, register once, ship a test" contract. + +### II. Test-Backed Change (NON-NEGOTIABLE) + +Every behavioral change MUST be accompanied by automated tests, and the suite is a hard gate. + +- **Tests gate merges.** CI runs `pytest` across a matrix of ubuntu + windows × Python 3.11, + 3.12, and 3.13. Changes MUST pass on every cell of that matrix. +- **Parity invariants MUST hold.** Every integration MUST be present in the registry, have a + `CommandRegistrar` config entry where required, and ship a dedicated + `tests/integrations/test_integration_.py` (hyphens in the key become underscores in the + filename). These are enforced by parametrized tests (e.g. `test_registry.py`) and MUST NOT + be weakened. +- **Follow pytest conventions.** Test modules/classes/functions use the `test_*` / `Test*` + naming the project configures, run under `--strict-markers`, and isolate state with + `tmp_path`, `monkeypatch`, and the autouse auth-isolation fixture. Platform-specific tests + MUST be guarded (e.g. `@requires_bash`) rather than left to fail. +- **Security and idempotency tests are mandatory categories.** Path-traversal rejection, + manifest hash integrity/symlink safety, and no-overwrite idempotency are covered by existing + suites; changes touching file writes, path handling, or setup scripts MUST extend (never + reduce) that coverage. +- **Network is mocked.** No test may make a real outbound network call; HTTP MUST be stubbed + so the suite is deterministic and offline-runnable. + +**Rationale:** The breadth of supported agents and the offline/air-gapped guarantees can only +be sustained by exhaustive, parametrized tests. The parity and security suites are what stop a +single new integration from regressing the whole matrix. + +### III. CLI & User-Experience Consistency + +The CLI presents one coherent surface; every command group MUST feel like the others. + +- **Reuse the shared verb vocabulary.** Consumer-facing groups use the established verbs — + `list`, `add`/`install`, `remove`, `search`, `info`, `update`, plus `enable`/`disable` and + `set-priority` where relevant. New verbs MUST NOT be invented when an existing one fits, and + any genuinely new verb MUST be justified. +- **Mirror the catalog-stack model.** Catalog-backed groups MUST expose + ` catalog list|add|remove`, back it with a priority-ordered source stack (lower number + = higher precedence) plus per-source install policy (`install-allowed` vs `discovery-only`), + and fall back to a built-in default stack when no project config is present. +- **Register sub-apps the standard way.** Command groups are `typer.Typer(...)` instances + attached via `app.add_typer(child, name="...")`, preferably through a modular + `register(app)` function imported in `__init__.py`. Nesting MUST stay within ~2–3 levels. +- **Output is consistent and machine-friendly.** Human output uses the shared Rich + conventions (e.g. `[green]✓[/green]` success, `[red]Error:[/red]` + non-zero exit on + failure, actionable remediation in messages). Where a `--json` flag is offered, valid JSON + goes to stdout and all other logging is redirected to stderr. +- **Interactions are safe and idempotent.** Destructive actions show what will change before + confirming; "already installed / already present" outcomes succeed (exit 0) rather than + error. User-facing command groups MUST be documented under `docs/reference/`. + +**Rationale:** Predictability is the product. Users learn one set of verbs, one catalog model, +and one output grammar, then apply them to every group — including `specify bundle`. + +### IV. Offline-First Performance & Resource Discipline + +Spec Kit is a local CLI; responsiveness, offline operability, and graceful degradation are the +performance contract. + +- **`specify init` and core scaffolding MUST work fully offline** using bundled `core_pack` + assets. Asset resolution MUST prefer bundled assets, then a source checkout, before ever + reaching the network. +- **Network use is lazy, bounded, and degradable.** Network calls happen only on explicit + user commands, MUST set timeouts, MUST cache catalog results (1-hour TTL) and fall back to + stale cache on failure, and MUST surface offline/rate-limit conditions as clear messages + without crashing. +- **Keep startup cheap.** Avoid adding heavyweight work to import time. New optional + subsystems SHOULD prefer lazy loading over unconditional eager imports so that unrelated + commands (including `--help`) stay fast. +- **Filesystem writes are minimal and idempotent.** Installs MUST track files (SHA-256 + manifests), avoid clobbering user-modified content, only uninstall files whose hash still + matches, and never follow symlinks out of the project root. + +**Rationale:** Developers run this tool in air-gapped, enterprise, and flaky-network +environments. Offline-first behavior and idempotent, hash-tracked file operations are what +make it safe and fast to run repeatedly. + +### V. Minimal Dependencies & Safe, Idempotent File Operations + +The project guards its dependency surface and its on-disk footprint deliberately. + +- **Zero new runtime dependencies by default.** The runtime dependency set is intentionally + small and pinned to a minimum major version. Adding a dependency requires maintainer + agreement and a justification that existing deps (typer, click, rich, pyyaml, packaging, + platformdirs, pathspec, json5, readchar) cannot serve the need. New subsystems SHOULD reuse + existing primitive machinery in-process rather than re-implementing or re-shipping it. +- **All paths are validated.** Any project-relative path derived from user/manifest/catalog + input MUST be confined to the project root (`Path.relative_to` checks) and reject traversal + payloads; symlink escapes MUST be refused. +- **Errors are explicit and chained.** Validate inputs up front, raise with actionable context + (offending field/value plus a hint), and use `raise ... from exc` to preserve causes. I/O + that can legitimately fail MUST degrade gracefully rather than emit a raw traceback. +- **Versioning follows SemVer.** User-visible and packaged behavior changes follow + MAJOR.MINOR.PATCH semantics; backward-incompatible changes MUST be called out and justified. + +**Rationale:** A lean, pinned dependency set and hardened, idempotent file handling are what +keep the tool trustworthy in enterprise and air-gapped contexts and cheap to maintain. + +## Security & Cross-Platform Constraints + +- **Cross-platform parity is required.** Code MUST run on Linux, macOS, and Windows and on + Python 3.11–3.13. Windows specifics (UTF-8 stream reconfiguration, bash-dependent tests + auto-skipping) MUST be respected; do not introduce POSIX-only assumptions without a guarded + fallback. +- **Security tooling is a gate.** CodeQL and the project's security test suites + (path-traversal, manifest/symlink hardening) MUST remain green. Network access MUST default + to off in tests and be opt-in, timeout-bounded, and credential-isolated at runtime. +- **Formatting is enforced.** `.editorconfig` rules (LF endings, final newline, no trailing + whitespace, 4-space Python / 2-space YAML-JSON-Markdown), `ruff check src/`, and + `markdownlint-cli2` MUST pass. + +## Development Workflow & Quality Gates + +- **Branch naming** follows `/-` (or `/` with no + issue), with `` ∈ {feat, fix, docs, community, chore}. +- **PRs are focused** and MUST: pass `ruff`, `pytest` (full matrix), markdown lint, and CodeQL; + add/extend tests for new behavior; update user-facing docs (`README.md`, `docs/`, + `spec-driven.md`) when behavior changes; and disclose any AI assistance used. +- **Slash-command-affecting changes** MUST be manually exercised through a coding agent and the + results reported in the PR, per CONTRIBUTING.md. +- **Large or cross-cutting changes** (new templates, arguments, command groups) MUST be agreed + with maintainers before implementation. + +## Governance + +This constitution supersedes ad-hoc convention where they conflict; the existing codebase +patterns it codifies remain authoritative references. + +- **Authority.** Principles I–V are binding gates. The `## Constitution Check` section of the + plan template MUST be evaluated against these principles, and `/speckit.analyze` treats + conflicts with a MUST as CRITICAL. Violations are resolved by changing the spec, plan, or + tasks — not by diluting a principle. +- **Amendments.** Changes to this document require a PR with rationale, maintainer approval, + and a version bump per the policy below. Any amendment MUST propagate to dependent templates + and command guidance in the same change, recorded in the Sync Impact Report at the top of + this file. +- **Versioning policy (SemVer for governance).** MAJOR = backward-incompatible governance or + principle removal/redefinition; MINOR = a new principle/section or materially expanded + guidance; PATCH = clarifications and non-semantic refinements. +- **Compliance review.** Every PR and review MUST verify compliance with these principles. + Added complexity or any deviation MUST be justified in-PR (and, for plans, in the plan's + Complexity Tracking section). Unjustified violations block merge. + +**Version**: 1.0.0 | **Ratified**: 2026-06-19 | **Last Amended**: 2026-06-19 diff --git a/AGENTS.md b/AGENTS.md index d21db4f426..3d5ea32377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their 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()`. -``` +```text src/specify_cli/integrations/ ├── __init__.py # INTEGRATION_REGISTRY + _register_builtins() ├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration @@ -340,18 +340,21 @@ Some agents require custom processing beyond the standard template transformatio ### 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 **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 @@ -371,11 +374,13 @@ specify init my-project --integration copilot --integration-options="--skills" ### 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()` @@ -385,11 +390,13 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: ### 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) @@ -400,7 +407,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): Branches follow one of two patterns depending on whether an issue exists: -``` +```text /- # when an issue is created first / # when no issue exists (PR-only changes) ``` @@ -463,6 +470,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag 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. +6. **Running tests against the wrong environment**: Always run the suite inside this working tree's own virtualenv (`uv sync --extra test` then `.venv/bin/python -m pytest`, or activate the venv first). A bare `uv run pytest` can resolve to an ambient/global interpreter whose editable `.pth` points at a *different* worktree. The failure is sneaky: test collection still imports `specify_cli` successfully, but newly-added subpackages (e.g. a fresh `specify_cli/bundler/`) resolve as a stale namespace package and raise `ModuleNotFoundError`. If a brand-new subpackage imports under `python -c` but not under pytest, suspect environment contamination, not your code. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12b095f5fc..899dae258c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,24 @@ uv run python -m pytest tests/test_agent_config_consistency.py -q Run this when you change agent metadata, context update scripts, or integration wiring. +#### Running the full test suite + +Install the test dependencies into the project's own virtual environment and run +`pytest` through that interpreter: + +```bash +uv pip install -e ".[test]" +.venv/bin/python -m pytest tests -q # Windows: .venv\Scripts\python -m pytest tests -q +``` + +> **Note:** prefer `.venv/bin/python -m pytest` over a bare `uv run pytest`. +> If another Spec Kit checkout has an editable (`-e`) install registered in a +> shared/global environment, `uv run pytest` can resolve `specify_cli` to that +> *other* worktree, turning it into a partial namespace package that fails to +> import newly added subpackages. Running through the project `.venv` resolves +> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in +> `AGENTS.md` (Common Pitfalls). + ### Manual testing #### Testing setup diff --git a/README.md b/README.md index afca9b15a5..34e1403324 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - [🤖 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) +- [📦 Bundles: Role-Based Setups](#-bundles-role-based-setups) - [📚 Core Philosophy](#-core-philosophy) - [🌟 Development Phases](#-development-phases) - [🎯 Experimental Goals](#-experimental-goals) @@ -228,6 +229,56 @@ For example, presets could restructure spec templates to require regulatory trac See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking. +## 📦 Bundles: Role-Based Setups + +Extensions and presets are individual building blocks. A **bundle** packages a +curated set of them — extensions, presets, steps, and workflows — into a single, +versioned, role-oriented setup so a whole team persona (product manager, business +analyst, security researcher, developer, …) can be provisioned with one command. + +A bundle is described by a hand-written `bundle.yml` manifest. It pins each +component to a version and, optionally, targets a specific integration; a bundle +with no `integration` is **agnostic** and inherits whatever integration the +project already uses. + +```bash +# Discover bundles in the active catalog stack +specify bundle search [] + +# Inspect the exact component set a bundle will add (equals what install does) +specify bundle info + +# Install a bundle's full component set in one operation +specify bundle install + +# See what's installed, then update or remove non-destructively +specify bundle list +specify bundle update # or --all +specify bundle remove # removes only this bundle's components +``` + +Bundles resolve from a **priority-ordered catalog stack** (project > user > +built-in). Each source carries an install policy: `install-allowed` sources can +be installed from, while `discovery-only` sources are visible in `search`/`info` +but refuse installation. Manage the stack with `specify bundle catalog list|add|remove`. + +Authors validate and package bundles locally — there is no first-class publish; +distribution is hosting the built artifact and adding a catalog entry: + +```bash +specify bundle validate --path ./my-bundle # structural + reference checks +specify bundle build --path ./my-bundle # produce a versioned .zip artifact +``` + +Four ready-to-read example manifests live under +[`examples/bundles/`](examples/bundles/) (product manager, business analyst, +security researcher, developer). + +Key guarantees: `info` shows exactly what `install` adds (transparency); +installs are idempotent and confined to the project root; `remove` never touches +components another installed bundle still needs; and all consume/author commands +work **offline** against local or pinned sources. + ### When to Use Which | Goal | Use | @@ -237,6 +288,7 @@ See the [Presets reference](https://github.github.io/spec-kit/reference/presets. | 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 | +| Provision a complete role-based setup in one command | Bundle | ## 📚 Core Philosophy diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md new file mode 100644 index 0000000000..2a7384cf6b --- /dev/null +++ b/docs/reference/bundles.md @@ -0,0 +1,156 @@ +# Bundles + +Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single, versioned, installable unit. Where extensions and presets are primitives, a bundle is a curated stack that declares everything a team or role needs and installs it in one step through each component's own machinery. Bundles add no new runtime behavior of their own: they are a distribution and composition layer over the primitives you already use. + +A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks for the single cross-bundle conflict point (the active integration), and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later. + +## Search Available Bundles + +```bash +specify bundle search [query] +``` + +| Option | Description | +| ----------- | ---------------------------- | +| `--offline` | Do not access the network | +| `--json` | Emit machine-readable JSON | + +Searches all active catalogs for bundles matching the query. Without a query, lists every available bundle with its version, role, source, and a trust indicator (`verified` for org-curated catalog entries, `community` otherwise) so you can judge trust before installing. + +## Bundle Info + +```bash +specify bundle info +``` + +| Option | Description | +| ------------ | --------------------------------- | +| `--offline` | Do not access the network | +| `--json` | Emit machine-readable JSON | + +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps with components already provided by installed bundles are surfaced here as well. + +## Install a Bundle + +```bash +specify bundle install +``` + +| Option | Description | +| ---------------- | ------------------------------------------------------------------ | +| `--integration` | Override the integration used when initializing/installing | +| `--offline` | Do not access the network | + +Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack. + +If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Installation is idempotent — components already present are skipped. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. + +## Update Bundles + +```bash +specify bundle update [] +``` + +| Option | Description | +| ------------ | ------------------------------------ | +| `--all` | Update every installed bundle | +| `--offline` | Do not access the network | + +Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. + +> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version. + +## Remove a Bundle + +```bash +specify bundle remove +``` + +Uninstalls only the components this bundle contributed, leaving any component that another installed bundle still needs in place (no collateral removals). + +## List Installed Bundles + +```bash +specify bundle list +``` + +| Option | Description | +| -------- | ---------------------------- | +| `--json` | Emit machine-readable JSON | + +Lists the bundles installed in the project with their versions, component counts, and install timestamps. + +## Initialize a Project with a Bundle + +```bash +specify bundle init [] +``` + +| Option | Description | +| ---------------- | ---------------------------------------- | +| `--integration` | Integration override | +| `--offline` | Do not access the network | + +Ensures the current directory is a Spec Kit project (initializing it idempotently if needed), then optionally installs the given bundle. Useful as an explicit one-step bootstrap for a new checkout. + +## Validate a Bundle + +```bash +specify bundle validate +``` + +| Option | Description | +| ------------ | ------------------------------------------------------------------- | +| `--path` | Bundle directory or `bundle.yml` (default: current directory) | +| `--offline` | Verify references against bundled/installed components only | + +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves. References are checked against bundled components, the project's installed components, and — when online — the active catalogs. Validation fails only when a reference is definitively absent everywhere it could be checked: that is, when an active catalog is reachable and confirms the component is missing. References that cannot be verified — because validation is offline, or because a catalog is unreachable — are downgraded to warnings so authoring can continue, rather than failing the run. + +## Build a Bundle Artifact + +```bash +specify bundle build +``` + +| Option | Description | +| ----------- | ------------------------------------------------------- | +| `--path` | Bundle directory (default: current directory) | +| `--output` | Output directory for the artifact | + +Produces a single versioned, distributable `.zip` artifact from a bundle directory. The artifact embeds the manifest and can be installed directly with `specify bundle install `. + +## Manage Catalog Sources + +Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). + +### List the Catalog Stack + +```bash +specify bundle catalog list +``` + +Prints the active, priority-ordered catalog stack with each source's scope and install policy. + +### Add a Catalog Source + +```bash +specify bundle catalog add +``` + +| Option | Description | +| ------------- | ------------------------------------------------------- | +| `--policy` | `install-allowed` or `discovery-only` | +| `--priority` | Source priority (lower = higher precedence; default 10) | +| `--id` | Explicit source id | + +Registers a project-scoped catalog source and persists it. + +### Remove a Catalog Source + +```bash +specify bundle catalog remove +``` + +Removes a project-scoped catalog source. Built-in default sources cannot be deleted. + +> **Note:** `search` and `info` work anywhere — with no project they fall back to the built-in/user catalog stack. The remaining state-changing commands (`list`, `update`, `remove`, `catalog`) require a project already initialized with `specify init`. `install` and `init` will initialize a project on demand when run in an uninitialized directory. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 10fcdc3bca..162515772f 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -31,3 +31,9 @@ Presets customize how Spec Kit works — overriding command files, template file 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) + +## Bundles + +Bundles compose existing extensions, presets, workflows, and steps into a single, versioned, installable unit. Rather than adding new behavior, a bundle curates a stack of primitives — everything a team or role needs — and installs it in one step through each component's own machinery, with version pinning, conflict checks, and provenance tracking for clean updates and removal. + +[Bundles reference →](bundles.md) diff --git a/examples/bundles/business-analyst/README.md b/examples/bundles/business-analyst/README.md new file mode 100644 index 0000000000..9f63464640 --- /dev/null +++ b/examples/bundles/business-analyst/README.md @@ -0,0 +1,22 @@ +# Business Analyst bundle + +A role bundle for business analysts working in a Spec-Driven Development flow: +requirements elicitation, traceability, and acceptance criteria. + +## What it installs + +- **Extension** `agent-context` — keeps the agent context file in sync. +- **Preset** `requirements-elicitation` (priority 10, append) — elicitation and + analysis command set. +- **Steps** `capture-requirements`, `trace-acceptance-criteria`. +- **Workflow** `requirements-to-spec` — turns captured requirements into a spec. + +This bundle is **integration-agnostic**: it inherits the project's active +integration. + +## Usage + +```bash +specify bundle validate --path examples/bundles/business-analyst +specify bundle build --path examples/bundles/business-analyst --output dist/ +``` diff --git a/examples/bundles/business-analyst/bundle.yml b/examples/bundles/business-analyst/bundle.yml new file mode 100644 index 0000000000..b03875a22e --- /dev/null +++ b/examples/bundles/business-analyst/bundle.yml @@ -0,0 +1,33 @@ +schema_version: "1.0" + +bundle: + id: "business-analyst" + name: "Business Analyst" + version: "1.0.0" + role: "business-analyst" + description: "Spec-Driven Development setup for business analysts: requirements elicitation, traceability, and acceptance criteria." + author: "spec-kit-examples" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "agent-context" + version: "1.0.0" + presets: + - id: "requirements-elicitation" + version: "1.0.0" + priority: 10 + strategy: "append" + steps: + - id: "capture-requirements" + - id: "trace-acceptance-criteria" + workflows: + - id: "requirements-to-spec" + version: "1.0.0" + +tags: ["requirements", "traceability", "analysis"] diff --git a/examples/bundles/developer/README.md b/examples/bundles/developer/README.md new file mode 100644 index 0000000000..c56d5071ca --- /dev/null +++ b/examples/bundles/developer/README.md @@ -0,0 +1,22 @@ +# Developer bundle + +A role bundle for developers practicing Spec-Driven Development: implementation +planning, task breakdown, and code review. + +## What it installs + +- **Extension** `agent-context` — keeps the agent context file in sync. +- **Preset** `implementation-planning` (priority 10, append) — implementation + planning command set. +- **Steps** `plan-implementation`, `break-down-tasks`. +- **Workflow** `spec-to-implementation` — drives a spec through to code. + +This bundle is **integration-agnostic**: it inherits the project's active +integration. + +## Usage + +```bash +specify bundle validate --path examples/bundles/developer +specify bundle build --path examples/bundles/developer --output dist/ +``` diff --git a/examples/bundles/developer/bundle.yml b/examples/bundles/developer/bundle.yml new file mode 100644 index 0000000000..3a365534e5 --- /dev/null +++ b/examples/bundles/developer/bundle.yml @@ -0,0 +1,33 @@ +schema_version: "1.0" + +bundle: + id: "developer" + name: "Developer" + version: "1.0.0" + role: "developer" + description: "Spec-Driven Development setup for developers: implementation planning, task breakdown, and code review." + author: "spec-kit-examples" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "agent-context" + version: "1.0.0" + presets: + - id: "implementation-planning" + version: "1.0.0" + priority: 10 + strategy: "append" + steps: + - id: "plan-implementation" + - id: "break-down-tasks" + workflows: + - id: "spec-to-implementation" + version: "1.0.0" + +tags: ["development", "implementation", "code-review"] diff --git a/examples/bundles/product-manager/README.md b/examples/bundles/product-manager/README.md new file mode 100644 index 0000000000..c7b5c8b9f8 --- /dev/null +++ b/examples/bundles/product-manager/README.md @@ -0,0 +1,22 @@ +# Product Manager bundle + +A role bundle that prepares a Spec Kit project for product managers driving +Spec-Driven Development: discovery, specification, and roadmap planning. + +## What it installs + +- **Extension** `agent-context` — keeps the agent context file in sync. +- **Preset** `product-discovery` (priority 10, append) — discovery-oriented + command set. +- **Steps** `draft-spec`, `review-spec` — specification authoring steps. +- **Workflow** `spec-to-roadmap` — turns an approved spec into a roadmap. + +This bundle is **integration-agnostic**: it inherits whatever integration the +project already uses (e.g. `copilot`, `claude`). + +## Usage + +```bash +specify bundle validate --path examples/bundles/product-manager +specify bundle build --path examples/bundles/product-manager --output dist/ +``` diff --git a/examples/bundles/product-manager/bundle.yml b/examples/bundles/product-manager/bundle.yml new file mode 100644 index 0000000000..9abba40bd4 --- /dev/null +++ b/examples/bundles/product-manager/bundle.yml @@ -0,0 +1,35 @@ +schema_version: "1.0" + +bundle: + id: "product-manager" + name: "Product Manager" + version: "1.0.0" + role: "product-manager" + description: "Spec-Driven Development setup for product managers: discovery, specification, and roadmap workflows." + author: "spec-kit-examples" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +# Agnostic bundle: inherits the project's active integration. + +provides: + extensions: + - id: "agent-context" + version: "1.0.0" + presets: + - id: "product-discovery" + version: "1.0.0" + priority: 10 + strategy: "append" + steps: + - id: "draft-spec" + - id: "review-spec" + workflows: + - id: "spec-to-roadmap" + version: "1.0.0" + +tags: ["product", "discovery", "roadmap"] diff --git a/examples/bundles/security-researcher/README.md b/examples/bundles/security-researcher/README.md new file mode 100644 index 0000000000..417cf5aced --- /dev/null +++ b/examples/bundles/security-researcher/README.md @@ -0,0 +1,23 @@ +# Security Researcher bundle + +A role bundle for security researchers practicing Spec-Driven Development: +threat modeling, security review, and compliance. + +## What it installs + +- **Extension** `agent-context` — keeps the agent context file in sync. +- **Preset** `security-compliance` (priority 5, append) — security and + compliance command set; presets apply in ascending priority order, so this + low number (5) places it ahead of higher-numbered presets in the stack. +- **Steps** `threat-model`, `security-review`. +- **Workflow** `secure-sdd` — a security-first SDD workflow. + +This bundle is **integration-agnostic**: it inherits the project's active +integration. + +## Usage + +```bash +specify bundle validate --path examples/bundles/security-researcher +specify bundle build --path examples/bundles/security-researcher --output dist/ +``` diff --git a/examples/bundles/security-researcher/bundle.yml b/examples/bundles/security-researcher/bundle.yml new file mode 100644 index 0000000000..d0b289e872 --- /dev/null +++ b/examples/bundles/security-researcher/bundle.yml @@ -0,0 +1,33 @@ +schema_version: "1.0" + +bundle: + id: "security-researcher" + name: "Security Researcher" + version: "1.0.0" + role: "security-researcher" + description: "Spec-Driven Development setup for security researchers: threat modeling, security review, and compliance checks." + author: "spec-kit-examples" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "agent-context" + version: "1.0.0" + presets: + - id: "security-compliance" + version: "1.0.0" + priority: 5 + strategy: "append" + steps: + - id: "threat-model" + - id: "security-review" + workflows: + - id: "secure-sdd" + version: "1.0.0" + +tags: ["security", "compliance", "threat-modeling"] diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3e4c15122f..9a05b7e518 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -609,6 +609,13 @@ def _require_specify_project() -> Path: _register_preset_cmds(app) +# ===== Bundle Commands ===== + +# Bundler subcommand group (specify bundle ...) — see commands/bundle/. +from .commands.bundle import register as _register_bundle_cmds # noqa: E402 +_register_bundle_cmds(app) + + # ===== Extension Commands ===== diff --git a/src/specify_cli/bundler/__init__.py b/src/specify_cli/bundler/__init__.py new file mode 100644 index 0000000000..dac5347b67 --- /dev/null +++ b/src/specify_cli/bundler/__init__.py @@ -0,0 +1,19 @@ +"""Spec Kit bundler — importable, Typer-free logic for the ``specify bundle`` group. + +This package holds the models, services, and helpers behind the ``specify bundle`` +subcommand. It is intentionally free of any Typer/CLI imports so the orchestration +logic can be unit-tested independently of the command surface (Constitution +Principle I). The CLI wiring lives in ``specify_cli.commands.bundle``. +""" +from __future__ import annotations + +__all__ = ["BundlerError"] + + +class BundlerError(Exception): + """Base class for all actionable bundler errors. + + Carrying a clean message lets the CLI layer print a single, user-facing line + on stderr and exit non-zero without leaking a traceback (Constitution + Principle V — explicit, actionable errors). + """ diff --git a/src/specify_cli/bundler/commands_impl/__init__.py b/src/specify_cli/bundler/commands_impl/__init__.py new file mode 100644 index 0000000000..ae91e9190a --- /dev/null +++ b/src/specify_cli/bundler/commands_impl/__init__.py @@ -0,0 +1,2 @@ +"""Bundler command-implementation helpers (kept thin; logic lives in services).""" +from __future__ import annotations diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py new file mode 100644 index 0000000000..477099b7d7 --- /dev/null +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -0,0 +1,191 @@ +"""Persistence for the project-scoped catalog config (``.specify/bundle-catalogs.yml``). + +Only project scope is writable; built-in defaults are never deleted (they can be +overridden by adding a same-id source). The on-disk shape mirrors +``bundle-catalog.schema.md``: ``{schema_version, catalogs: [{id,url,priority,install_policy}]}``. +""" +from __future__ import annotations + +from pathlib import Path +from urllib.parse import urlparse +import re + +from .. import BundlerError +from ..lib.yamlio import dump_yaml, ensure_within, load_yaml +from ..models.catalog import ( + CONFIG_FILENAME, + BUILTIN_DEFAULT_STACK, + CatalogSource, + InstallPolicy, + Scope, +) + +CONFIG_SCHEMA_VERSION = "1.0" + +_BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK} + +# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter +# ``scheme`` under urlparse; treat them as local files rather than URLs. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") + + +def _config_path(project_root: Path) -> Path: + return Path(project_root) / ".specify" / CONFIG_FILENAME + + +def _read(project_root: Path) -> list[dict]: + # Confine the read (parity with the write path's within= guard): refuse to + # follow a symlinked or traversal-escaping .specify that resolves outside + # project_root. + path = ensure_within(project_root, _config_path(project_root)) + if not path.exists(): + return [] + data = load_yaml(path) + if data is None: + return [] + if not isinstance(data, dict): + raise BundlerError( + f"Malformed catalog config at {path}: expected a mapping at the top " + f"level, got {type(data).__name__}." + ) + schema_version = data.get("schema_version") + if schema_version is not None and ( + str(schema_version).strip().split(".")[0] + != CONFIG_SCHEMA_VERSION.split(".")[0] + ): + raise BundlerError( + f"Unsupported catalog config schema version " + f"'{str(schema_version).strip()}' at {path}; this Spec Kit " + f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been " + "written by a newer version or is corrupt." + ) + catalogs = data.get("catalogs") + if catalogs is None: + return [] + if not isinstance(catalogs, list): + raise BundlerError( + f"Malformed catalog config at {path}: 'catalogs' must be a list, " + f"got {type(catalogs).__name__}." + ) + for entry in catalogs: + if not isinstance(entry, dict): + raise BundlerError( + f"Malformed catalog config at {path}: each catalog entry must be " + f"a mapping, got {type(entry).__name__}." + ) + return list(catalogs) + + +def _write(project_root: Path, catalogs: list[dict]) -> None: + payload = {"schema_version": CONFIG_SCHEMA_VERSION, "catalogs": catalogs} + dump_yaml(_config_path(project_root), payload, within=project_root) + + +def _slug(value: str) -> str: + # Lowercase so derived ids are deterministic and case-insensitive across + # platforms (e.g. 'Team-A.json' and 'team-a.json' yield the same id), + # keeping the case-sensitive duplicate check from admitting logical dupes. + return "".join(ch if ch.isalnum() else "-" for ch in value.lower()).strip("-") + + +_REMOTE_SCHEMES = {"http", "https", "file", "builtin"} + + +def _is_local_path(url: str) -> bool: + """True when *url* denotes a local filesystem path rather than a URL.""" + if _WINDOWS_DRIVE_RE.match(url): + return True + scheme = urlparse(url).scheme.lower() + return scheme not in _REMOTE_SCHEMES + + +def _canonicalize_url(url: str) -> str: + """Make local file paths absolute so config is independent of the caller's cwd. + + Remote URLs (``http(s)://``, ``file://``, ``builtin://``) are returned + unchanged; only bare/relative local paths are resolved to an absolute path. + """ + if _is_local_path(url): + return str(Path(url).expanduser().resolve()) + return url + + +def _derive_id(url: str) -> str: + parsed = urlparse(url) + if parsed.netloc: + # Use .hostname (not netloc.split(':')) so credentials, ports, and IPv6 + # literals (e.g. https://[2001:db8::1]/x) are handled correctly. Use the + # full host (TLD included) so different domains sharing a second-level + # label (example.com vs example.net) don't collide. _slug() lowercases + # and turns separators into dashes, so 'Example.com' -> 'example-com'. + host = parsed.hostname or "" + path_stem = Path(parsed.path).stem if parsed.path else "" + parts = [p for p in (_slug(host), _slug(path_stem)) if p] + return "-".join(parts) or "catalog" + stem = Path(parsed.path or url).stem + return _slug(stem) or "catalog" + + +def add_source( + project_root: Path, + url: str, + *, + policy: str, + priority: int, + source_id: str | None = None, +) -> CatalogSource: + url = url.strip() + if not url: + raise BundlerError("A catalog url is required.") + parsed = urlparse(url) + if not (parsed.scheme or parsed.path): + raise BundlerError(f"Invalid catalog url: '{url}'.") + # Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are + # never silently canonicalized as local filesystem paths. Local paths that + # merely contain a ':' but no '://' (e.g. Windows drives) are still allowed. + if "://" in url and parsed.scheme.lower() not in _REMOTE_SCHEMES: + raise BundlerError( + f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. " + "Use http(s)://, file://, builtin://, or a local path." + ) + + url = _canonicalize_url(url) + install_policy = InstallPolicy.parse(policy) + resolved_id = (source_id or _derive_id(url)).strip() + + catalogs = _read(project_root) + for existing in catalogs: + if existing.get("id") == resolved_id or existing.get("url") == url: + raise BundlerError( + f"Catalog source '{resolved_id}' (or url) already exists in this project." + ) + + entry = { + "id": resolved_id, + "url": url, + "priority": int(priority), + "install_policy": install_policy.value, + } + catalogs.append(entry) + _write(project_root, catalogs) + return CatalogSource.from_dict(entry, Scope.PROJECT) + + +def remove_source(project_root: Path, id_or_url: str) -> str: + target = id_or_url.strip() + if target in _BUILTIN_IDS: + raise BundlerError( + f"'{target}' is a built-in default source and cannot be deleted " + "(add a same-id source to override it instead)." + ) + + catalogs = _read(project_root) + remaining = [ + c for c in catalogs if c.get("id") != target and c.get("url") != target + ] + if len(remaining) == len(catalogs): + raise BundlerError( + f"No project-scoped catalog source matching '{target}' was found." + ) + _write(project_root, remaining) + return target diff --git a/src/specify_cli/bundler/lib/__init__.py b/src/specify_cli/bundler/lib/__init__.py new file mode 100644 index 0000000000..f0c89c4a0f --- /dev/null +++ b/src/specify_cli/bundler/lib/__init__.py @@ -0,0 +1,2 @@ +"""Shared, dependency-light helpers for the bundler (YAML/JSON IO, versioning, project detection).""" +from __future__ import annotations diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py new file mode 100644 index 0000000000..66b8a1b27b --- /dev/null +++ b/src/specify_cli/bundler/lib/project.py @@ -0,0 +1,62 @@ +"""Spec Kit project detection and active-integration resolution.""" +from __future__ import annotations + +from pathlib import Path + +from .. import BundlerError +from .yamlio import ensure_within, load_json + +DEFAULT_INTEGRATION = "copilot" + + +def find_project_root(start: Path | None = None) -> Path | None: + """Return the nearest ancestor (incl. *start*) containing a ``.specify/`` dir, or None. + + A symlinked ``.specify`` is not accepted as a project root: following it + could read/write outside the intended tree, and other CLI surfaces refuse + it for the same reason. + """ + current = Path(start or Path.cwd()).resolve() + for candidate in (current, *current.parents): + marker = candidate / ".specify" + if marker.is_dir() and not marker.is_symlink(): + return candidate + return None + + +def require_project_root(start: Path | None = None) -> Path: + """Return the Spec Kit project root or raise an actionable error.""" + root = find_project_root(start) + if root is None: + raise BundlerError( + "Not a Spec Kit project (no .specify/ directory). " + "Run 'specify bundle init' or 'specify init' first." + ) + return root + + +def active_integration(project_root: Path) -> str | None: + """Return the project's active integration id, if recorded. + + Spec Kit records the chosen integration in ``.specify/integration.json`` + during init. Returns None when it cannot be determined (e.g. agnostic). + """ + marker = Path(project_root) / ".specify" / "integration.json" + # Confine the read (mirrors records/catalog IO): refuse to follow a + # symlinked or traversal-escaping .specify that resolves outside + # project_root. An escape is treated as "not determinable". + try: + marker = ensure_within(project_root, marker) + except BundlerError: + return None + if not marker.exists(): + return None + try: + data = load_json(marker) + except BundlerError: + return None + if isinstance(data, dict): + value = data.get("integration") or data.get("id") or data.get("active") + if isinstance(value, str) and value: + return value + return None diff --git a/src/specify_cli/bundler/lib/versioning.py b/src/specify_cli/bundler/lib/versioning.py new file mode 100644 index 0000000000..552f21950c --- /dev/null +++ b/src/specify_cli/bundler/lib/versioning.py @@ -0,0 +1,99 @@ +"""SemVer parsing and constraint evaluation, built on ``packaging`` (already a dependency).""" +from __future__ import annotations + +import re + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +from .. import BundlerError + +# Common SemVer prerelease spellings (``1.2.3-rc1``, ``1.2.3-alpha.1``) that +# PEP 440 / ``packaging`` rejects verbatim. Normalized to PEP 440 before +# parsing so prerelease versions validate consistently (mirrors +# ``specify_cli._version._normalize_tag``). +_PRERELEASE_PATTERN = re.compile( + r"^([0-9]+\.[0-9]+\.[0-9]+)[-.]?(alpha|beta|a|b|rc)[-.]?([0-9]+)(.*)$", + flags=re.IGNORECASE, +) + + +def _normalize_semver(value: str) -> str: + """Normalize common SemVer prerelease spellings into PEP 440 text.""" + text = str(value) + normalized = text[1:] if text[:1] in ("v", "V") else text + match = _PRERELEASE_PATTERN.match(normalized) + if match is None: + return normalized + base, label, number, rest = match.groups() + pep440_label = {"alpha": "a", "beta": "b"}.get(label.lower(), label.lower()) + return f"{base}{pep440_label}{number}{rest}" + + +def parse_version(value: str) -> Version: + """Parse a version string into a comparable :class:`Version`.""" + try: + return Version(_normalize_semver(value)) + except InvalidVersion as exc: + raise BundlerError(f"Invalid version '{value}': {exc}") from exc + + +_SPECIFIER_CLAUSE = re.compile(r"^\s*(===|==|~=|!=|<=|>=|<|>)?\s*(.*?)\s*$") + + +def _normalize_constraint(value: str) -> str: + """Normalize the version portion of each clause in a constraint string. + + ``packaging.SpecifierSet`` rejects SemVer prerelease spellings like + ``>=1.2.3-rc1`` verbatim, even though :func:`parse_version` accepts the same + spelling for installed versions. Normalize each comma-separated clause's + version so prerelease handling is consistent across versions and constraints. + """ + clauses = [] + for raw in str(value).split(","): + if not raw.strip(): + continue + match = _SPECIFIER_CLAUSE.match(raw) + operator, version = match.groups() + clauses.append(f"{operator or ''}{_normalize_semver(version)}") + return ",".join(clauses) + + +def parse_constraint(value: str) -> SpecifierSet: + """Parse a version constraint such as ``>=0.9.0`` into a :class:`SpecifierSet`.""" + try: + return SpecifierSet(_normalize_constraint(value)) + except InvalidSpecifier as exc: + raise BundlerError( + f"Invalid version constraint '{value}': {exc}" + ) from exc + + +def satisfies(installed: str, constraint: str) -> bool: + """Return True if *installed* satisfies *constraint* (e.g. ``">=0.9.0"``). + + Pre-releases are allowed so a dev/pre build of Spec Kit still counts. + """ + spec = parse_constraint(constraint) + version = parse_version(installed) + return spec.contains(version, prereleases=True) + + +_SEMVER_RE = re.compile( + r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +) + + +def is_semver(value: str) -> bool: + """Return True only for a full ``MAJOR.MINOR.PATCH`` SemVer string. + + Stricter than ``packaging.version.Version``, which also accepts partial + versions like ``"1"`` or ``"1.0"``. An optional leading ``v`` or ``V`` is + tolerated (mirrors ``_normalize_semver``). + """ + text = str(value) + core = text[1:] if text[:1] in ("v", "V") else text + return bool(_SEMVER_RE.match(core)) diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py new file mode 100644 index 0000000000..e4cd538a09 --- /dev/null +++ b/src/specify_cli/bundler/lib/yamlio.py @@ -0,0 +1,119 @@ +"""YAML/JSON read-write helpers with path confinement (Constitution Principles IV & V). + +All reads/writes go through these functions so that: +- IO failures degrade into actionable :class:`~specify_cli.bundler.BundlerError`s + rather than raw tracebacks, and +- every path can be confined to an allowed root via :func:`ensure_within`. +""" +from __future__ import annotations + +import json +import os +import re +from pathlib import Path, PurePosixPath +from typing import Any + +import yaml + +from .. import BundlerError + + +def ensure_within(root: Path, candidate: Path) -> Path: + """Resolve *candidate* and guarantee it stays within *root*. + + Refuses path-traversal payloads and symlink escapes. Returns the resolved, + confined path. Raises :class:`BundlerError` if the path escapes *root*. + """ + root_resolved = Path(root).resolve() + # Resolve symlinks so a symlinked component cannot point outside the root. + candidate_resolved = Path(candidate).resolve() + try: + candidate_resolved.relative_to(root_resolved) + except ValueError as exc: + raise BundlerError( + f"Refusing path '{candidate}' — it escapes the allowed root '{root}'." + ) from exc + return candidate_resolved + + +def load_yaml(path: Path) -> Any: + """Parse a YAML file, returning ``{}`` for an empty document.""" + path = Path(path) + if not path.exists(): + raise BundlerError(f"File not found: {path}") + try: + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + except yaml.YAMLError as exc: + raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc + except OSError as exc: + raise BundlerError(f"Could not read {path}: {exc}") from exc + + +def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path: + """Write *data* as YAML to *path* (optionally confined to *within*).""" + path = Path(path) + if within is not None: + path = ensure_within(within, path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False) + except OSError as exc: + raise BundlerError(f"Could not write {path}: {exc}") from exc + return path + + +def load_json(path: Path) -> Any: + """Parse a JSON file.""" + path = Path(path) + if not path.exists(): + raise BundlerError(f"File not found: {path}") + try: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + except json.JSONDecodeError as exc: + raise BundlerError(f"Invalid JSON in {path}: {exc}") from exc + except OSError as exc: + raise BundlerError(f"Could not read {path}: {exc}") from exc + + +def loads_json(text: str, *, origin: str = "") -> Any: + """Parse JSON from a string (used for catalog payloads fetched as text).""" + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise BundlerError(f"Invalid JSON from {origin}: {exc}") from exc + + +def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path: + """Write *data* as pretty JSON to *path* (optionally confined to *within*).""" + path = Path(path) + if within is not None: + path = ensure_within(within, path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=False) + handle.write("\n") + except OSError as exc: + raise BundlerError(f"Could not write {path}: {exc}") from exc + return path + + +def is_safe_relpath(rel: str) -> bool: + """Return True if *rel* is a project-relative path with no traversal/absolute parts. + + Platform-independent: a POSIX-absolute path (``/abs``) or a Windows + drive-absolute path (``C:\\x``) is rejected on every OS, since these strings + can appear in untrusted catalog/manifest data regardless of the host. + """ + if not rel: + return False + normalized = rel.replace("\\", "/") + if os.path.isabs(rel) or normalized.startswith("/"): + return False + if re.match(r"^[A-Za-z]:", normalized): # Windows drive-absolute (C:/...) + return False + parts = PurePosixPath(normalized).parts + return ".." not in parts diff --git a/src/specify_cli/bundler/models/__init__.py b/src/specify_cli/bundler/models/__init__.py new file mode 100644 index 0000000000..2a5136287b --- /dev/null +++ b/src/specify_cli/bundler/models/__init__.py @@ -0,0 +1,2 @@ +"""Bundler data models (manifest, catalog, records).""" +from __future__ import annotations diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py new file mode 100644 index 0000000000..dd069f5bc9 --- /dev/null +++ b/src/specify_cli/bundler/models/catalog.py @@ -0,0 +1,258 @@ +"""Catalog models: source stack (priority + install policy) and catalog entries. + +Mirrors ``contracts/bundle-catalog.schema.md``. The stack precedence is +project > user > built-in; install is permitted only from ``install-allowed`` +sources. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from .. import BundlerError +from ..lib.yamlio import ensure_within, load_yaml + +CONFIG_FILENAME = "bundle-catalogs.yml" + + +class InstallPolicy(str, Enum): + INSTALL_ALLOWED = "install-allowed" + DISCOVERY_ONLY = "discovery-only" + + @classmethod + def parse(cls, value: Any) -> "InstallPolicy": + text = str(value or "").strip() + for policy in cls: + if policy.value == text: + return policy + raise BundlerError( + f"Invalid install_policy '{value}' " + f"(must be one of {[p.value for p in cls]})." + ) + + +class Scope(str, Enum): + PROJECT = "project" + USER = "user" + BUILTIN = "built-in" + + +# Built-in default stack (used when no project/user config overrides it). +BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = ( + {"id": "default", "url": "builtin://default", "priority": 1, + "install_policy": InstallPolicy.INSTALL_ALLOWED.value}, + {"id": "community", "url": "builtin://community", "priority": 2, + "install_policy": InstallPolicy.DISCOVERY_ONLY.value}, +) + + +@dataclass(frozen=True) +class CatalogSource: + id: str + url: str + priority: int + install_policy: InstallPolicy + scope: Scope = Scope.PROJECT + + @property + def install_allowed(self) -> bool: + return self.install_policy is InstallPolicy.INSTALL_ALLOWED + + @classmethod + def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource": + if not isinstance(data, dict): + raise BundlerError("Each catalog source must be a mapping.") + source_id = str(data.get("id", "")).strip() + url = str(data.get("url", "")).strip() + if not source_id: + raise BundlerError("A catalog source is missing its 'id'.") + if not url: + raise BundlerError(f"Catalog source '{source_id}' is missing its 'url'.") + priority = data.get("priority") + if priority is None: + raise BundlerError(f"Catalog source '{source_id}' is missing its 'priority'.") + if isinstance(priority, bool) or not isinstance(priority, (int, str)): + raise BundlerError( + f"Catalog source '{source_id}' has a non-integer priority: {priority!r}." + ) + try: + priority_int = int(priority) + except (TypeError, ValueError): + raise BundlerError( + f"Catalog source '{source_id}' has a non-integer priority: {priority!r}." + ) from None + return cls( + id=source_id, + url=url, + priority=priority_int, + install_policy=InstallPolicy.parse(data.get("install_policy")), + scope=scope, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "url": self.url, + "priority": self.priority, + "install_policy": self.install_policy.value, + } + + +def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]: + """Coerce a catalog entry's ``tags`` into a tuple of strings. + + Catalogs are untrusted input: a bare string would otherwise be iterated + character-by-character, so reject anything that is not a list/tuple. + """ + if value is None: + return () + if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)): + raise BundlerError( + f"Catalog entry '{entry_id}': 'tags' must be a list of strings." + ) + return tuple(str(t) for t in value) + + +def _parse_verified(value: Any, entry_id: str) -> bool: + """Validate a catalog entry's ``verified`` flag is a real boolean. + + ``bool("false")`` is truthy, so coercing arbitrary strings would silently + mark untrusted entries as verified; require an actual boolean instead. + """ + if isinstance(value, bool): + return value + raise BundlerError( + f"Catalog entry '{entry_id}': 'verified' must be a boolean (true/false)." + ) + + +@dataclass(frozen=True) +class CatalogEntry: + id: str + name: str + version: str + role: str + description: str + author: str + license: str + download_url: str + requires_speckit_version: str + provides: dict[str, int] = field(default_factory=dict) + repository: str | None = None + tags: tuple[str, ...] = () + verified: bool = False + # Resolution provenance (filled in by the catalog stack at lookup time): + source_id: str | None = None + source_policy: InstallPolicy | None = None + + @classmethod + def from_dict(cls, data: Any) -> "CatalogEntry": + if not isinstance(data, dict): + raise BundlerError("Each catalog entry must be a mapping.") + entry_id = str(data.get("id", "")).strip() + requires = data.get("requires") or {} + if not isinstance(requires, dict): + raise BundlerError( + f"Catalog entry '{entry_id or ''}': 'requires' must be a " + "mapping when present." + ) + provides_raw = data.get("provides") or {} + if not isinstance(provides_raw, dict): + raise BundlerError( + f"Catalog entry '{entry_id or ''}': 'provides' must be a " + "mapping when present." + ) + return cls( + id=entry_id, + name=str(data.get("name", "")).strip(), + version=str(data.get("version", "")).strip(), + role=str(data.get("role", "")).strip(), + description=str(data.get("description", "")).strip(), + author=str(data.get("author", "")).strip(), + license=str(data.get("license", "")).strip(), + download_url=str(data.get("download_url", "")).strip(), + requires_speckit_version=str(requires.get("speckit_version", "")).strip(), + provides=dict(provides_raw), + repository=(str(data["repository"]) if data.get("repository") else None), + tags=_parse_tags(data.get("tags"), entry_id), + verified=_parse_verified(data.get("verified", False), entry_id), + ) + + def with_provenance(self, source: CatalogSource) -> "CatalogEntry": + return CatalogEntry( + id=self.id, name=self.name, version=self.version, role=self.role, + description=self.description, author=self.author, license=self.license, + download_url=self.download_url, + requires_speckit_version=self.requires_speckit_version, + provides=self.provides, repository=self.repository, tags=self.tags, + verified=self.verified, source_id=source.id, + source_policy=source.install_policy, + ) + + +def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]: + """Parse a catalog JSON payload into ``{bundle_id: CatalogEntry}``.""" + if not isinstance(data, dict): + raise BundlerError("Catalog payload must be a JSON object.") + bundles_raw = data.get("bundles") + if not isinstance(bundles_raw, dict): + raise BundlerError("Catalog payload is missing a 'bundles' object.") + entries: dict[str, CatalogEntry] = {} + for bundle_id, entry_raw in bundles_raw.items(): + key = str(bundle_id) + entry = CatalogEntry.from_dict(entry_raw) + # The enclosing key is the authoritative bundle id used by + # search/resolve/install. Reject entries whose own ``id`` is missing or + # disagrees with the key, so a malformed or malicious catalog can't list + # an id that resolves to a different (or no) bundle. + if not entry.id: + raise BundlerError( + f"Catalog entry for '{key}' is missing its 'id' field." + ) + if entry.id != key: + raise BundlerError( + f"Catalog entry id mismatch: key '{key}' != entry id " + f"'{entry.id}'." + ) + entries[key] = entry + return entries + + +def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -> list[CatalogSource]: + """Build the effective, priority-sorted source stack (project > user > built-in). + + A source id present at a higher-precedence scope overrides the same id at a + lower scope. The built-in default stack is always the fallback. + """ + by_id: dict[str, CatalogSource] = {} + + # Lowest precedence first; later writes override earlier ones for the same id. + for raw in BUILTIN_DEFAULT_STACK: + src = CatalogSource.from_dict(raw, Scope.BUILTIN) + by_id[src.id] = src + + if user_config_dir is not None: + _merge_config(by_id, Path(user_config_dir) / CONFIG_FILENAME, Scope.USER) + + # Confine the project-scoped read: refuse a symlinked .specify/ that + # resolves outside the project root (consistent with other guarded reads). + project_config = Path(project_root) / ".specify" / CONFIG_FILENAME + if project_config.exists(): + ensure_within(project_root, project_config) + _merge_config(by_id, project_config, Scope.PROJECT) + + return sorted(by_id.values(), key=lambda s: (s.priority, s.id)) + + +def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None: + if not config_path.exists(): + return + data = load_yaml(config_path) + catalogs = data.get("catalogs") if isinstance(data, dict) else None + if not catalogs: + return + for raw in catalogs: + src = CatalogSource.from_dict(raw, scope) + by_id[src.id] = src diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py new file mode 100644 index 0000000000..4a903fbd18 --- /dev/null +++ b/src/specify_cli/bundler/models/manifest.py @@ -0,0 +1,263 @@ +"""Bundle manifest model (``bundle.yml``) — parsing and structural normalization. + +Mirrors ``contracts/bundle-manifest.schema.md``. Structural validation (shape, +required fields, enum/semver checks) lives here; *reference* resolution against a +catalog stack lives in the validator/resolver services. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .. import BundlerError +from ..lib.versioning import is_semver +from ..lib.yamlio import load_yaml + +SUPPORTED_SCHEMA_VERSIONS = {"1.0"} +PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} + +COMPONENT_KINDS = ("extensions", "presets", "steps", "workflows") + +# A bundle id must be a filesystem-safe slug: it is interpolated into artifact +# filenames (e.g. ``-.zip``), so path separators or traversal +# segments must never appear. +_SAFE_BUNDLE_ID = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$") + + +@dataclass(frozen=True) +class ComponentRef: + """A pointer to an existing Spec Kit primitive a bundle installs.""" + + kind: str # one of COMPONENT_KINDS (singularized concept), stored plural-of-origin + id: str + version: str | None = None + source: str | None = None + priority: int | None = None # presets only + strategy: str | None = None # presets only + + def label(self) -> str: + return f"{self.kind[:-1]}:{self.id}@{self.version or 'unpinned'}" + + +@dataclass(frozen=True) +class IntegrationRef: + id: str + + +@dataclass(frozen=True) +class Requires: + speckit_version: str + tools: tuple[str, ...] = () + mcp: tuple[str, ...] = () + + +@dataclass(frozen=True) +class BundleMeta: + id: str + name: str + version: str + role: str + description: str + author: str + license: str + + +@dataclass +class BundleManifest: + schema_version: str + bundle: BundleMeta + requires: Requires + integration: IntegrationRef | None = None + extensions: list[ComponentRef] = field(default_factory=list) + presets: list[ComponentRef] = field(default_factory=list) + steps: list[ComponentRef] = field(default_factory=list) + workflows: list[ComponentRef] = field(default_factory=list) + tags: tuple[str, ...] = () + source_path: Path | None = None + + @property + def components(self) -> list[ComponentRef]: + """All installable component references in deterministic order.""" + return [*self.extensions, *self.presets, *self.steps, *self.workflows] + + # -- construction --------------------------------------------------------- + + @classmethod + def from_file(cls, path: Path) -> "BundleManifest": + data = load_yaml(path) + manifest = cls.from_dict(data) + manifest.source_path = Path(path) + return manifest + + @classmethod + def from_dict(cls, data: Any) -> "BundleManifest": + if not isinstance(data, dict): + raise BundlerError("Manifest must be a YAML mapping at the top level.") + + schema_version = str(data.get("schema_version", "")).strip() + + bundle_raw = data.get("bundle") + if not isinstance(bundle_raw, dict): + raise BundlerError("Manifest is missing the required 'bundle' mapping.") + meta = BundleMeta( + id=str(bundle_raw.get("id", "")).strip(), + name=str(bundle_raw.get("name", "")).strip(), + version=str(bundle_raw.get("version", "")).strip(), + role=str(bundle_raw.get("role", "")).strip(), + description=str(bundle_raw.get("description", "")).strip(), + author=str(bundle_raw.get("author", "")).strip(), + license=str(bundle_raw.get("license", "")).strip(), + ) + + requires_raw = data.get("requires") or {} + if not isinstance(requires_raw, dict): + raise BundlerError("'requires' must be a mapping when present.") + requires = Requires( + speckit_version=str(requires_raw.get("speckit_version", "")).strip(), + tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"), + mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"), + ) + + integration = None + integration_raw = data.get("integration") + if isinstance(integration_raw, dict) and integration_raw.get("id"): + integration = IntegrationRef(id=str(integration_raw["id"]).strip()) + + provides = data.get("provides") or {} + if not isinstance(provides, dict): + raise BundlerError("'provides' must be a mapping when present.") + + tags_raw = data.get("tags") + if tags_raw is None: + tags_raw = [] + else: + tags_raw = _parse_str_list(tags_raw, "tags") + + manifest = cls( + schema_version=schema_version, + bundle=meta, + requires=requires, + integration=integration, + extensions=_parse_refs("extensions", provides.get("extensions")), + presets=_parse_refs("presets", provides.get("presets")), + steps=_parse_refs("steps", provides.get("steps")), + workflows=_parse_refs("workflows", provides.get("workflows")), + tags=tuple(str(t) for t in tags_raw), + ) + return manifest + + # -- structural validation ------------------------------------------------ + + def structural_errors(self) -> list[str]: + """Return a list of human-readable structural problems (empty == valid).""" + errors: list[str] = [] + + if self.schema_version not in SUPPORTED_SCHEMA_VERSIONS: + errors.append( + f"schema_version '{self.schema_version or ''}' is not supported " + f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})." + ) + + required = { + "bundle.id": self.bundle.id, + "bundle.name": self.bundle.name, + "bundle.version": self.bundle.version, + "bundle.role": self.bundle.role, + "bundle.description": self.bundle.description, + "bundle.author": self.bundle.author, + "bundle.license": self.bundle.license, + "requires.speckit_version": self.requires.speckit_version, + } + for field_path, value in required.items(): + if not value: + errors.append(f"Missing required field: {field_path}.") + + if self.bundle.version and not is_semver(self.bundle.version): + errors.append(f"bundle.version '{self.bundle.version}' is not valid semver.") + + if self.bundle.id and not _SAFE_BUNDLE_ID.match(self.bundle.id): + errors.append( + f"bundle.id '{self.bundle.id}' must be a slug " + "(lowercase letters, digits, '.', '_', '-'; no path separators)." + ) + + for ref in self.components: + if not ref.id: + errors.append(f"A {ref.kind[:-1]} entry is missing its 'id'.") + if ref.kind != "steps" and not ref.version: + errors.append( + f"{ref.kind[:-1]} '{ref.id or ''}' must be pinned to a 'version'." + ) + if ref.version and not is_semver(ref.version): + errors.append( + f"{ref.kind[:-1]} '{ref.id}' has invalid version '{ref.version}'." + ) + + for ref in self.presets: + if ref.priority is None: + errors.append(f"preset '{ref.id}' must declare an integer 'priority'.") + if ref.strategy is None or ref.strategy not in PRESET_STRATEGIES: + errors.append( + f"preset '{ref.id}' has invalid strategy '{ref.strategy}' " + f"(must be one of {sorted(PRESET_STRATEGIES)})." + ) + + return errors + + def is_agnostic(self) -> bool: + """True when the bundle declares no integration (inherits the active one).""" + return self.integration is None + + +def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]: + """Coerce a manifest list-of-strings field into a tuple of strings. + + Rejects a bare string/bytes (which would otherwise be iterated + character-by-character) and any non-list/tuple, matching the manifest + contract (``string[]``). + """ + if raw is None: + return () + if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): + raise BundlerError(f"'{field_name}' must be a list of strings when present.") + return tuple(str(item) for item in raw) + + +def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]: + if raw is None: + return [] + if not isinstance(raw, list): + raise BundlerError(f"provides.{kind} must be a list when present.") + refs: list[ComponentRef] = [] + for item in raw: + if not isinstance(item, dict): + raise BundlerError(f"Each provides.{kind} entry must be a mapping.") + priority = _parse_priority(kind, item.get("priority")) + refs.append( + ComponentRef( + kind=kind, + id=str(item.get("id", "")).strip(), + version=(str(item["version"]).strip() if item.get("version") else None), + source=(str(item["source"]).strip() if item.get("source") else None), + priority=priority, + strategy=(str(item["strategy"]).strip() if item.get("strategy") else None), + ) + ) + return refs + + +def _parse_priority(kind: str, raw: Any) -> int | None: + if raw is None: + return None + if isinstance(raw, bool) or not isinstance(raw, (int, str)): + raise BundlerError( + f"provides.{kind} priority must be an integer, got {raw!r}." + ) + try: + return int(raw) + except (TypeError, ValueError): + raise BundlerError( + f"provides.{kind} priority must be an integer, got {raw!r}." + ) from None diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundler/models/records.py new file mode 100644 index 0000000000..15c53523c3 --- /dev/null +++ b/src/specify_cli/bundler/models/records.py @@ -0,0 +1,229 @@ +"""Installed-bundle records — provenance for precise list/remove/update. + +Records are stored as JSON at ``.specify/bundle-records.json``. Each record +captures exactly which components a bundle contributed so removal touches only +that bundle's components and never collateral (FR-022, SC-004). +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .. import BundlerError +from ..lib.yamlio import dump_json, ensure_within, load_json +from .manifest import COMPONENT_KINDS, ComponentRef + +RECORDS_FILENAME = "bundle-records.json" +RECORDS_SCHEMA_VERSION = "1.0" + + +@dataclass(frozen=True) +class InstalledBundleRecord: + bundle_id: str + version: str + contributed_components: tuple[ComponentRef, ...] + installed_at: str + + @classmethod + def create( + cls, + bundle_id: str, + version: str, + components: list[ComponentRef], + installed_at: str | None = None, + ) -> "InstalledBundleRecord": + return cls( + bundle_id=bundle_id, + version=version, + contributed_components=tuple(components), + installed_at=installed_at or _utc_now(), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "bundle_id": self.bundle_id, + "version": self.version, + "installed_at": self.installed_at, + "contributed_components": [ + _component_to_dict(c) for c in self.contributed_components + ], + } + + @classmethod + def from_dict(cls, data: Any) -> "InstalledBundleRecord": + if not isinstance(data, dict): + raise BundlerError("Each installed-bundle record must be a mapping.") + components_raw = data.get("contributed_components") or [] + if not isinstance(components_raw, list): + raise BundlerError( + "Corrupt record: 'contributed_components' must be a list." + ) + bundle_id = str(data.get("bundle_id", "")).strip() + version = str(data.get("version", "")).strip() + if not bundle_id: + raise BundlerError( + "Corrupt records file: an installed-bundle record is missing " + "its 'bundle_id'." + ) + if not version: + raise BundlerError( + f"Corrupt records file: record for bundle '{bundle_id}' is " + "missing its 'version'." + ) + return cls( + bundle_id=bundle_id, + version=version, + installed_at=str(data.get("installed_at", "")).strip(), + contributed_components=tuple( + _component_from_dict(c) for c in components_raw + ), + ) + + +def records_path(project_root: Path) -> Path: + return Path(project_root) / ".specify" / RECORDS_FILENAME + + +def _check_schema_version(value: Any, *, path: Path, required: bool) -> None: + """Reject a records file whose schema version we cannot safely parse. + + A future incompatible format (or a corrupted file) must fail fast with an + actionable error rather than being silently mis-parsed, which could lead to + incorrect bundle attribution or removal. Forward-compatible minor bumps that + keep the same major version are accepted. + """ + if value is None: + if required: + raise BundlerError( + f"Corrupt records file: {path} — missing 'schema_version'. " + f"Expected version {RECORDS_SCHEMA_VERSION}." + ) + return + seen = str(value).strip() + if seen.split(".")[0] != RECORDS_SCHEMA_VERSION.split(".")[0]: + raise BundlerError( + f"Unsupported records schema version '{seen}' at {path}; this " + f"Spec Kit understands version {RECORDS_SCHEMA_VERSION}. The file may " + "have been written by a newer version or is corrupt." + ) + + +def load_records(project_root: Path) -> list[InstalledBundleRecord]: + # Defense in depth (mirrors the write path's within= confinement): refuse to + # read through a symlinked or traversal-escaping ``.specify`` that resolves + # outside project_root. + path = ensure_within(project_root, records_path(project_root)) + if not path.exists(): + return [] + data = load_json(path) + if not isinstance(data, dict): + raise BundlerError(f"Corrupt records file: {path}") + _check_schema_version(data.get("schema_version"), path=path, required=True) + bundles = data.get("bundles") or [] + if not isinstance(bundles, list): + raise BundlerError( + f"Corrupt records file: {path} — 'bundles' must be a list." + ) + return [InstalledBundleRecord.from_dict(item) for item in bundles] + + +def save_records(project_root: Path, records: list[InstalledBundleRecord]) -> None: + payload = { + "schema_version": RECORDS_SCHEMA_VERSION, + "updated_at": _utc_now(), + "bundles": [r.to_dict() for r in records], + } + dump_json(records_path(project_root), payload, within=project_root) + + +def find_record( + records: list[InstalledBundleRecord], bundle_id: str +) -> InstalledBundleRecord | None: + for record in records: + if record.bundle_id == bundle_id: + return record + return None + + +def upsert_record( + records: list[InstalledBundleRecord], record: InstalledBundleRecord +) -> list[InstalledBundleRecord]: + """Return a new list with *record* replacing any same-id record (append otherwise).""" + updated = [r for r in records if r.bundle_id != record.bundle_id] + updated.append(record) + return updated + + +def remove_record( + records: list[InstalledBundleRecord], bundle_id: str +) -> list[InstalledBundleRecord]: + return [r for r in records if r.bundle_id != bundle_id] + + +def components_still_needed( + records: list[InstalledBundleRecord], exclude_bundle_id: str +) -> set[tuple[str, str]]: + """Set of ``(kind, id)`` component keys required by bundles other than the excluded one.""" + needed: set[tuple[str, str]] = set() + for record in records: + if record.bundle_id == exclude_bundle_id: + continue + for component in record.contributed_components: + needed.add((component.kind, component.id)) + return needed + + +def _component_to_dict(ref: ComponentRef) -> dict[str, Any]: + data: dict[str, Any] = {"kind": ref.kind, "id": ref.id} + if ref.version is not None: + data["version"] = ref.version + if ref.source is not None: + data["source"] = ref.source + if ref.priority is not None: + data["priority"] = ref.priority + if ref.strategy is not None: + data["strategy"] = ref.strategy + return data + + +def _component_from_dict(data: Any) -> ComponentRef: + if not isinstance(data, dict): + raise BundlerError("Each contributed component must be a mapping.") + kind = str(data.get("kind", "")).strip() + cid = str(data.get("id", "")).strip() + if kind not in COMPONENT_KINDS: + raise BundlerError( + f"Corrupt records file: component 'kind' must be one of " + f"{list(COMPONENT_KINDS)}, got {kind or ''!r}." + ) + if not cid: + raise BundlerError( + "Corrupt records file: a contributed component is missing its 'id'." + ) + return ComponentRef( + kind=kind, + id=cid, + version=(str(data["version"]) if data.get("version") else None), + source=(str(data["source"]) if data.get("source") else None), + priority=_parse_priority(data.get("priority")), + strategy=(str(data["strategy"]) if data.get("strategy") else None), + ) + + +def _parse_priority(raw: Any) -> int | None: + if raw is None: + return None + if isinstance(raw, bool) or not isinstance(raw, (int, str)): + raise BundlerError(f"Component priority must be an integer, got {raw!r}.") + try: + return int(raw) + except (TypeError, ValueError): + raise BundlerError( + f"Component priority must be an integer, got {raw!r}." + ) from None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/src/specify_cli/bundler/services/__init__.py b/src/specify_cli/bundler/services/__init__.py new file mode 100644 index 0000000000..1db5b56614 --- /dev/null +++ b/src/specify_cli/bundler/services/__init__.py @@ -0,0 +1,2 @@ +"""Bundler services (catalog stack, resolver, installer, conflict, validator, packager).""" +from __future__ import annotations diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py new file mode 100644 index 0000000000..17c9d3324c --- /dev/null +++ b/src/specify_cli/bundler/services/adapters.py @@ -0,0 +1,193 @@ +"""Concrete adapters: catalog fetching and primitive installation. + +These wire the bundler's injectable seams to the real environment: + +* :func:`make_catalog_fetcher` returns an offline-first fetcher that reads + built-in catalogs and local/pinned file URLs without network, and falls back + to a timeout-bounded HTTP GET only for ``http(s)://`` sources. +* :class:`DefaultPrimitiveInstaller` dispatches component install/remove to the + existing Spec Kit primitive machinery in-process. +""" +from __future__ import annotations + +import re +from pathlib import Path +from urllib.parse import ParseResult, urlparse +from urllib.request import url2pathname + +from .. import BundlerError +from ..lib.yamlio import loads_json +from ..models.catalog import CatalogSource +from ..models.manifest import ComponentRef + +# Built-in catalog payloads ship empty by default; a host distribution can +# replace these with curated content. Keeping them here makes ``search``/``info`` +# work fully offline against the default stack. +_BUILTIN_CATALOGS: dict[str, dict] = { + "builtin://default": { + "schema_version": "1.0", + "catalog_url": "builtin://default", + "bundles": {}, + }, + "builtin://community": { + "schema_version": "1.0", + "catalog_url": "builtin://community", + "bundles": {}, + }, +} + +HTTP_TIMEOUT_SECONDS = 10 + +# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter +# ``scheme`` under urlparse; treat them as local files rather than URLs. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") + + +def _is_windows_drive_path(url: str) -> bool: + return bool(_WINDOWS_DRIVE_RE.match(url)) + + +def _file_url_to_path(parsed: ParseResult) -> Path: + """Convert a ``file://`` URL to a local path. + + Uses ``url2pathname`` for percent-decoding and OS-correct separators, and + preserves ``netloc`` so UNC paths (``file://server/share``) and Windows + drive URLs (``file:///C:/x``) resolve correctly instead of dropping host + or producing ``/C:/x``. + """ + netloc = parsed.netloc + if netloc and netloc.lower() != "localhost": + # UNC share: file://server/share/... -> \\server\share\... + return Path(url2pathname(f"//{netloc}{parsed.path}")) + return Path(url2pathname(parsed.path)) + + +def _validate_remote_url(source_id: str, url: str) -> None: + """Restrict remote catalogs to HTTPS (HTTP only for localhost) with a host. + + Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade + issues before any network call. + """ + 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 BundlerError( + f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise BundlerError( + f"Catalog '{source_id}' URL must be a valid URL with a host: {url}" + ) + + +def make_catalog_fetcher(*, allow_network: bool = True): + """Return a fetcher callable suitable for :class:`CatalogStack`. + + When *allow_network* is False, ``http(s)://`` sources raise instead of + touching the network (used by offline tests and ``--offline`` flows). + """ + + def fetch(source: CatalogSource) -> dict: + url = source.url + parsed = urlparse(url) + scheme = parsed.scheme.lower() + + if scheme == "builtin": + payload = _BUILTIN_CATALOGS.get(url) + if payload is None: + raise BundlerError(f"Unknown built-in catalog '{url}'.") + return payload + + if scheme == "file": + path = _file_url_to_path(parsed) + if not path.exists(): + raise BundlerError(f"Catalog file not found: {path}") + return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) + + if scheme == "" or _is_windows_drive_path(url): + path = Path(url) + if not path.exists(): + raise BundlerError(f"Catalog file not found: {path}") + return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) + + if scheme in ("http", "https"): + if not allow_network: + raise BundlerError( + f"Network access disabled; cannot fetch catalog '{source.id}' " + f"from {url}." + ) + _validate_remote_url(source.id, url) + return _http_get_json(source.id, url) + + raise BundlerError(f"Unsupported catalog URL scheme: {url}") + + return fetch + + +def _http_get_json(source_id: str, url: str) -> dict: + """Fetch catalog JSON over HTTP(S) via the shared authenticated client. + + Routing through :func:`specify_cli.authentication.http.open_url` gives + ``auth.json`` token support and strips the ``Authorization`` header when a + redirect leaves the entry's trusted hosts or downgrades the scheme. We also + reject any redirect that leaves HTTPS (the ``redirect_validator`` runs + *before* each hop) and re-validate the final URL after redirects, so the + HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end + rather than only on the initial URL. + """ + from ...authentication.http import open_url + + def _validate_redirect(_old_url: str, new_url: str) -> None: + _validate_remote_url(source_id, new_url) + + try: + with open_url( + url, + timeout=HTTP_TIMEOUT_SECONDS, + redirect_validator=_validate_redirect, + ) as response: + final_url = response.geturl() + _validate_remote_url(source_id, final_url) + raw = response.read().decode("utf-8") + except BundlerError: + raise + except Exception as exc: # noqa: BLE001 + raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc + return loads_json(raw, origin=final_url) + + +class DefaultPrimitiveInstaller: + """Dispatch component install/remove to existing primitive machinery. + + This adapter is intentionally thin: it owns no install logic of its own, + delegating entirely to the per-primitive managers so the bundler honours + Principle I (no duplicated primitive logic). + + *allow_network* mirrors the bundle command's ``--offline`` flag: when False, + component kinds that can only be sourced from a remote catalog refuse rather + than touching the network. Bundled presets/extensions still install offline. + """ + + def __init__(self, *, allow_network: bool = True) -> None: + self._allow_network = allow_network + + def is_installed(self, project_root: Path, component: ComponentRef) -> bool: + manager = self._manager_for(component, project_root) + return manager.is_installed(component) + + def install(self, project_root: Path, component: ComponentRef) -> None: + manager = self._manager_for(component, project_root) + manager.install(component) + + def remove(self, project_root: Path, component: ComponentRef) -> None: + manager = self._manager_for(component, project_root) + manager.remove(component) + + def _manager_for(self, component: ComponentRef, project_root: Path): + # Lazy import to avoid import cycles and keep startup cheap (Principle IV). + from .primitives import primitive_manager + + return primitive_manager( + component.kind, project_root, allow_network=self._allow_network + ) diff --git a/src/specify_cli/bundler/services/catalog_stack.py b/src/specify_cli/bundler/services/catalog_stack.py new file mode 100644 index 0000000000..d1e7fddde9 --- /dev/null +++ b/src/specify_cli/bundler/services/catalog_stack.py @@ -0,0 +1,114 @@ +"""Catalog stack: aggregate bundle entries across sources with precedence + policy. + +Loads each source's catalog payload (via an injectable fetcher so tests stay +offline), then resolves a bundle id to the highest-precedence entry while +recording whether installation is permitted by that source's policy. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .. import BundlerError +from ..models.catalog import ( + CatalogEntry, + CatalogSource, + load_catalog_payload, + load_source_stack, +) + +# A fetcher returns the raw JSON payload (a dict) for a given source. +CatalogFetcher = Callable[[CatalogSource], dict] + + +@dataclass +class ResolvedBundle: + entry: CatalogEntry + source: CatalogSource + + @property + def install_allowed(self) -> bool: + return self.source.install_allowed + + +class CatalogStack: + def __init__( + self, + sources: list[CatalogSource], + fetcher: CatalogFetcher, + ) -> None: + # Highest precedence (lowest priority number) first. + self._sources = sorted(sources, key=lambda s: (s.priority, s.id)) + self._fetcher = fetcher + self._payloads: dict[str, dict[str, CatalogEntry]] = {} + + @classmethod + def load( + cls, + project_root: Path, + fetcher: CatalogFetcher, + user_config_dir: Path | None = None, + ) -> "CatalogStack": + sources = load_source_stack(project_root, user_config_dir) + return cls(sources, fetcher) + + @property + def sources(self) -> list[CatalogSource]: + return list(self._sources) + + def _entries_for(self, source: CatalogSource) -> dict[str, CatalogEntry]: + if source.id not in self._payloads: + try: + raw = self._fetcher(source) + except BundlerError: + raise + except Exception as exc: # noqa: BLE001 - surface as chained BundlerError + raise BundlerError( + f"Failed to load catalog '{source.id}' ({source.url}): {exc}" + ) from exc + self._payloads[source.id] = load_catalog_payload(raw) + return self._payloads[source.id] + + def resolve(self, bundle_id: str) -> ResolvedBundle: + """Return the highest-precedence entry for *bundle_id* or raise.""" + for source in self._sources: + entries = self._entries_for(source) + entry = entries.get(bundle_id) + if entry is not None: + return ResolvedBundle(entry=entry.with_provenance(source), source=source) + raise BundlerError( + f"Bundle '{bundle_id}' was not found in any configured catalog." + ) + + def search(self, query: str = "") -> list[ResolvedBundle]: + """Return entries matching *query* (substring over id/name/role/tags/description). + + Each bundle id appears once, resolved at its highest-precedence source. + Results are sorted by bundle id for deterministic output. + """ + needle = query.strip().lower() + seen: dict[str, ResolvedBundle] = {} + for source in self._sources: + for bundle_id, entry in self._entries_for(source).items(): + if bundle_id in seen: + continue + if needle and not _matches(entry, needle): + continue + seen[bundle_id] = ResolvedBundle( + entry=entry.with_provenance(source), source=source + ) + return [seen[k] for k in sorted(seen)] + + +def _matches(entry: CatalogEntry, needle: str) -> bool: + haystack = " ".join( + [ + entry.id, + entry.name, + entry.role, + entry.description, + " ".join(entry.tags), + ] + ).lower() + return needle in haystack diff --git a/src/specify_cli/bundler/services/conflict.py b/src/specify_cli/bundler/services/conflict.py new file mode 100644 index 0000000000..e7cf356283 --- /dev/null +++ b/src/specify_cli/bundler/services/conflict.py @@ -0,0 +1,54 @@ +"""Conflict detection across the installed-bundle stack. + +The single cross-bundle conflict point is the active integration (FR-019). +Component-level overlaps (same preset id at different priorities, etc.) are +resolved by the existing primitive machinery's own precedence rules, so the +bundler only needs to guard the integration invariant and surface informational +overlaps. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..models.manifest import BundleManifest +from ..models.records import InstalledBundleRecord + + +@dataclass +class ConflictReport: + integration_clash: str | None = None # message when a hard clash exists + overlaps: list[str] = field(default_factory=list) # components already provided + + @property + def has_blocking_conflict(self) -> bool: + return self.integration_clash is not None + + +def detect_conflicts( + manifest: BundleManifest, + active_integration: str | None, + installed: list[InstalledBundleRecord], +) -> ConflictReport: + report = ConflictReport() + + if manifest.integration is not None and active_integration: + if manifest.integration.id != active_integration: + report.integration_clash = ( + f"Bundle targets integration '{manifest.integration.id}' but the " + f"project's active integration is '{active_integration}'." + ) + + already: dict[tuple[str, str], str] = {} + for record in installed: + for component in record.contributed_components: + already[(component.kind, component.id)] = record.bundle_id + + for component in manifest.components: + owner = already.get((component.kind, component.id)) + if owner and owner != manifest.bundle.id: + report.overlaps.append( + f"{component.kind[:-1]} '{component.id}' is already provided by " + f"bundle '{owner}'." + ) + + return report diff --git a/src/specify_cli/bundler/services/installer.py b/src/specify_cli/bundler/services/installer.py new file mode 100644 index 0000000000..10f49ef94b --- /dev/null +++ b/src/specify_cli/bundler/services/installer.py @@ -0,0 +1,210 @@ +"""Installer: apply an :class:`InstallPlan` via existing primitive machinery. + +The actual component installation (extensions, presets, steps, workflows) is +delegated to a :class:`PrimitiveInstaller` so the bundler never re-implements +primitive logic (Principle I) and integration tests can inject a deterministic, +offline fake (Principle II/IV). The real adapter dispatches in-process to the +existing extension/preset/step/workflow machinery. + +Installation is idempotent and stops on first failure with no partial record +write (FR-018, SC partial-failure-stop). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + +from .. import BundlerError +from ..models.manifest import BundleManifest, ComponentRef +from ..models.records import ( + InstalledBundleRecord, + components_still_needed, + find_record, + load_records, + remove_record, + save_records, + upsert_record, +) +from .conflict import detect_conflicts +from .resolver import InstallPlan + + +class PrimitiveInstaller(Protocol): + """Adapter over the existing Spec Kit primitive install/remove machinery.""" + + def is_installed(self, project_root: Path, component: ComponentRef) -> bool: ... + + def install(self, project_root: Path, component: ComponentRef) -> None: ... + + def remove(self, project_root: Path, component: ComponentRef) -> None: ... + + +@dataclass +class InstallResult: + bundle_id: str + installed: list[ComponentRef] = field(default_factory=list) + skipped: list[ComponentRef] = field(default_factory=list) + refreshed: list[ComponentRef] = field(default_factory=list) + uninstalled: list[ComponentRef] = field(default_factory=list) + + @property + def changed(self) -> bool: + return bool(self.installed or self.refreshed) + + +def install_bundle( + project_root: Path, + plan: InstallPlan, + installer: PrimitiveInstaller, + manifest: BundleManifest | None = None, + refresh: bool = False, +) -> InstallResult: + """Execute *plan*, recording provenance. Idempotent, with bounded rollback. + + Atomicity is scoped, not global: on failure only the components newly + installed during *this* call are rolled back, and the provenance record is + written solely on full success (a failure records nothing). Components that + were already installed beforehand — including those re-applied when *refresh* + is True — are never rolled back. + + When *refresh* is True (used by ``specify bundle update``), components that + are already installed are re-applied through the primitive machinery so they + are brought up to the plan's pinned versions, rather than skipped. Primitive + config (e.g. preset priority overrides) is preserved by the underlying + machinery. + + Version-pin enforcement is install-time only. The primitive ``is_installed`` + checks are id-based (they do not compare versions), so when a component is + already present and *refresh* is False it is skipped without verifying that + the on-disk version matches the manifest pin. Pins are therefore only + guaranteed to be applied when the bundler actually performs an install or a + refresh; running ``specify bundle update`` re-applies every owned component + at its pinned version. + """ + records = load_records(project_root) + + if manifest is not None: + report = detect_conflicts(manifest, plan.effective_integration, records) + if report.has_blocking_conflict: + raise BundlerError(report.integration_clash) + + result = InstallResult(bundle_id=plan.bundle_id) + existing = find_record(records, plan.bundle_id) + prior_ours = { + (c.kind, c.id) for c in existing.contributed_components + } if existing is not None else set() + # Components already attributed to a *different* installed bundle: these are + # legitimately shareable (refcounted on removal), so this bundle may also + # claim them. A component that is installed on disk but tracked by no bundle + # was installed independently and must NOT be attributed here — otherwise + # removing this bundle would uninstall it (collateral removal, FR-022). + other_tracked = { + (c.kind, c.id) + for r in records + if r.bundle_id != plan.bundle_id + for c in r.contributed_components + } + + contributed: list[ComponentRef] = [] + done: list[ComponentRef] = [] + try: + for component in plan.components: + key = (component.kind, component.id) + if installer.is_installed(project_root, component): + # A component is "ours" only when this bundle (or a sibling + # bundle) already owns it. Independently-installed components + # are never attributed and — crucially — never refreshed, so + # ``bundle update`` cannot make collateral changes to things it + # does not own (FR-022). + owned = key in prior_ours or key in other_tracked + if refresh and owned: + _refresh_component(project_root, installer, component) + result.refreshed.append(component) + else: + result.skipped.append(component) + if owned: + contributed.append(component) + continue + installer.install(project_root, component) + done.append(component) + result.installed.append(component) + contributed.append(component) + except BundlerError: + _rollback(project_root, installer, done) + raise + except Exception as exc: # noqa: BLE001 + _rollback(project_root, installer, done) + raise BundlerError( + f"Failed to install bundle '{plan.bundle_id}': {exc}. " + "No changes were recorded." + ) from exc + + record = InstalledBundleRecord.create( + bundle_id=plan.bundle_id, + version=plan.version, + components=contributed, + # Preserve the original install time across refresh/update so + # ``bundle list`` keeps reporting when the bundle was first installed. + installed_at=existing.installed_at if existing is not None else None, + ) + save_records(project_root, upsert_record(records, record)) + return result + + +def remove_bundle( + project_root: Path, + bundle_id: str, + installer: PrimitiveInstaller, +) -> InstallResult: + """Remove a bundle, uninstalling only components no other bundle still needs.""" + records = load_records(project_root) + target = next((r for r in records if r.bundle_id == bundle_id), None) + if target is None: + raise BundlerError(f"Bundle '{bundle_id}' is not installed.") + + still_needed = components_still_needed(records, exclude_bundle_id=bundle_id) + result = InstallResult(bundle_id=bundle_id) + + for component in target.contributed_components: + key = (component.kind, component.id) + if key in still_needed: + result.skipped.append(component) + continue + if installer.is_installed(project_root, component): + installer.remove(project_root, component) + result.uninstalled.append(component) + else: + result.skipped.append(component) + + save_records(project_root, remove_record(records, bundle_id)) + return result + + +def _refresh_component( + project_root: Path, + installer: PrimitiveInstaller, + component: ComponentRef, +) -> None: + """Re-apply an already-installed component to bring it up to its pinned version. + + Prefers a primitive-provided ``refresh`` hook when available; otherwise falls + back to a re-install through the existing idempotent install path. + """ + op = getattr(installer, "refresh", None) + if callable(op): + op(project_root, component) + else: + installer.install(project_root, component) + + +def _rollback( + project_root: Path, + installer: PrimitiveInstaller, + done: list[ComponentRef], +) -> None: + for component in reversed(done): + try: + installer.remove(project_root, component) + except Exception: # noqa: BLE001 - best-effort rollback + continue diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundler/services/packager.py new file mode 100644 index 0000000000..481a0a2bb8 --- /dev/null +++ b/src/specify_cli/bundler/services/packager.py @@ -0,0 +1,145 @@ +"""Packager: produce a single versioned distributable artifact from a bundle dir. + +``specify bundle build`` zips the manifest, README, and any local assets into +``-.zip``. Build refuses on an invalid manifest, pointing the +author to ``validate``. All file reads are confined within the bundle source +directory (Principle V path confinement). +""" +from __future__ import annotations + +import os +import re +import zipfile +from dataclasses import dataclass +from pathlib import Path + +from .. import BundlerError +from ..lib.yamlio import ensure_within +from ..models.manifest import BundleManifest +from .validator import validate_manifest + +# Files/dirs never included in an artifact. +EXCLUDE_NAMES = {".git", "__pycache__", ".DS_Store"} + +# Fixed member timestamp (zip epoch) for reproducible, byte-stable artifacts. +_FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0) + + +@dataclass +class BuildResult: + artifact_path: Path + file_count: int + + +def build_bundle( + bundle_dir: Path, + output_dir: Path | None = None, +) -> BuildResult: + bundle_dir = Path(bundle_dir).resolve() + manifest_path = bundle_dir / "bundle.yml" + if not manifest_path.exists(): + raise BundlerError(f"No bundle.yml found in '{bundle_dir}'.") + + # The artifact contract requires a human-facing README.md alongside the + # manifest; refuse early rather than publish a bundle with no description. + if not (bundle_dir / "README.md").exists(): + raise BundlerError( + f"No README.md found in '{bundle_dir}'. Every bundle must ship a " + "README.md describing it." + ) + + manifest = BundleManifest.from_file(manifest_path) + report = validate_manifest(manifest) + if not report.ok: + raise BundlerError( + "Refusing to build an invalid manifest. Run 'specify bundle validate' " + "and fix:\n - " + "\n - ".join(report.errors) + ) + + out_dir = Path(output_dir).resolve() if output_dir else bundle_dir + out_dir.mkdir(parents=True, exist_ok=True) + artifact_name = f"{manifest.bundle.id}-{manifest.bundle.version}.zip" + artifact_path = out_dir / artifact_name + # Defense in depth: even though validate_manifest() rejects unsafe ids, make + # sure a crafted id cannot push the artifact outside the output directory. + ensure_within(out_dir, artifact_path) + + # If the output dir lives inside the bundle, skip its whole subtree so + # previously-built artifacts are never re-packaged (keeps builds + # reproducible and bounded). + skip_dir = out_dir if out_dir != bundle_dir and _is_within(bundle_dir, out_dir) else None + # Also skip any prior build artifact for this bundle (e.g. an older + # -.zip sitting next to bundle.yml), not just the current one. + # Match only a semver-looking version segment so legitimate assets that + # merely start with the bundle id (e.g. -assets.zip) are still packaged. + artifact_re = re.compile( + rf"^{re.escape(manifest.bundle.id)}-" + r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\.zip$" + ) + files = _collect_files( + bundle_dir, skip=artifact_path, skip_dir=skip_dir, artifact_re=artifact_re + ) + with zipfile.ZipFile(artifact_path, "w", zipfile.ZIP_DEFLATED) as archive: + for file_path in files: + # Confinement: every packaged file must live under bundle_dir. + ensure_within(bundle_dir, file_path) + arcname = file_path.relative_to(bundle_dir).as_posix() + # Fixed timestamp so identical inputs yield a byte-for-byte + # identical artifact (reproducible builds). + info = zipfile.ZipInfo(filename=arcname, date_time=_FIXED_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + # Reproducible, normalized permissions: preserve executability so + # bundled scripts (e.g. extension hook scripts) stay runnable after + # extraction, but collapse to two canonical modes (0755 when any + # execute bit is set on the source, otherwise 0644) so identical + # inputs yield a byte-for-byte identical artifact. + mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644 + info.external_attr = mode << 16 + archive.writestr(info, file_path.read_bytes()) + + return BuildResult(artifact_path=artifact_path, file_count=len(files)) + + +def _is_within(parent: Path, child: Path) -> bool: + try: + child.relative_to(parent) + return True + except ValueError: + return False + + +def _collect_files( + bundle_dir: Path, + skip: Path, + skip_dir: Path | None = None, + artifact_re: re.Pattern[str] | None = None, +) -> list[Path]: + collected: list[Path] = [] + # followlinks=False so a symlinked directory is never descended into, + # which would otherwise pull in out-of-tree files and then fail at + # ensure_within(). Symlinked dirs are pruned from traversal explicitly. + for root, dirnames, filenames in os.walk(bundle_dir, followlinks=False): + root_path = Path(root) + # Prune directories we must not descend into (in-place edit of dirnames). + dirnames[:] = [ + d + for d in dirnames + if d not in EXCLUDE_NAMES and not (root_path / d).is_symlink() + ] + if skip_dir is not None and _is_within(skip_dir, root_path): + dirnames[:] = [] + continue + for name in filenames: + path = root_path / name + if path == skip: + continue + if name in EXCLUDE_NAMES: + continue + if artifact_re is not None and artifact_re.match(name): + # A prior build artifact for this bundle — never re-package it. + continue + if path.is_symlink(): + # Skip symlinked files to avoid escaping the bundle directory. + continue + collected.append(path) + return sorted(collected) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py new file mode 100644 index 0000000000..bd0d8ddee0 --- /dev/null +++ b/src/specify_cli/bundler/services/primitives.py @@ -0,0 +1,345 @@ +"""Bridge from bundler component kinds to existing primitive managers. + +The bundler does not own install logic; it routes each component to the +existing Spec Kit primitive machinery so a bundle install behaves exactly as a +sequence of ``specify add`` calls would (Principle I: never +reimplement or fake primitive behaviour). + +Routing strategy per kind: + +* **presets** / **extensions** — wired through their reusable managers + (``install_from_directory`` / ``install_from_zip``). Bundled assets shipped + with Spec Kit install fully offline; catalog assets are fetched only when + network access is permitted. +* **workflows** / **steps** — their install/remove orchestration lives in the + CLI command layer rather than a reusable service method, so the bundler + delegates to those existing command callables in-process (with the project + root as the working directory) instead of duplicating their download and + validation logic. +""" +from __future__ import annotations + +import contextlib +import os +from pathlib import Path +from typing import Protocol + +from .. import BundlerError +from ..models.manifest import ComponentRef + +DEFAULT_PRIORITY = 10 + + +def _assert_pinned_version( + kind: str, component_id: str, pinned: str | None, advertised: object +) -> None: + """Refuse to install when the catalog version differs from the manifest pin. + + Bundle manifests pin component versions for reproducibility; installing + whatever the active catalog currently serves would silently violate the + pin. When the catalog advertises no version we cannot enforce the pin, so + installation proceeds (the catalog, not the bundler, owns that gap). + """ + if not pinned or advertised is None: + return + actual = str(advertised).strip() + if not actual: + return + from ..lib.versioning import parse_version + + try: + matches = parse_version(actual) == parse_version(pinned) + except BundlerError: + matches = actual == str(pinned).strip() + if not matches: + raise BundlerError( + f"{kind} '{component_id}' is pinned to version {pinned} in the bundle " + f"manifest, but the active catalog serves {actual}. Update the bundle's " + "pinned version or the catalog before installing." + ) + + +class _KindManager(Protocol): + def is_installed(self, component: ComponentRef) -> bool: ... + + def install(self, component: ComponentRef) -> None: ... + + def remove(self, component: ComponentRef) -> None: ... + + +def primitive_manager( + kind: str, project_root: Path, *, allow_network: bool = True +) -> _KindManager: + if kind == "presets": + return _PresetKindManager(project_root, allow_network) + if kind == "extensions": + return _ExtensionKindManager(project_root, allow_network) + if kind == "workflows": + return _WorkflowKindManager(project_root, allow_network) + if kind == "steps": + return _StepKindManager(project_root, allow_network) + raise BundlerError(f"Unknown component kind '{kind}'.") + + +@contextlib.contextmanager +def _chdir(path: Path): + """Temporarily switch the working directory. + + The delegated workflow/step command callables resolve the project via + ``Path.cwd()``; this makes that resolution land on *path*. + """ + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _delegate_command(action: str, label: str, call) -> None: + """Run a delegated CLI command callable, translating its exit into errors.""" + import typer + + try: + call() + except typer.Exit as exc: # raised by the delegated command on failure + code = getattr(exc, "exit_code", 0) or 0 + if code != 0: + raise BundlerError(f"Failed to {action} {label}.") from exc + except SystemExit as exc: # pragma: no cover - defensive + if exc.code not in (0, None): + raise BundlerError(f"Failed to {action} {label}.") from exc + + +class _PresetKindManager: + def __init__(self, project_root: Path, allow_network: bool) -> None: + from ...presets import PresetManager + + self._root = project_root + self._allow_network = allow_network + self._manager = PresetManager(project_root) + + def is_installed(self, component: ComponentRef) -> bool: + try: + return self._manager.get_pack(component.id) is not None + except Exception: # noqa: BLE001 + return False + + def install(self, component: ComponentRef) -> None: + from ... import get_speckit_version + from ..._assets import _locate_bundled_preset + + speckit_version = get_speckit_version() + priority = DEFAULT_PRIORITY if component.priority is None else component.priority + + bundled = _locate_bundled_preset(component.id) + if bundled is not None: + self._manager.install_from_directory(bundled, speckit_version, priority) + return + + if not self._allow_network: + raise BundlerError( + f"Preset '{component.id}' is not bundled and network access is " + f"disabled; re-run without --offline or install it first with " + f"'specify preset add {component.id}'." + ) + + from ...presets import PresetCatalog + + catalog = PresetCatalog(self._root) + info = catalog.get_pack_info(component.id) + if not info: + raise BundlerError(f"Preset '{component.id}' not found in any catalog.") + if not info.get("_install_allowed", True): + raise BundlerError( + f"Preset '{component.id}' is from a discovery-only catalog; " + "installation is not allowed." + ) + _assert_pinned_version( + "Preset", component.id, component.version, info.get("version") + ) + zip_path = catalog.download_pack(component.id) + try: + self._manager.install_from_zip(zip_path, speckit_version, priority) + finally: + with contextlib.suppress(Exception): + if zip_path.exists(): + zip_path.unlink() + + def remove(self, component: ComponentRef) -> None: + try: + self._manager.remove(component.id) + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Failed to remove preset '{component.id}': {exc}" + ) from exc + + +class _ExtensionKindManager: + def __init__(self, project_root: Path, allow_network: bool) -> None: + from ...extensions import ExtensionManager + + self._root = project_root + self._allow_network = allow_network + self._manager = ExtensionManager(project_root) + + def is_installed(self, component: ComponentRef) -> bool: + try: + return self._manager.registry.is_installed(component.id) + except Exception: # noqa: BLE001 + return False + + def install(self, component: ComponentRef) -> None: + from ... import get_speckit_version + from ..._assets import _locate_bundled_extension + + speckit_version = get_speckit_version() + priority = DEFAULT_PRIORITY if component.priority is None else component.priority + + bundled = _locate_bundled_extension(component.id) + if bundled is not None: + self._manager.install_from_directory( + bundled, speckit_version, priority=priority + ) + return + + if not self._allow_network: + raise BundlerError( + f"Extension '{component.id}' is not bundled and network access is " + f"disabled; re-run without --offline or install it first with " + f"'specify extension add {component.id}'." + ) + + from ...extensions import ExtensionCatalog + + catalog = ExtensionCatalog(self._root) + info = catalog.get_extension_info(component.id) + if not info: + raise BundlerError( + f"Extension '{component.id}' not found in any catalog." + ) + if not info.get("_install_allowed", True): + raise BundlerError( + f"Extension '{component.id}' is from a discovery-only catalog; " + "installation is not allowed." + ) + _assert_pinned_version( + "Extension", component.id, component.version, info.get("version") + ) + zip_path = catalog.download_extension(component.id) + try: + self._manager.install_from_zip( + zip_path, speckit_version, priority=priority + ) + finally: + with contextlib.suppress(Exception): + if zip_path.exists(): + zip_path.unlink() + + def remove(self, component: ComponentRef) -> None: + try: + self._manager.remove(component.id) + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Failed to remove extension '{component.id}': {exc}" + ) from exc + + +class _WorkflowKindManager: + def __init__(self, project_root: Path, allow_network: bool) -> None: + from ...workflows.catalog import WorkflowRegistry + + self._root = project_root + self._allow_network = allow_network + self._registry = WorkflowRegistry(project_root) + + def is_installed(self, component: ComponentRef) -> bool: + try: + return self._registry.is_installed(component.id) + except Exception: # noqa: BLE001 + return False + + def install(self, component: ComponentRef) -> None: + if not self._allow_network and not self._is_bundled(component.id): + raise BundlerError( + f"Workflow '{component.id}' installs from a catalog and network " + f"access is disabled; re-run without --offline or install it first " + f"with 'specify workflow add {component.id}'." + ) + self._assert_pinned_version(component) + from ... import workflow_add + + with _chdir(self._root): + _delegate_command( + "install", f"workflow '{component.id}'", + lambda: workflow_add(component.id), + ) + + def _assert_pinned_version(self, component: ComponentRef) -> None: + if not component.version: + return + try: + from ...workflows.catalog import WorkflowCatalog + + info = WorkflowCatalog(self._root).get_workflow_info(component.id) + except Exception: # noqa: BLE001 - catalog unreachable: cannot enforce + return + if info: + _assert_pinned_version( + "Workflow", component.id, component.version, info.get("version") + ) + + @staticmethod + def _is_bundled(workflow_id: str) -> bool: + # A workflow that ships with Spec Kit installs fully offline. + from ..._assets import _locate_bundled_workflow + + return _locate_bundled_workflow(workflow_id) is not None + + def remove(self, component: ComponentRef) -> None: + from ... import workflow_remove + + with _chdir(self._root): + _delegate_command( + "remove", f"workflow '{component.id}'", + lambda: workflow_remove(component.id), + ) + + +class _StepKindManager: + def __init__(self, project_root: Path, allow_network: bool) -> None: + from ...workflows.catalog import StepRegistry + + self._root = project_root + self._allow_network = allow_network + self._registry = StepRegistry(project_root) + + def is_installed(self, component: ComponentRef) -> bool: + try: + return self._registry.is_installed(component.id) + except Exception: # noqa: BLE001 + return False + + def install(self, component: ComponentRef) -> None: + if not self._allow_network: + raise BundlerError( + f"Step '{component.id}' installs from a catalog and network access " + f"is disabled; re-run without --offline or install it first with " + f"'specify workflow step add {component.id}'." + ) + from ... import workflow_step_add + + with _chdir(self._root): + _delegate_command( + "install", f"step '{component.id}'", + lambda: workflow_step_add(component.id), + ) + + def remove(self, component: ComponentRef) -> None: + from ... import workflow_step_remove + + with _chdir(self._root): + _delegate_command( + "remove", f"step '{component.id}'", + lambda: workflow_step_remove(component.id), + ) diff --git a/src/specify_cli/bundler/services/references.py b/src/specify_cli/bundler/services/references.py new file mode 100644 index 0000000000..3dd0f3d010 --- /dev/null +++ b/src/specify_cli/bundler/services/references.py @@ -0,0 +1,114 @@ +"""Resolve bundle component references against real, available components. + +Used by ``specify bundle validate`` (FR-005 / SC-007) to confirm that every +declared component points at something installable. Resolution is offline-first: +a reference resolves when the component is bundled with Spec Kit or already +installed in the project; catalog sources are consulted only when network access +is permitted. Offline runs that cannot confirm a reference downgrade to a +warning rather than a false failure, while definitively-unknown references +always error. +""" +from __future__ import annotations + +from pathlib import Path + +from ..models.manifest import ComponentRef + + +def _resolved_locally(root: Path, component: ComponentRef) -> bool: + kind = component.kind + try: + if kind == "presets": + from ..._assets import _locate_bundled_preset + from ...presets import PresetManager + + if _locate_bundled_preset(component.id) is not None: + return True + return PresetManager(root).get_pack(component.id) is not None + if kind == "extensions": + from ..._assets import _locate_bundled_extension + from ...extensions import ExtensionManager + + if _locate_bundled_extension(component.id) is not None: + return True + return ExtensionManager(root).registry.is_installed(component.id) + if kind == "workflows": + from ..._assets import _locate_bundled_workflow + from ...workflows.catalog import WorkflowRegistry + + if _locate_bundled_workflow(component.id) is not None: + return True + return WorkflowRegistry(root).is_installed(component.id) + if kind == "steps": + from ...workflows.catalog import StepRegistry + + return StepRegistry(root).is_installed(component.id) + except Exception: # noqa: BLE001 - resolution is best-effort + return False + return False + + +def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | None: + """Return True/False if a catalog could be consulted, or None on failure.""" + kind = component.kind + try: + if kind == "presets": + from ...presets import PresetCatalog + + return PresetCatalog(root).get_pack_info(component.id) is not None + if kind == "extensions": + from ...extensions import ExtensionCatalog + + return ExtensionCatalog(root).get_extension_info(component.id) is not None + if kind == "workflows": + from ...workflows.catalog import WorkflowCatalog + + return WorkflowCatalog(root).get_workflow_info(component.id) is not None + if kind == "steps": + from ...workflows.catalog import StepCatalog + + return StepCatalog(root).get_step_info(component.id) is not None + except Exception: # noqa: BLE001 - catalog may be unreachable/misconfigured + return None + return None + + +def make_reference_checker( + project_root: Path, + *, + allow_network: bool, + warnings: list[str], +): + """Build a ``ReferenceChecker`` for :func:`validate_manifest`. + + Returns an error string for a reference that is definitively unresolvable, + ``None`` otherwise. Unverifiable references (offline, or an unreachable + catalog) append a note to *warnings* and pass. + """ + + def check(component: ComponentRef) -> str | None: + if _resolved_locally(project_root, component): + return None + + if allow_network: + in_catalog = _resolved_in_catalog(project_root, component) + if in_catalog is True: + return None + if in_catalog is False: + return ( + f"{component.kind[:-1]} '{component.id}' is not bundled, " + "installed, or present in any active catalog." + ) + warnings.append( + f"Could not verify {component.kind[:-1]} '{component.id}' " + "(catalog unreachable); reference left unchecked." + ) + return None + + warnings.append( + f"Could not verify {component.kind[:-1]} '{component.id}' offline " + "(not bundled or installed); re-run validate online to check catalogs." + ) + return None + + return check diff --git a/src/specify_cli/bundler/services/resolver.py b/src/specify_cli/bundler/services/resolver.py new file mode 100644 index 0000000000..127fa683fd --- /dev/null +++ b/src/specify_cli/bundler/services/resolver.py @@ -0,0 +1,122 @@ +"""Resolver: expand a bundle manifest into a concrete, ordered install plan. + +The plan the resolver produces is the single source of truth shared by +``info`` (preview) and ``install`` (execution) so the two never diverge +(SC-002 transparency). Resolution also enforces the SpecKit version gate +(FR-016) and the integration-compatibility check (FR-019). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from .. import BundlerError +from ..lib.versioning import satisfies +from ..models.manifest import BundleManifest, ComponentRef + + +@dataclass +class InstallPlan: + bundle_id: str + version: str + role: str + effective_integration: str | None + components: list[ComponentRef] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def component_count(self) -> int: + return len(self.components) + + def grouped(self) -> dict[str, list[ComponentRef]]: + groups: dict[str, list[ComponentRef]] = { + "extensions": [], + "presets": [], + "steps": [], + "workflows": [], + } + for component in self.components: + groups.setdefault(component.kind, []).append(component) + return groups + + +def resolve_install_plan( + manifest: BundleManifest, + *, + speckit_version: str, + active_integration: str | None, + integration_explicit: bool = False, + enforce_version: bool = True, +) -> InstallPlan: + """Expand *manifest* into an :class:`InstallPlan`, enforcing gates. + + Raises :class:`BundlerError` when a hard gate fails (version gate, + integration clash). Soft issues are collected in ``plan.warnings``. + + *integration_explicit* signals that ``active_integration`` came from an + explicit ``--integration`` override rather than project auto-detection. When + a bundle pins an integration but the project's active integration cannot be + determined (``active_integration is None``) and the caller did not supply an + explicit override, resolution fails instead of silently adopting the + bundle's required integration (FR-019 guard). + """ + structural = manifest.structural_errors() + if structural: + raise BundlerError( + "Cannot resolve an invalid manifest:\n - " + "\n - ".join(structural) + ) + + # FR-016: SpecKit version gate — refuse incompatible installs. + if enforce_version and manifest.requires.speckit_version: + if not satisfies(speckit_version, manifest.requires.speckit_version): + raise BundlerError( + f"Bundle '{manifest.bundle.id}' requires Spec Kit " + f"{manifest.requires.speckit_version}, but this project uses " + f"{speckit_version}. Update Spec Kit or choose a compatible bundle." + ) + + # FR-019: integration-compatibility — a bundle that pins a different + # integration than the project's active one halts (no silent change). + effective_integration = active_integration + if manifest.integration is not None: + required = manifest.integration.id + if active_integration and required != active_integration: + raise BundlerError( + f"Bundle '{manifest.bundle.id}' targets integration '{required}', " + f"but this project's active integration is '{active_integration}'. " + "Installing it would conflict; aborting with no changes." + ) + if active_integration is None and not integration_explicit: + raise BundlerError( + f"Bundle '{manifest.bundle.id}' targets integration '{required}', " + "but this project's active integration could not be determined " + "(missing or unreadable .specify/integration.json). Re-run with " + "'--integration' to confirm the target, or repair the project " + "before installing." + ) + effective_integration = required + + warnings: list[str] = [] + if manifest.requires.tools: + warnings.append( + "Requires external tools: " + ", ".join(manifest.requires.tools) + ) + if manifest.requires.mcp: + warnings.append("Requires MCP servers: " + ", ".join(manifest.requires.mcp)) + + return InstallPlan( + bundle_id=manifest.bundle.id, + version=manifest.bundle.version, + role=manifest.bundle.role, + effective_integration=effective_integration, + components=list(manifest.components), + warnings=warnings, + ) + + +def load_manifest_from_dir(bundle_dir: Path) -> BundleManifest: + """Load ``bundle.yml`` from a bundle directory.""" + manifest_path = Path(bundle_dir) / "bundle.yml" + if not manifest_path.exists(): + raise BundlerError(f"No bundle.yml found in '{bundle_dir}'.") + return BundleManifest.from_file(manifest_path) diff --git a/src/specify_cli/bundler/services/validator.py b/src/specify_cli/bundler/services/validator.py new file mode 100644 index 0000000000..a1b3ae6c93 --- /dev/null +++ b/src/specify_cli/bundler/services/validator.py @@ -0,0 +1,60 @@ +"""Validator: structural + reference validation for a bundle manifest. + +``specify bundle validate`` reports whether a manifest is well-formed and all +component references are resolvable. Structural checks come from the manifest +model; reference resolution is optional (requires a resolver callback) so the +command can run fully offline against pinned/local references. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from .. import BundlerError +from ..lib.versioning import parse_constraint +from ..models.manifest import BundleManifest, ComponentRef + +# A reference checker returns None when resolvable, or an error string. +ReferenceChecker = Callable[[ComponentRef], str | None] + + +@dataclass +class ValidationReport: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + def merge(self, other: "ValidationReport") -> None: + self.errors.extend(other.errors) + self.warnings.extend(other.warnings) + + +def validate_manifest( + manifest: BundleManifest, + reference_checker: ReferenceChecker | None = None, +) -> ValidationReport: + report = ValidationReport() + + report.errors.extend(manifest.structural_errors()) + + if manifest.requires.speckit_version: + try: + parse_constraint(manifest.requires.speckit_version) + except BundlerError as exc: + report.errors.append( + f"requires.speckit_version '{manifest.requires.speckit_version}' " + f"is not a valid constraint: {exc}" + ) + + if reference_checker is not None: + for component in manifest.components: + problem = reference_checker(component) + if problem: + report.errors.append( + f"Unresolved reference {component.label()}: {problem}" + ) + + return report diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py new file mode 100644 index 0000000000..185e00acf6 --- /dev/null +++ b/src/specify_cli/commands/bundle/__init__.py @@ -0,0 +1,834 @@ +"""``specify bundle`` command group — discover, install, author Spec Kit bundles. + +This module is the CLI/UX layer only (Principle I: thin commands over services). +Each command resolves a project, builds a catalog stack, delegates to a bundler +service, and renders Rich output. ``--json`` emits machine-readable data on +stdout; human logs go to stderr/console. +""" +from __future__ import annotations + +import json as _json +import re +from pathlib import Path + +import typer + +from ..._console import console +from ...bundler import BundlerError +from ...bundler.lib.project import ( + active_integration, + find_project_root, + require_project_root, +) +from ...bundler.models.records import load_records + +bundle_app = typer.Typer( + name="bundle", + help="Discover, install, and author Spec Kit bundles", + add_completion=False, +) + +bundle_catalog_app = typer.Typer( + name="catalog", + help="Manage bundle catalog sources", + add_completion=False, +) +bundle_app.add_typer(bundle_catalog_app, name="catalog") + + +# ===== helpers ===== + + +def _fail(message: str) -> None: + """Print an actionable error to stderr and exit non-zero.""" + console.print(f"[red]Error:[/red] {message}", style=None) + raise typer.Exit(code=1) + + +def _user_config_dir() -> Path: + # User-scope Spec Kit config lives under ~/.specify (same convention as + # auth.json, extension/preset catalogs). Passing this through to the source + # stack is what makes the documented project > user > built-in precedence + # reachable from the CLI. + return Path.home() / ".specify" + + +def _build_stack(project_root: Path, *, offline: bool): + from ...bundler.services.adapters import make_catalog_fetcher + from ...bundler.services.catalog_stack import CatalogStack + + fetcher = make_catalog_fetcher(allow_network=not offline) + return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir()) + + +def _speckit_version() -> str: + from ..._assets import get_speckit_version + + return get_speckit_version() + + +def _trust_level(verified: bool) -> str: + """Trust framing for a catalog entry (FR-010): org-curated vs community.""" + return "verified" if verified else "community" + + +def _trust_badge(verified: bool) -> str: + return ( + "[green]✔ verified[/green]" + if verified + else "[yellow]community[/yellow]" + ) + + +def _default_script_type() -> str: + """OS-appropriate default script flavor (FR-013).""" + import os + + return "ps" if os.name == "nt" else "sh" + + +def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None: + """Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery. + + Reuses the real ``specify init`` command callback in-process (Principle I) + with ``--here --force`` so it is non-interactive and merges into the current + directory. + """ + from ... import app + + init_cb = next( + c.callback + for c in app.registered_commands + if c.callback and c.callback.__name__ == "init" + ) + try: + init_cb( + project_name=None, + script_type=script_type, + ignore_agent_tools=True, + here=True, + force=True, + skip_tls=False, + debug=False, + github_token=None, + offline=offline, + preset=None, + integration=integration, + integration_options=None, + ) + except typer.Exit as exc: + if exc.exit_code: + raise BundlerError( + f"Failed to initialize a Spec Kit project (integration '{integration}')." + ) from exc + + +def _resolve_init_integration(override: str | None, manifest) -> str: + """Precedence (FR-013): explicit override → bundle-declared → default.""" + from ..._agent_config import DEFAULT_INIT_INTEGRATION + + if override: + return override + if manifest is not None and manifest.integration is not None: + return manifest.integration.id + return DEFAULT_INIT_INTEGRATION + + +# ===== Consume ===== + + +@bundle_app.command("search") +def bundle_search( + query: str = typer.Argument("", help="Optional text query"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List matching bundles across the active catalog stack.""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + results = stack.search(query) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + payload = [ + { + "id": r.entry.id, + "name": r.entry.name, + "role": r.entry.role, + "version": r.entry.version, + "description": r.entry.description, + "source": r.source.id, + "install_policy": r.source.install_policy.value, + "verified": r.entry.verified, + "trust": _trust_level(r.entry.verified), + } + for r in results + ] + print(_json.dumps(payload, indent=2)) + return + + if not results: + console.print("[yellow]No matching bundles found.[/yellow]") + return + + console.print("\n[bold cyan]Bundles:[/bold cyan]\n") + for r in results: + policy = ( + "[dim](discovery-only)[/dim]" + if not r.source.install_allowed + else "" + ) + console.print( + f" [bold]{r.entry.id}[/bold] v{r.entry.version} — {r.entry.name} " + f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}" + ) + console.print(f" {r.entry.description}") + console.print(f" [dim]source: {r.source.id}[/dim]") + + +@bundle_app.command("info") +def bundle_info( + bundle_id: str = typer.Argument(..., help="Bundle id to inspect"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """Show full metadata and the fully expanded component set (== what install adds).""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + resolved = stack.resolve(bundle_id) + # `info` must show the fully expanded component set that `install` would + # apply (contracts/cli-commands.md). Expansion happens regardless of + # install policy — discovery-only bundles stay inspectable; only + # `install` is refused. But if the manifest itself can't be resolved + # (e.g. --offline against an https:// download_url, or a download + # failure), fail loudly and exit non-zero rather than silently + # degrading to catalog `provides` counts, so users never mistake an + # unverifiable bundle for a known/installable one. + manifest = _download_manifest(resolved, offline=offline) + except BundlerError as exc: + _fail(str(exc)) + return + + overlaps = _bundle_overlaps(project_root, manifest, offline=offline) + components = _manifest_component_view(manifest) + + entry = resolved.entry + if as_json: + payload = { + "id": entry.id, + "name": entry.name, + "version": entry.version, + "role": entry.role, + "description": entry.description, + "author": entry.author, + "license": entry.license, + "source": resolved.source.id, + "install_policy": resolved.source.install_policy.value, + "provides": entry.provides, + "requires": {"speckit_version": entry.requires_speckit_version}, + "verified": entry.verified, + "trust": _trust_level(entry.verified), + "integration": (manifest.integration.id if manifest and manifest.integration else None), + "components": components, + "overlaps": overlaps, + } + print(_json.dumps(payload, indent=2)) + return + + console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version} — {entry.name}") + console.print(f" Role: {entry.role}") + console.print(f" {entry.description}") + console.print(f" Author: {entry.author} License: {entry.license}") + console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})") + console.print(f" Trust: {_trust_badge(entry.verified)}") + if entry.requires_speckit_version: + console.print(f" Requires Spec Kit: {entry.requires_speckit_version}") + if manifest and manifest.integration: + console.print(f" Integration: {manifest.integration.id}") + + if components: + console.print("\n [bold]Components[/bold] (added on install):") + for kind in ("extensions", "presets", "steps", "workflows"): + items = [c for c in components if c["kind"] == kind] + if not items: + continue + console.print(f" [bold]{kind}:[/bold]") + for item in items: + console.print(f" - {_format_component(item)}") + else: + console.print("\n [bold]Provides:[/bold]") + for kind in ("extensions", "presets", "steps", "workflows"): + count = entry.provides.get(kind, 0) + if count: + console.print(f" {kind}: {count}") + + if overlaps: + console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]") + for overlap in overlaps: + console.print(f" [yellow]-[/yellow] {overlap}") + + if not resolved.install_allowed: + console.print( + "\n [yellow]This source is discovery-only; the bundle cannot be " + "installed from here.[/yellow]" + ) + + +@bundle_app.command("list") +def bundle_list( + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List bundles currently installed in the project with versions.""" + try: + project_root = require_project_root() + records = load_records(project_root) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + print(_json.dumps([r.to_dict() for r in records], indent=2)) + return + + if not records: + console.print("[yellow]No bundles installed.[/yellow]") + console.print("\nInstall one with: [cyan]specify bundle install [/cyan]") + return + + console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") + for record in records: + console.print( + f" [bold]{record.bundle_id}[/bold] v{record.version} " + f"[dim]({len(record.contributed_components)} components, " + f"installed {record.installed_at})[/dim]" + ) + + +@bundle_app.command("install") +def bundle_install( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Install a bundle's full component set through each primitive's machinery. + + ``bundle_id`` may be a catalog bundle id, or a local path to a built + artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local + sources install directly without consulting the catalog stack. + """ + try: + from ...bundler.lib.project import find_project_root + from ...bundler.services.adapters import DefaultPrimitiveInstaller + from ...bundler.services.installer import install_bundle + from ...bundler.services.resolver import resolve_install_plan + + project_root = find_project_root() + + local_manifest = _local_manifest_source(bundle_id) + if local_manifest is not None: + manifest = local_manifest + else: + stack = _build_stack(project_root or Path.cwd(), offline=offline) + resolved = stack.resolve(bundle_id) + + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{bundle_id}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be installed from there." + ) + manifest = _download_manifest(resolved, offline=offline) + + if project_root is None: + init_integration = _resolve_init_integration(integration, manifest) + console.print( + f"[cyan]No Spec Kit project here; initializing with integration " + f"'{init_integration}'…[/cyan]" + ) + _run_init(init_integration, script_type=_default_script_type(), offline=offline) + project_root = require_project_root() + + for overlap in _bundle_overlaps(project_root, manifest, offline=offline): + console.print(f"[yellow]![/yellow] {overlap}") + + # For an already-initialized project, the project's recorded active + # integration is authoritative — an explicit --integration must not be + # able to bypass the FR-019 integration-clash guard. The override only + # selects the integration at init time (handled above) or confirms the + # target when the active integration cannot be determined. + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + for warning in plan.warnings: + console.print(f"[yellow]![/yellow] {warning}") + + result = install_bundle( + project_root, + plan, + DefaultPrimitiveInstaller(allow_network=not offline), + manifest=manifest, + ) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Installed '{result.bundle_id}' " + f"({len(result.installed)} added, {len(result.skipped)} already present)." + ) + + +@bundle_app.command("update") +def bundle_update( + bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), + all_bundles: bool = typer.Option(False, "--all", help="Update every installed bundle"), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Re-resolve and refresh a bundle's components via each primitive's update path.""" + try: + project_root = require_project_root() + records = load_records(project_root) + if not all_bundles and not bundle_id: + raise BundlerError("Specify a bundle id or use --all.") + targets = ( + [r.bundle_id for r in records] + if all_bundles + else [bundle_id] + ) + if not targets: + console.print("[yellow]No installed bundles to update.[/yellow]") + return + + stack = _build_stack(project_root, offline=offline) + from ...bundler.services.adapters import DefaultPrimitiveInstaller + from ...bundler.services.installer import install_bundle + from ...bundler.services.resolver import resolve_install_plan + + installer = DefaultPrimitiveInstaller(allow_network=not offline) + for target in targets: + if not any(r.bundle_id == target for r in records): + raise BundlerError(f"Bundle '{target}' is not installed.") + resolved = stack.resolve(target) + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{target}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be updated from there. " + "Update requires an install-allowed source (FR-025)." + ) + manifest = _download_manifest(resolved, offline=offline) + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + install_bundle(project_root, plan, installer, manifest=manifest, refresh=True) + console.print(f"[green]✓[/green] Updated '{target}' to v{plan.version}.") + except BundlerError as exc: + _fail(str(exc)) + return + + +@bundle_app.command("remove") +def bundle_remove( + bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"), +) -> None: + """Uninstall only the components this bundle contributed (no collateral removals).""" + try: + project_root = require_project_root() + from ...bundler.services.adapters import DefaultPrimitiveInstaller + from ...bundler.services.installer import remove_bundle + + result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Removed '{result.bundle_id}' " + f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." + ) + + +# ===== Author ===== + + +@bundle_app.command("validate") +def bundle_validate( + path: Path = typer.Option( + None, "--path", help="Bundle directory or bundle.yml (default: cwd)" + ), + offline: bool = typer.Option( + False, + "--offline", + help="Do not access catalogs; verify references against bundled/installed only", + ), +) -> None: + """Report whether the manifest is well-formed and references resolve.""" + try: + manifest_path = _resolve_manifest_path(path) + from ...bundler.lib.project import find_project_root + from ...bundler.models.manifest import BundleManifest + from ...bundler.services.references import make_reference_checker + from ...bundler.services.validator import validate_manifest + + manifest = BundleManifest.from_file(manifest_path) + ref_root = find_project_root(manifest_path.parent) or Path.cwd() + ref_warnings: list[str] = [] + checker = make_reference_checker( + ref_root, allow_network=not offline, warnings=ref_warnings + ) + report = validate_manifest(manifest, reference_checker=checker) + report.warnings.extend(ref_warnings) + except BundlerError as exc: + _fail(str(exc)) + return + + for warning in report.warnings: + console.print(f"[yellow]![/yellow] {warning}") + if not report.ok: + console.print("[red]Manifest is invalid:[/red]") + for error in report.errors: + console.print(f" [red]-[/red] {error}") + raise typer.Exit(code=1) + console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.") + + +@bundle_app.command("build") +def bundle_build( + path: Path = typer.Option( + None, "--path", help="Bundle directory (default: cwd)" + ), + output: Path = typer.Option(None, "--output", help="Output directory for the artifact"), +) -> None: + """Produce a single versioned distributable artifact (.zip).""" + try: + bundle_dir = (path or Path.cwd()).resolve() + if bundle_dir.is_file(): + bundle_dir = bundle_dir.parent + from ...bundler.services.packager import build_bundle + + result = build_bundle(bundle_dir, output_dir=output) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Built {result.artifact_path.name} " + f"({result.file_count} files) → {result.artifact_path}" + ) + + +@bundle_app.command("init") +def bundle_init( + bundle: str = typer.Argument(None, help="Optional bundle to install after init"), + integration: str = typer.Option(None, "--integration", help="Integration override"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Ensure the project is initialized (idempotent), then optionally install a bundle.""" + from ...bundler.lib.project import find_project_root + + try: + project_root = find_project_root() + if project_root is None: + init_integration = _resolve_init_integration(integration, None) + console.print( + f"[cyan]Initializing a Spec Kit project with integration " + f"'{init_integration}'…[/cyan]" + ) + _run_init(init_integration, script_type=_default_script_type(), offline=offline) + project_root = require_project_root() + except BundlerError as exc: + _fail(str(exc)) + return + + console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.") + if bundle: + bundle_install(bundle, integration=integration, offline=offline) + + +# ===== Catalog management ===== + + +@bundle_catalog_app.command("list") +def catalog_list() -> None: + """Print the active, priority-ordered catalog stack with scope and policy.""" + try: + project_root = require_project_root() + from ...bundler.models.catalog import Scope, load_source_stack + + sources = load_source_stack(project_root, user_config_dir=_user_config_dir()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print("\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n") + only_builtin = all(s.scope == Scope.BUILTIN for s in sources) + for source in sources: + console.print( + f" [bold]{source.id}[/bold] priority={source.priority} " + f"policy={source.install_policy.value} scope={source.scope.value}" + ) + console.print(f" [dim]{source.url}[/dim]") + if only_builtin: + console.print("\n[dim]Using the built-in default stack.[/dim]") + + +@bundle_catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(..., help="Catalog URL"), + policy: str = typer.Option( + "install-allowed", "--policy", help="install-allowed | discovery-only" + ), + priority: int = typer.Option(10, "--priority", help="Source priority (lower = higher)"), + source_id: str = typer.Option(None, "--id", help="Explicit source id"), +) -> None: + """Register a project-scoped catalog source and persist it.""" + try: + project_root = require_project_root() + from ...bundler.commands_impl.catalog_config import add_source + + source = add_source(project_root, url, policy=policy, priority=priority, source_id=source_id) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Added catalog '{source.id}' " + f"(priority {source.priority}, {source.install_policy.value})." + ) + + +@bundle_catalog_app.command("remove") +def catalog_remove( + id_or_url: str = typer.Argument(..., help="Source id or url to remove"), +) -> None: + """Remove a project-scoped catalog source (built-in defaults can't be deleted).""" + try: + project_root = require_project_root() + from ...bundler.commands_impl.catalog_config import remove_source + + removed = remove_source(project_root, id_or_url) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print(f"[green]✓[/green] Removed catalog source '{removed}'.") + + +# ===== internal helpers ===== + + +def _manifest_component_view(manifest) -> list[dict]: + """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" + if manifest is None: + return [] + view: list[dict] = [] + for component in manifest.components: + item = { + "kind": component.kind, + "id": component.id, + "version": component.version, + } + if component.priority is not None: + item["priority"] = component.priority + if component.strategy is not None: + item["strategy"] = component.strategy + view.append(item) + return view + + +def _format_component(item: dict) -> str: + label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] + extras = [] + if item.get("priority") is not None: + extras.append(f"priority={item['priority']}") + if item.get("strategy") is not None: + extras.append(f"strategy={item['strategy']}") + if extras: + label += f" ({', '.join(extras)})" + return label + + +def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: + """Return informational overlaps between *manifest* and installed bundles.""" + if manifest is None: + return [] + try: + from ...bundler.services.conflict import detect_conflicts + + report = detect_conflicts( + manifest, + active_integration(project_root), + load_records(project_root), + ) + return list(report.overlaps) + except BundlerError: + return [] + + +def _local_manifest_source(arg: str): + """Return a :class:`BundleManifest` if *arg* points at a local bundle. + + Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml`` + file. Returns ``None`` when *arg* is not an existing path, so callers fall + back to catalog-stack resolution by bundle id. + """ + from ...bundler.models.manifest import BundleManifest + + candidate = Path(arg).expanduser() + if not candidate.exists(): + return None + + if candidate.is_dir(): + manifest_path = candidate / "bundle.yml" + if not manifest_path.exists(): + raise BundlerError(f"No bundle.yml found in '{candidate}'.") + return BundleManifest.from_file(manifest_path) + + if candidate.suffix == ".zip": + import io + import zipfile + + import yaml as _yaml + + with zipfile.ZipFile(candidate) as archive: + try: + raw = archive.read("bundle.yml") + except KeyError as exc: + raise BundlerError( + f"Artifact '{candidate}' does not contain a bundle.yml." + ) from exc + data = _yaml.safe_load(io.BytesIO(raw)) + return BundleManifest.from_dict(data) + + if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): + return BundleManifest.from_file(candidate) + + raise BundlerError( + f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle " + "directory, or bundle.yml)." + ) + + +def _resolve_manifest_path(path: Path | None) -> Path: + target = (path or Path.cwd()).resolve() + if target.is_dir(): + target = target / "bundle.yml" + if not target.exists(): + raise BundlerError(f"No bundle.yml found at '{target}'.") + return target + + +def _download_manifest(resolved, *, offline: bool): + """Resolve a bundle's manifest from its catalog ``download_url``. + + Local/``file://`` URLs always work offline and may point at a ``.zip`` + artifact, a bundle directory, or a ``bundle.yml`` (handled by + :func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with + the shared authenticated, redirect-validated HTTP client, and only when not + ``--offline``. + """ + from urllib.parse import urlparse + + url = resolved.entry.download_url + if not url: + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve " + "its manifest." + ) + parsed = urlparse(url) + scheme = parsed.scheme.lower() + + # On Windows an absolute path like ``C:\bundle.yml`` parses with a + # single-letter ``scheme``; treat it as a local file, not a URL scheme. + if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): + local = Path(parsed.path if scheme == "file" else url) + manifest = _local_manifest_source(str(local)) + if manifest is None: + raise BundlerError(f"Bundle manifest not found: {local}") + return manifest + + if scheme in ("http", "https"): + if offline: + raise BundlerError( + f"Network access disabled; cannot download bundle '{resolved.entry.id}' " + f"from {url}." + ) + return _download_remote_manifest(resolved.entry.id, url) + + raise BundlerError( + f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}" + ) + + +def _require_https(label: str, 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 BundlerError( + f"Refusing to download {label} over non-HTTPS URL: {url}" + ) + if not parsed.hostname: + raise BundlerError(f"Refusing to download {label} from URL with no host: {url}") + + +def _download_remote_manifest(entry_id: str, url: str): + """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" + import io + import tempfile + + from ...authentication.http import open_url + + def _validate_redirect(old_url: str, new_url: str) -> None: + _require_https(f"bundle '{entry_id}'", new_url) + + _require_https(f"bundle '{entry_id}'", url) + try: + with open_url(url, timeout=30, redirect_validator=_validate_redirect) as resp: + _require_https(f"bundle '{entry_id}'", resp.geturl()) + raw = resp.read() + except BundlerError: + raise + except Exception as exc: # noqa: BLE001 + raise BundlerError(f"Failed to download bundle '{entry_id}' from {url}: {exc}") from exc + + # A .zip artifact is written to a temp file and parsed via the local-source + # path (which extracts bundle.yml); any other payload is treated as YAML. + if url.lower().endswith(".zip"): + with tempfile.TemporaryDirectory() as tmp: + artifact = Path(tmp) / "bundle.zip" + artifact.write_bytes(raw) + manifest = _local_manifest_source(str(artifact)) + if manifest is None: + raise BundlerError( + f"Downloaded artifact for bundle '{entry_id}' is not a valid bundle." + ) + return manifest + + import yaml as _yaml + + from ...bundler.models.manifest import BundleManifest + + data = _yaml.safe_load(io.BytesIO(raw)) + return BundleManifest.from_dict(data) + + +def register(app: typer.Typer) -> None: + """Attach the bundle command group to the root Typer app.""" + app.add_typer(bundle_app, name="bundle") diff --git a/tests/bundler_helpers.py b/tests/bundler_helpers.py new file mode 100644 index 0000000000..0ebaf2f1c7 --- /dev/null +++ b/tests/bundler_helpers.py @@ -0,0 +1,125 @@ +"""Shared helpers and fakes for bundler tests. + +Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched. +Import what you need explicitly, e.g.:: + + from tests.bundler_helpers import FakeInstaller, write_manifest +""" +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from specify_cli.bundler.models.manifest import ComponentRef + + +def valid_manifest_dict(**overrides) -> dict: + """Return a structurally valid manifest dict; override any top-level key.""" + data = { + "schema_version": "1.0", + "bundle": { + "id": "demo-bundle", + "name": "Demo Bundle", + "version": "1.2.0", + "role": "developer", + "description": "A demo bundle for tests.", + "author": "Spec Kit", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "extensions": [{"id": "ext-a", "version": "1.0.0"}], + "presets": [ + {"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"} + ], + "steps": [{"id": "step-a"}], + "workflows": [{"id": "wf-a", "version": "0.3.0"}], + }, + "tags": ["demo", "test"], + } + data.update(overrides) + return data + + +def write_manifest(directory: Path, data: dict | None = None) -> Path: + directory.mkdir(parents=True, exist_ok=True) + manifest_path = directory / "bundle.yml" + manifest_path.write_text( + yaml.safe_dump(data if data is not None else valid_manifest_dict()), + encoding="utf-8", + ) + return manifest_path + + +def make_project(root: Path) -> Path: + """Create a minimal Spec Kit project skeleton under *root*.""" + (root / ".specify").mkdir(parents=True, exist_ok=True) + return root + + +def catalog_payload(bundles: dict | None = None) -> dict: + return { + "schema_version": "1.0", + "updated_at": "2026-06-19T00:00:00Z", + "catalog_url": "file://test", + "bundles": bundles or {}, + } + + +def catalog_entry_dict(bundle_id: str = "demo-bundle", **overrides) -> dict: + entry = { + "id": bundle_id, + "name": "Demo Bundle", + "version": "1.2.0", + "role": "developer", + "description": "A demo bundle.", + "author": "Spec Kit", + "license": "MIT", + "download_url": "", + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"extensions": 1, "presets": 1, "steps": 1, "workflows": 1}, + "verified": True, + } + entry.update(overrides) + return entry + + +def write_catalog_file(path: Path, bundles: dict) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(catalog_payload(bundles)), encoding="utf-8") + return path + + +class FakeInstaller: + """Deterministic in-memory PrimitiveInstaller for offline integration tests.""" + + def __init__(self, *, fail_on: str | None = None) -> None: + self.installed: set[tuple[str, str]] = set() + self.install_calls: list[tuple[str, str]] = [] + self.remove_calls: list[tuple[str, str]] = [] + self.refresh_calls: list[tuple[str, str]] = [] + self._fail_on = fail_on + + def _key(self, component: ComponentRef) -> tuple[str, str]: + return (component.kind, component.id) + + def is_installed(self, project_root: Path, component: ComponentRef) -> bool: + return self._key(component) in self.installed + + def install(self, project_root: Path, component: ComponentRef) -> None: + from specify_cli.bundler import BundlerError + + self.install_calls.append(self._key(component)) + if self._fail_on is not None and component.id == self._fail_on: + raise BundlerError(f"Simulated failure installing {component.id}") + self.installed.add(self._key(component)) + + def remove(self, project_root: Path, component: ComponentRef) -> None: + self.remove_calls.append(self._key(component)) + self.installed.discard(self._key(component)) + + def refresh(self, project_root: Path, component: ComponentRef) -> None: + self.refresh_calls.append(self._key(component)) + self.installed.add(self._key(component)) diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py new file mode 100644 index 0000000000..018b2bbec1 --- /dev/null +++ b/tests/contract/test_bundle_cli.py @@ -0,0 +1,391 @@ +"""Contract test for the `specify bundle` CLI surface (Typer integration). + +Exercises the wired commands end-to-end via CliRunner against a temp project, +asserting exit codes and the cross-cutting error guarantees from +contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error). +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundler.services.packager import build_bundle +from tests.bundler_helpers import ( + catalog_entry_dict, + valid_manifest_dict, + write_catalog_file, +) + +runner = CliRunner() + + +@pytest.fixture() +def project(tmp_path: Path, monkeypatch) -> Path: + (tmp_path / ".specify").mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_bundle_help_lists_all_commands(): + result = runner.invoke(app, ["bundle", "--help"]) + assert result.exit_code == 0 + for cmd in ("search", "info", "list", "install", "update", "remove", + "validate", "build", "init", "catalog"): + assert cmd in result.output + + +def test_update_accepts_integration_override(): + # Update must expose --integration so integration-pinned bundles can be + # updated in projects where the active integration can't be auto-detected. + # Rich may insert ANSI escapes between the two leading dashes, so match the + # un-split option word rather than the literal "--integration". + result = runner.invoke(app, ["bundle", "update", "--help"]) + assert result.exit_code == 0 + assert "integration" in result.output + + +def test_list_empty_project(project: Path): + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0 + assert "No bundles installed" in result.output + + +def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 1 + assert "Spec Kit project" in result.output + + +def test_search_works_without_a_project(tmp_path: Path, monkeypatch): + # Discovery commands fall back to the built-in/user catalog stack and must + # not require a Spec Kit project (matches README/quickstart examples). + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0, result.output + assert result.output.strip().startswith("[") + + +def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"]) + # Reaches catalog resolution (not the project gate) and reports a clean miss. + assert result.exit_code == 1 + assert "Spec Kit project" not in result.output + + +def test_catalog_list_shows_builtin_defaults(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "list"]) + assert result.exit_code == 0 + assert "default" in result.output + assert "community" in result.output + assert "built-in default stack" in result.output + + +def test_catalog_add_and_remove(project: Path): + catalog = project / "local-catalog.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + + listed = runner.invoke(app, ["bundle", "catalog", "list"]) + assert "local" in listed.output + + removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"]) + assert removed.exit_code == 0 + + +def test_catalog_remove_builtin_is_refused(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "remove", "default"]) + assert result.exit_code == 1 + assert "built-in" in result.output + + +def test_validate_reports_invalid_manifest(project: Path): + data = valid_manifest_dict() + del data["bundle"]["license"] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "license" in result.output + + +def test_validate_accepts_valid_manifest(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + # Offline mode does not fail on references it cannot verify (synthetic ids + # here); they surface as warnings while structure is confirmed valid. + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output + + +def test_validate_rejects_broken_reference(project: Path): + # Synthetic component ids resolve to nothing in any catalog → hard failure. + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "preset-a" in result.output or "ext-a" in result.output + + +def test_validate_accepts_bundled_reference(project: Path): + data = valid_manifest_dict() + data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output + + +def test_build_produces_artifact(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) + assert result.exit_code == 0, result.output + artifacts = list((project / "dist").glob("*.zip")) + assert len(artifacts) == 1 + + +def test_info_expands_full_component_set(project: Path): + bundle_dir = project / "src-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "local-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url=str(bundle_dir / "bundle.yml") + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]): c for c in payload["components"]} + assert ("extensions", "ext-a") in components + preset = components[("presets", "preset-a")] + assert preset["version"] == "2.0.0" + assert preset["priority"] == 10 + assert preset["strategy"] == "append" + assert payload["trust"] == "verified" + + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "preset-a v2.0.0" in text.output + assert "Trust" in text.output + + +def test_info_expands_discovery_only_bundle(project: Path): + # Discovery-only bundles must still be fully inspectable via `info`; + # only `install` is refused for them. + bundle_dir = project / "disc-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "disc-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url=str(bundle_dir / "bundle.yml") + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "disc", "url": str(catalog), "priority": 1, + "install_policy": "discovery-only"} + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_info_resolves_local_zip_download_url(project: Path): + # A local .zip artifact as download_url is extracted to read bundle.yml. + bundle_dir = project / "zip-src" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (bundle_dir / "README.md").write_text("# Demo", encoding="utf-8") + artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path + catalog = project / "zip-catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))}, + ) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_install_refuses_discovery_only_source(project: Path, monkeypatch): + # Point a discovery-only catalog at a local payload containing the bundle. + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "disc", "url": str(catalog), "priority": 1, + "install_policy": "discovery-only"} + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "install", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output + + +def test_update_refuses_discovery_only_source(project: Path): + # An installed bundle whose only resolvable source is discovery-only must + # not be updatable from there (FR-025), mirroring the install policy gate. + from specify_cli.bundler.models.manifest import ComponentRef + from specify_cli.bundler.models.records import ( + InstalledBundleRecord, + save_records, + ) + + save_records( + project, + [ + InstalledBundleRecord.create( + "demo", + "1.0.0", + [ComponentRef(kind="extensions", id="ext-a", version=None)], + ) + ], + ) + + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "disc", "url": str(catalog), "priority": 1, + "install_policy": "discovery-only"} + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output + + +def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): + # `info` must expand the real component set; if the manifest can't be + # resolved (here: --offline against an https download_url), it should error + # and exit non-zero rather than silently degrading to `provides` counts. + catalog = project / "remote-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"] + ) + assert added.exit_code == 0, added.output + + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert result.exit_code == 1 + assert "Network access disabled" in result.output + + +def test_search_json_offline(project: Path): + catalog = project / "c.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "c", "url": str(catalog), "priority": 1, + "install_policy": "install-allowed"} + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload[0]["id"] == "demo" + # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). + assert payload[0]["verified"] is True + assert payload[0]["trust"] == "verified" + + +def test_search_text_shows_trust(project: Path): + catalog = project / "c.json" + write_catalog_file( + catalog, + { + "verified-one": catalog_entry_dict("verified-one", verified=True), + "community-one": catalog_entry_dict("community-one", verified=False), + }, + ) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "c", "url": str(catalog), "priority": 1, + "install_policy": "install-allowed"} + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline"]) + assert result.exit_code == 0, result.output + assert "verified" in result.output + assert "community" in result.output + + +def test_install_integration_override_cannot_bypass_clash_guard(project: Path): + # An initialized project's recorded active integration is authoritative: + # passing --integration must not let a differently-pinned bundle install. + import json + + (project / ".specify" / "integration.json").write_text( + json.dumps({"integration": "copilot"}), encoding="utf-8" + ) + bundle_dir = project / "claude-bundle" + bundle_dir.mkdir() + data = valid_manifest_dict(integration={"id": "claude"}) + (bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8") + + result = runner.invoke( + app, + ["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"], + ) + assert result.exit_code == 1 + assert "claude" in result.output and "copilot" in result.output diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py new file mode 100644 index 0000000000..2155a9f1fd --- /dev/null +++ b/tests/contract/test_catalog_schema.py @@ -0,0 +1,147 @@ +"""Contract tests for the catalog schema and source stack. + +Mirrors contracts/bundle-catalog.schema.md: source precedence project > user > +built-in, install policy gating, payload parsing. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.bundler.models.catalog import ( + BUILTIN_DEFAULT_STACK, + CatalogSource, + InstallPolicy, + Scope, + load_catalog_payload, + load_source_stack, +) +from specify_cli.bundler import BundlerError +import pytest +from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project + + +def test_non_integer_source_priority_raises_actionable_error(): + with pytest.raises(BundlerError, match="non-integer priority"): + CatalogSource.from_dict( + {"id": "corp", "url": "https://corp/catalog.json", "priority": "high"}, + Scope.PROJECT, + ) + + +def test_builtin_default_stack_when_no_config(tmp_path: Path): + make_project(tmp_path) + sources = load_source_stack(tmp_path) + ids = [s.id for s in sources] + assert ids == ["default", "community"] + assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED + assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY + assert all(s.scope is Scope.BUILTIN for s in sources) + + +def test_project_config_overrides_same_id(tmp_path: Path): + make_project(tmp_path) + config = { + "schema_version": "1.0", + "catalogs": [ + {"id": "default", "url": "file://local", "priority": 1, + "install_policy": "install-allowed"}, + {"id": "corp", "url": "https://corp/catalog.json", "priority": 0, + "install_policy": "install-allowed"}, + ], + } + (tmp_path / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + sources = load_source_stack(tmp_path) + by_id = {s.id: s for s in sources} + assert by_id["default"].scope is Scope.PROJECT + assert by_id["default"].url == "file://local" + # Highest precedence (lowest priority number) sorts first. + assert sources[0].id == "corp" + + +def test_user_scope_between_builtin_and_project(tmp_path: Path): + make_project(tmp_path) + user_dir = tmp_path / "userconf" + user_dir.mkdir() + (user_dir / "bundle-catalogs.yml").write_text( + yaml.safe_dump( + {"catalogs": [ + {"id": "community", "url": "https://u", "priority": 2, + "install_policy": "install-allowed"} + ]} + ), + encoding="utf-8", + ) + sources = load_source_stack(tmp_path, user_config_dir=user_dir) + by_id = {s.id: s for s in sources} + # User overrode the built-in community policy to install-allowed. + assert by_id["community"].scope is Scope.USER + assert by_id["community"].install_allowed is True + + +def test_load_payload_parses_entries(): + payload = catalog_payload({"demo-bundle": catalog_entry_dict()}) + entries = load_catalog_payload(payload) + assert "demo-bundle" in entries + assert entries["demo-bundle"].version == "1.2.0" + assert entries["demo-bundle"].provides["presets"] == 1 + + +def test_builtin_default_stack_constant_shape(): + ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK} + assert ids == {"default", "community"} + + +def test_catalog_entry_rejects_string_tags(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["tags"] = "not-a-list" + with pytest.raises(BundlerError, match="'tags' must be a list"): + CatalogEntry.from_dict(data) + + +def test_catalog_entry_rejects_non_boolean_verified(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["verified"] = "false" # truthy string must not mark the entry verified + with pytest.raises(BundlerError, match="'verified' must be a boolean"): + CatalogEntry.from_dict(data) + + +def test_load_payload_rejects_id_key_mismatch(): + # The enclosing key is authoritative; an entry whose own id disagrees with + # the key must be rejected so a catalog can't list a spoofed/unresolvable id. + payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")}) + with pytest.raises(BundlerError, match="id mismatch"): + load_catalog_payload(payload) + + +def test_load_payload_rejects_missing_entry_id(): + entry = catalog_entry_dict("demo-bundle") + entry["id"] = "" + payload = catalog_payload({"demo-bundle": entry}) + with pytest.raises(BundlerError, match="missing its 'id'"): + load_catalog_payload(payload) + + +def test_catalog_entry_rejects_non_mapping_requires(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["requires"] = "speckit>=0.1" + with pytest.raises(BundlerError, match="'requires' must be a mapping"): + CatalogEntry.from_dict(data) + + +def test_catalog_entry_rejects_non_mapping_provides(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["provides"] = "extensions" + with pytest.raises(BundlerError, match="'provides' must be a mapping"): + CatalogEntry.from_dict(data) diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py new file mode 100644 index 0000000000..4d0d95f608 --- /dev/null +++ b/tests/contract/test_manifest_schema.py @@ -0,0 +1,126 @@ +"""Contract tests for the bundle manifest schema (bundle.yml). + +Mirrors contracts/bundle-manifest.schema.md: required identity/metadata fields, +semver pinning of components, preset priority+strategy, integration optionality. +""" +from __future__ import annotations + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.manifest import BundleManifest +from tests.bundler_helpers import valid_manifest_dict + + +def test_valid_manifest_has_no_structural_errors(): + manifest = BundleManifest.from_dict(valid_manifest_dict()) + assert manifest.structural_errors() == [] + assert manifest.bundle.id == "demo-bundle" + assert manifest.is_agnostic() is True + + +def test_missing_required_field_is_reported_by_name(): + data = valid_manifest_dict() + del data["bundle"]["license"] + errors = BundleManifest.from_dict(data).structural_errors() + assert any("bundle.license" in e for e in errors) + + +def test_unsupported_schema_version_is_rejected(): + data = valid_manifest_dict(schema_version="9.9") + errors = BundleManifest.from_dict(data).structural_errors() + assert any("schema_version" in e for e in errors) + + +def test_non_semver_bundle_version_is_rejected(): + data = valid_manifest_dict() + data["bundle"]["version"] = "not-a-version" + errors = BundleManifest.from_dict(data).structural_errors() + assert any("semver" in e for e in errors) + + +def test_preset_requires_priority_and_strategy(): + data = valid_manifest_dict() + data["provides"]["presets"] = [{"id": "p", "version": "1.0.0"}] + errors = BundleManifest.from_dict(data).structural_errors() + assert any("priority" in e for e in errors) + assert any("strategy" in e for e in errors) + + +def test_invalid_preset_strategy_is_rejected(): + data = valid_manifest_dict() + data["provides"]["presets"][0]["strategy"] = "merge" + errors = BundleManifest.from_dict(data).structural_errors() + assert any("strategy" in e for e in errors) + + +def test_non_integer_priority_raises_actionable_error(): + data = valid_manifest_dict() + data["provides"]["presets"][0]["priority"] = "high" + with pytest.raises(BundlerError, match="priority must be an integer"): + BundleManifest.from_dict(data) + + +def test_non_step_components_must_be_pinned(): + data = valid_manifest_dict() + data["provides"]["extensions"] = [{"id": "ext-unpinned"}] + errors = BundleManifest.from_dict(data).structural_errors() + assert any("must be pinned" in e for e in errors) + + +def test_steps_may_be_unpinned(): + data = valid_manifest_dict() + data["provides"]["steps"] = [{"id": "step-x"}] + manifest = BundleManifest.from_dict(data) + assert manifest.structural_errors() == [] + + +def test_integration_makes_bundle_non_agnostic(): + data = valid_manifest_dict(integration={"id": "copilot"}) + manifest = BundleManifest.from_dict(data) + assert manifest.is_agnostic() is False + assert manifest.integration.id == "copilot" + + +def test_components_property_orders_by_kind(): + manifest = BundleManifest.from_dict(valid_manifest_dict()) + kinds = [c.kind for c in manifest.components] + assert kinds == ["extensions", "presets", "steps", "workflows"] + + +def test_string_tags_rejected_not_split_per_character(): + # A bare string would otherwise be iterated character-by-character; the + # schema requires a list of strings. + data = valid_manifest_dict() + data["tags"] = "security" + with pytest.raises(BundlerError, match="'tags' must be a list of strings"): + BundleManifest.from_dict(data) + + +def test_unsafe_bundle_id_flagged_by_structural_validation(): + data = valid_manifest_dict() + data["bundle"]["id"] = "../evil" + manifest = BundleManifest.from_dict(data) + errors = manifest.structural_errors() + assert any("bundle.id" in e and "slug" in e for e in errors) + + +def test_valid_slug_bundle_id_passes(): + data = valid_manifest_dict() + data["bundle"]["id"] = "team-a.bundle_1" + manifest = BundleManifest.from_dict(data) + assert not any("bundle.id" in e for e in manifest.structural_errors()) + + +def test_string_tools_rejected_not_split_per_character(): + data = valid_manifest_dict() + data["requires"]["tools"] = "docker" + with pytest.raises(BundlerError, match="'requires.tools' must be a list of strings"): + BundleManifest.from_dict(data) + + +def test_string_mcp_rejected_not_split_per_character(): + data = valid_manifest_dict() + data["requires"]["mcp"] = "github" + with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"): + BundleManifest.from_dict(data) diff --git a/tests/integration/test_bundler_catalog_stack.py b/tests/integration/test_bundler_catalog_stack.py new file mode 100644 index 0000000000..55af49039b --- /dev/null +++ b/tests/integration/test_bundler_catalog_stack.py @@ -0,0 +1,79 @@ +"""Integration tests for the catalog stack: precedence, policy gating, search.""" +from __future__ import annotations + + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope +from specify_cli.bundler.services.catalog_stack import CatalogStack +from tests.bundler_helpers import catalog_entry_dict, catalog_payload + + +def _source(source_id, priority, policy, url="builtin://x"): + return CatalogSource( + id=source_id, url=url, priority=priority, + install_policy=InstallPolicy(policy), scope=Scope.PROJECT, + ) + + +def _stack(sources, payloads): + def fetcher(src): + return payloads[src.id] + return CatalogStack(sources, fetcher) + + +def test_resolve_prefers_highest_precedence_source(): + sources = [ + _source("low", 2, "install-allowed"), + _source("high", 1, "discovery-only"), + ] + payloads = { + "high": catalog_payload({"b": catalog_entry_dict("b", version="9.0.0")}), + "low": catalog_payload({"b": catalog_entry_dict("b", version="1.0.0")}), + } + resolved = _stack(sources, payloads).resolve("b") + assert resolved.source.id == "high" + assert resolved.entry.version == "9.0.0" + assert resolved.install_allowed is False + + +def test_resolve_unknown_bundle_errors(): + stack = _stack( + [_source("only", 1, "install-allowed")], + {"only": catalog_payload({})}, + ) + with pytest.raises(BundlerError, match="not found"): + stack.resolve("missing") + + +def test_search_dedupes_by_precedence_and_filters(): + sources = [_source("a", 1, "install-allowed"), _source("b", 2, "install-allowed")] + payloads = { + "a": catalog_payload({ + "alpha": catalog_entry_dict("alpha", role="developer"), + }), + "b": catalog_payload({ + "alpha": catalog_entry_dict("alpha", version="0.0.1"), + "beta": catalog_entry_dict("beta", role="qa"), + }), + } + stack = _stack(sources, payloads) + + all_results = stack.search() + ids = [r.entry.id for r in all_results] + assert ids == ["alpha", "beta"] + # alpha resolved from the higher-precedence source 'a'. + alpha = next(r for r in all_results if r.entry.id == "alpha") + assert alpha.source.id == "a" + + qa_only = stack.search("qa") + assert [r.entry.id for r in qa_only] == ["beta"] + + +def test_unreachable_source_raises_named_error(): + def fetcher(src): + raise RuntimeError("boom") + stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher) + with pytest.raises(BundlerError, match="bad"): + stack.resolve("anything") diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py new file mode 100644 index 0000000000..c1e079ce27 --- /dev/null +++ b/tests/integration/test_bundler_init_install.py @@ -0,0 +1,92 @@ +"""Install-time initialization and integration precedence (T049, T050). + +``specify bundle install`` into an uninitialized directory must scaffold a Spec +Kit project first (FR-012), choosing the integration by precedence (FR-013): +explicit ``--integration`` override → bundle-declared integration → default. +The end-to-end test runs fully offline against bundled assets. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundler.models.manifest import BundleManifest +from specify_cli.commands.bundle import _resolve_init_integration +from specify_cli.bundler.services.packager import build_bundle +from tests.bundler_helpers import valid_manifest_dict + +runner = CliRunner() + + +def _manifest(**overrides): + data = valid_manifest_dict(**overrides) + return BundleManifest.from_dict(data) + + +def test_precedence_override_wins(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration("gemini", manifest) == "gemini" + + +def test_precedence_bundle_declared_when_no_override(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration(None, manifest) == "claude" + + +def test_precedence_default_when_unspecified(): + manifest = _manifest() + assert _resolve_init_integration(None, manifest) == "copilot" + assert _resolve_init_integration(None, None) == "copilot" + + +def _build_mini(tmp_path: Path) -> Path: + bundle = tmp_path / "mini" + bundle.mkdir() + (bundle / "bundle.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "bundle": { + "id": "mini", + "name": "Mini", + "version": "1.0.0", + "role": "developer", + "description": "minimal", + "author": "tests", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, + } + ), + encoding="utf-8", + ) + (bundle / "README.md").write_text("# Mini\n", encoding="utf-8") + return build_bundle(bundle).artifact_path + + +def test_install_initializes_uninitialized_project(tmp_path: Path): + project = tmp_path / "proj" + project.mkdir() + artifact = _build_mini(tmp_path) + + previous = Path.cwd() + os.chdir(project) + try: + result = runner.invoke( + app, ["bundle", "install", str(artifact), "--offline"] + ) + assert result.exit_code == 0, result.output + finally: + os.chdir(previous) + + assert (project / ".specify").is_dir() + marker = project / ".specify" / "integration.json" + assert marker.exists() + data = json.loads(marker.read_text(encoding="utf-8")) + assert "copilot" in json.dumps(data) diff --git a/tests/integration/test_bundler_install_flow.py b/tests/integration/test_bundler_install_flow.py new file mode 100644 index 0000000000..85dcc6c51b --- /dev/null +++ b/tests/integration/test_bundler_install_flow.py @@ -0,0 +1,222 @@ +"""Integration tests for the install → record → remove lifecycle (offline, fake installer). + +Uses :class:`FakeInstaller` so no network or real primitive machinery is touched +(Constitution Principle II network-mocking, Principle IV offline-first). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.manifest import BundleManifest +from specify_cli.bundler.models.records import load_records +from specify_cli.bundler.services.installer import install_bundle, remove_bundle +from specify_cli.bundler.services.resolver import resolve_install_plan +from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict + + +def _plan(manifest): + return resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + + +def test_install_records_and_invokes_primitives(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + + result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + assert len(result.installed) == 4 + assert len(installer.install_calls) == 4 + records = load_records(tmp_path) + assert len(records) == 1 + assert records[0].bundle_id == "demo-bundle" + + +def test_install_is_idempotent(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + # Second install adds nothing and does not duplicate the record. + assert second.installed == [] + assert len(second.skipped) == 4 + assert len(load_records(tmp_path)) == 1 + + +def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller(fail_on="preset-a") + + with pytest.raises(BundlerError): + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + # ext-a was installed first, then rolled back; no record persisted. + assert installer.installed == set() + assert load_records(tmp_path) == [] + + +def test_remove_is_non_collateral(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + + # Bundle A provides a shared preset; Bundle B also provides it. + data_a = valid_manifest_dict() + data_a["bundle"]["id"] = "a" + data_b = valid_manifest_dict() + data_b["bundle"]["id"] = "b" + data_b["provides"] = {"presets": [ + {"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"} + ]} + + man_a = BundleManifest.from_dict(data_a) + man_b = BundleManifest.from_dict(data_b) + install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a) + install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b) + + # Removing B must NOT uninstall preset-a (still needed by A). + result = remove_bundle(tmp_path, "b", installer) + assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped} + assert installer.is_installed(tmp_path, man_a.presets[0]) is True + + remaining = {r.bundle_id for r in load_records(tmp_path)} + assert remaining == {"a"} + + +def test_remove_unknown_bundle_errors(tmp_path: Path): + make_project(tmp_path) + with pytest.raises(BundlerError, match="not installed"): + remove_bundle(tmp_path, "ghost", FakeInstaller()) + + +def test_remove_reports_uninstalled_not_installed(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + result = remove_bundle(tmp_path, "demo-bundle", installer) + + # Removal flows populate the dedicated ``uninstalled`` list; ``installed`` + # stays empty so the result type is never ambiguous for callers. + assert result.installed == [] + assert len(result.uninstalled) == 4 + assert installer.installed == set() + + +def test_remove_counts_only_components_actually_removed(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + # Simulate one contributed component already gone from disk (e.g. removed + # out of band). It must not be reported as uninstalled and remove() must + # not be called for it. + gone = manifest.components[0] + installer.installed.discard((gone.kind, gone.id)) + + result = remove_bundle(tmp_path, "demo-bundle", installer) + + assert len(result.uninstalled) == 3 + assert (gone.kind, gone.id) not in installer.remove_calls + assert gone in result.skipped + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + result = install_bundle( + tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True + ) + + # With refresh, already-installed components are re-applied, not skipped. + assert result.skipped == [] + assert len(result.refreshed) == 4 + assert len(installer.refresh_calls) == 4 + assert result.changed is True + + +def test_refresh_falls_back_to_install_without_hook(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + + class NoRefreshInstaller(FakeInstaller): + refresh = None # type: ignore[assignment] + + installer = NoRefreshInstaller() + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + before = len(installer.install_calls) + result = install_bundle( + tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True + ) + + # No refresh hook → re-install path keeps components current. + assert len(result.refreshed) == 4 + assert len(installer.install_calls) == before + 4 + + +def test_update_preserves_original_installed_at(tmp_path: Path): + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + original = load_records(tmp_path)[0].installed_at + + # A refresh (bundle update) must not rewrite the original install timestamp. + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True) + + assert load_records(tmp_path)[0].installed_at == original + + +def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path): + # bundle update (refresh) must not re-apply a component installed + # independently and tracked by no bundle — refreshing it would be a + # collateral change to something the bundle does not own (FR-022). + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + installer.installed.add(("extensions", "ext-a")) + + result = install_bundle( + tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True + ) + + # ext-a is skipped (not refreshed) and never attributed to the bundle. + assert ("extensions", "ext-a") not in installer.refresh_calls + assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped} + assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed} + contributed = { + (c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components + } + assert ("extensions", "ext-a") not in contributed + + +def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path): + # A component installed independently (before any bundle) must not be + # attributed to the bundle, so removing the bundle never uninstalls it + # (FR-022, no collateral removal). + make_project(tmp_path) + manifest = BundleManifest.from_dict(valid_manifest_dict()) + installer = FakeInstaller() + # Pre-install ext-a independently — no bundle record references it yet. + installer.installed.add(("extensions", "ext-a")) + + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + + contributed = { + (c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components + } + assert ("extensions", "ext-a") not in contributed + + remove_bundle(tmp_path, "demo-bundle", installer) + assert ("extensions", "ext-a") in installer.installed diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py new file mode 100644 index 0000000000..a150ffce04 --- /dev/null +++ b/tests/integration/test_bundler_local_install.py @@ -0,0 +1,114 @@ +"""Tests for installing a bundle from a local artifact/path (T045). + +The resolution-level tests are pure; the end-to-end test installs the bundled +``agent-context`` extension fully offline from a built ``.zip`` artifact, +proving the real in-process primitive dispatch (T044) works without a network. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundler import BundlerError +from specify_cli.commands.bundle import _local_manifest_source +from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest + + +def test_local_source_none_for_non_path(): + assert _local_manifest_source("some-catalog-bundle-id") is None + + +def test_local_source_from_directory(tmp_path: Path): + write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(tmp_path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_bundle_yml(tmp_path: Path): + path = write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_zip_artifact(tmp_path: Path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + write_manifest(bundle_dir, valid_manifest_dict()) + (bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) + assert result.exit_code == 0, result.output + artifact = next(bundle_dir.glob("*.zip")) + + manifest = _local_manifest_source(str(artifact)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_rejects_unknown_file(tmp_path: Path): + weird = tmp_path / "thing.txt" + weird.write_text("nope", encoding="utf-8") + with pytest.raises(BundlerError, match="not a recognised bundle source"): + _local_manifest_source(str(weird)) + + +def test_install_bundled_extension_from_zip_offline(tmp_path: Path): + """End-to-end: build → install (offline, local .zip) → list → remove.""" + project = make_project(tmp_path / "proj") + + bundle_dir = tmp_path / "mini" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "bundle": { + "id": "mini", + "name": "Mini", + "version": "1.0.0", + "role": "developer", + "description": "minimal", + "author": "tests", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "extensions": [{"id": "agent-context", "version": "1.0.0"}] + }, + } + ), + encoding="utf-8", + ) + (bundle_dir / "README.md").write_text("# Mini\n", encoding="utf-8") + + runner = CliRunner() + previous = Path.cwd() + os.chdir(project) + try: + build = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) + assert build.exit_code == 0, build.output + artifact = next(bundle_dir.glob("*.zip")) + + install = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"]) + assert install.exit_code == 0, install.output + + from specify_cli.extensions import ExtensionManager + + assert ExtensionManager(project).registry.is_installed("agent-context") + + listing = runner.invoke(app, ["bundle", "list"]) + assert "mini" in listing.output + + remove = runner.invoke(app, ["bundle", "remove", "mini"]) + assert remove.exit_code == 0, remove.output + assert not ExtensionManager(project).registry.is_installed("agent-context") + finally: + os.chdir(previous) diff --git a/tests/integration/test_bundler_offline.py b/tests/integration/test_bundler_offline.py new file mode 100644 index 0000000000..582f69cef0 --- /dev/null +++ b/tests/integration/test_bundler_offline.py @@ -0,0 +1,78 @@ +"""Offline-first tests (Constitution Principle IV). + +Assert that consume/author flows work with no network access: built-in catalogs +resolve offline, file:// catalogs resolve offline, and http(s) sources are +refused (never silently attempted) when network is disabled. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope +from specify_cli.bundler.services.adapters import make_catalog_fetcher +from specify_cli.bundler.services.catalog_stack import CatalogStack +from tests.bundler_helpers import catalog_entry_dict, write_catalog_file + + +def _src(source_id, url, priority=1, policy="install-allowed"): + return CatalogSource( + id=source_id, url=url, priority=priority, + install_policy=InstallPolicy(policy), scope=Scope.PROJECT, + ) + + +def test_builtin_catalog_resolves_offline(): + fetcher = make_catalog_fetcher(allow_network=False) + stack = CatalogStack([_src("default", "builtin://default")], fetcher) + # Built-in default ships empty; search works without network and returns []. + assert stack.search() == [] + + +def test_file_catalog_resolves_offline(tmp_path: Path): + catalog_path = tmp_path / "catalog.json" + write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")}) + fetcher = make_catalog_fetcher(allow_network=False) + stack = CatalogStack([_src("local", str(catalog_path))], fetcher) + resolved = stack.resolve("demo") + assert resolved.entry.id == "demo" + + +def test_http_source_refused_when_offline(): + fetcher = make_catalog_fetcher(allow_network=False) + stack = CatalogStack([_src("remote", "https://example.com/catalog.json")], fetcher) + with pytest.raises(BundlerError, match="Network access disabled"): + stack.resolve("anything") + + +def test_missing_file_catalog_errors_offline(tmp_path: Path): + fetcher = make_catalog_fetcher(allow_network=False) + stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher) + with pytest.raises(BundlerError): + stack.resolve("anything") + + +def test_file_url_catalog_resolves_offline(tmp_path: Path): + catalog_path = tmp_path / "catalog.json" + write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")}) + fetcher = make_catalog_fetcher(allow_network=False) + stack = CatalogStack([_src("local", catalog_path.as_uri())], fetcher) + resolved = stack.resolve("demo") + assert resolved.entry.id == "demo" + + +def test_plain_http_remote_rejected_before_network(): + # HTTPS is required for non-localhost catalogs; reject http:// up front. + fetcher = make_catalog_fetcher(allow_network=True) + stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher) + with pytest.raises(BundlerError, match="must use HTTPS"): + stack.resolve("anything") + + +def test_remote_url_without_host_rejected(): + fetcher = make_catalog_fetcher(allow_network=True) + stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher) + with pytest.raises(BundlerError, match="valid URL with a host"): + stack.resolve("anything") diff --git a/tests/integration/test_bundler_security_paths.py b/tests/integration/test_bundler_security_paths.py new file mode 100644 index 0000000000..85c64919cf --- /dev/null +++ b/tests/integration/test_bundler_security_paths.py @@ -0,0 +1,173 @@ +"""Security tests: path-traversal / symlink confinement (Constitution Principle V). + +These assert the bundler refuses to read or write outside an allowed root, so a +malicious manifest or artifact path cannot escape the project/bundle directory. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath + + +def test_ensure_within_allows_child(tmp_path: Path): + root = tmp_path / "bundle" + root.mkdir() + child = root / "sub" / "file.txt" + assert ensure_within(root, child) == child.resolve() + + +def test_ensure_within_rejects_parent_traversal(tmp_path: Path): + root = tmp_path / "bundle" + root.mkdir() + escape = root / ".." / "secret.txt" + with pytest.raises(BundlerError, match="escapes"): + ensure_within(root, escape) + + +def test_ensure_within_rejects_absolute_outside(tmp_path: Path): + root = tmp_path / "bundle" + root.mkdir() + with pytest.raises(BundlerError): + ensure_within(root, Path("/etc/passwd")) + + +@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows") +def test_ensure_within_rejects_symlink_escape(tmp_path: Path): + root = tmp_path / "bundle" + root.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("secret", encoding="utf-8") + link = root / "link.txt" + link.symlink_to(outside) + with pytest.raises(BundlerError, match="escapes"): + ensure_within(root, link) + + +@pytest.mark.parametrize("rel,safe", [ + ("a/b.txt", True), + ("./a.txt", True), + ("../escape", False), + ("a/../../escape", False), + ("/abs", False), + ("C:/abs", False), + ("C:\\abs", False), + ("\\\\server\\share", False), + ("", False), +]) +def test_is_safe_relpath(rel, safe): + assert is_safe_relpath(rel) is safe + + +def test_build_skips_symlinks(tmp_path: Path): + """Packager must not follow symlinks out of the bundle dir.""" + import yaml + + from specify_cli.bundler.services.packager import build_bundle + from tests.bundler_helpers import valid_manifest_dict + + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (bundle / "README.md").write_text("# Demo", encoding="utf-8") + + if os.name != "nt": + secret = tmp_path / "secret.txt" + secret.write_text("top secret", encoding="utf-8") + (bundle / "leak.txt").symlink_to(secret) + + result = build_bundle(bundle, output_dir=tmp_path / "out") + import zipfile + + with zipfile.ZipFile(result.artifact_path) as archive: + names = archive.namelist() + assert "leak.txt" not in names + assert "bundle.yml" in names + + +def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path): + # Reading bundle-records.json must honour the same confinement as writes: + # a symlinked .specify pointing outside project_root is refused. + from specify_cli.bundler.models.records import load_records + + project = tmp_path / "proj" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "bundle-records.json").write_text( + '{"schema_version": "1.0", "bundles": []}', encoding="utf-8" + ) + (project / ".specify").symlink_to(outside, target_is_directory=True) + + with pytest.raises(BundlerError, match="escapes the allowed root"): + load_records(project) + + +def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path): + # Reading the integration marker must not follow a .specify symlink that + # resolves outside project_root; an escape is treated as "not determinable". + from specify_cli.bundler.lib.project import active_integration + + project = tmp_path / "proj" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "integration.json").write_text( + '{"integration": "leaked"}', encoding="utf-8" + ) + (project / ".specify").symlink_to(outside, target_is_directory=True) + + assert active_integration(project) is None + + +def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path): + from specify_cli.bundler.commands_impl import catalog_config as cc + + project = tmp_path / "proj" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "bundle-catalogs.yml").write_text( + "schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8" + ) + (project / ".specify").symlink_to(outside, target_is_directory=True) + + with pytest.raises(BundlerError, match="escapes the allowed root"): + cc._read(project) + + +def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path): + from specify_cli.bundler.models.catalog import load_source_stack + + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8") + try: + (project / ".specify").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + with pytest.raises(BundlerError, match="escapes the allowed root"): + load_source_stack(project) + + +def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): + from specify_cli.bundler.lib.project import find_project_root + + real = tmp_path / "real-specify" + real.mkdir() + project = tmp_path / "project" + project.mkdir() + try: + (project / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + # A symlinked .specify must not be accepted as a project root. + assert find_project_root(project) is None diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py new file mode 100644 index 0000000000..4a6b2cb808 --- /dev/null +++ b/tests/unit/test_bundler_adapters.py @@ -0,0 +1,71 @@ +"""Unit tests for catalog-fetch adapters (auth + redirect safety).""" +from __future__ import annotations + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy +from specify_cli.bundler.services import adapters + + +def _source(url: str) -> CatalogSource: + return CatalogSource( + id="team", + url=url, + priority=10, + install_policy=InstallPolicy.INSTALL_ALLOWED, + ) + + +class _FakeResponse: + def __init__(self, body: bytes, final_url: str) -> None: + self._body = body + self._final_url = final_url + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *exc) -> bool: + return False + + def geturl(self) -> str: + return self._final_url + + def read(self) -> bytes: + return self._body + + +def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch): + captured: dict = {} + + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + captured["url"] = url + captured["validator"] = redirect_validator + return _FakeResponse(b'{"schema_version": "1.0"}', url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + result = fetcher(_source("https://example.com/c.json")) + assert result == {"schema_version": "1.0"} + assert captured["url"] == "https://example.com/c.json" + + # The validator handed to open_url must reject an HTTP downgrade redirect. + validator = captured["validator"] + assert validator is not None + with pytest.raises(BundlerError, match="must use HTTPS"): + validator("https://example.com/c.json", "http://evil.example/c.json") + # And a same-scheme HTTPS redirect is allowed (no raise). + validator("https://example.com/c.json", "https://cdn.example/c.json") + + +def test_http_fetch_rejects_non_https_final_url(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + # Simulate a response whose final URL silently downgraded to HTTP. + return _FakeResponse(b"{}", "http://evil.example/c.json") + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + with pytest.raises(BundlerError, match="must use HTTPS"): + fetcher(_source("https://example.com/c.json")) diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/unit/test_bundler_catalog_config.py new file mode 100644 index 0000000000..0ccb219a53 --- /dev/null +++ b/tests/unit/test_bundler_catalog_config.py @@ -0,0 +1,181 @@ +"""Unit tests for project catalog-config id derivation and url canonicalization.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.commands_impl import catalog_config as cc + + +def test_derive_id_incorporates_path_stem_for_same_host(): + # Two catalogs on the same host must not collide on the derived id. + a = cc._derive_id("https://example.com/team-a.json") + b = cc._derive_id("https://example.com/team-b.json") + assert a == "example-com-team-a" + assert b == "example-com-team-b" + assert a != b + + +def test_derive_id_distinguishes_tlds(): + # Different TLDs sharing a second-level label must not collide. + com = cc._derive_id("https://example.com/team-a.json") + net = cc._derive_id("https://example.net/team-a.json") + assert com == "example-com-team-a" + assert net == "example-net-team-a" + assert com != net + + +def test_derive_id_falls_back_to_host_when_no_path(): + assert cc._derive_id("https://example.com/") == "example-com" + + +def test_derive_id_for_local_path_uses_stem(): + assert cc._derive_id("./catalogs/my-catalog.json") == "my-catalog" + + +def test_canonicalize_makes_relative_local_path_absolute(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "local.json").write_text("{}", encoding="utf-8") + + result = cc._canonicalize_url("local.json") + + assert Path(result).is_absolute() + assert Path(result) == (tmp_path / "local.json").resolve() + + +def test_canonicalize_leaves_remote_urls_untouched(): + for url in ( + "https://example.com/c.json", + "http://localhost:8080/c.json", + "file:///tmp/c.json", + "builtin://default", + ): + assert cc._canonicalize_url(url) == url + + +def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + catalog = project / "sub" / "cat.json" + catalog.parent.mkdir() + catalog.write_text("{}", encoding="utf-8") + + monkeypatch.chdir(project) + source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50) + + assert Path(source.url).is_absolute() + assert Path(source.url) == catalog.resolve() + + +def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path): + project = tmp_path / "proj" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (project / ".specify").symlink_to(outside, target_is_directory=True) + + with pytest.raises(BundlerError, match="escapes the allowed root"): + cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50) + + +def test_read_rejects_non_list_catalogs(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text( + "schema_version: '1.0'\ncatalogs: not-a-list\n", encoding="utf-8" + ) + + with pytest.raises(BundlerError, match="'catalogs' must be a list"): + cc._read(project) + + +def test_read_rejects_non_mapping_catalog_entry(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text( + "schema_version: '1.0'\ncatalogs:\n - just-a-string\n", encoding="utf-8" + ) + + with pytest.raises(BundlerError, match="each catalog entry must be a mapping"): + cc._read(project) + + +def test_read_rejects_non_mapping_top_level(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text("- a\n- b\n", encoding="utf-8") + + with pytest.raises(BundlerError, match="expected a mapping at the top level"): + cc._read(project) + + +def test_read_rejects_unknown_schema_version(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text( + "schema_version: '2.0'\ncatalogs: []\n", encoding="utf-8" + ) + + with pytest.raises(BundlerError, match="Unsupported catalog config schema version"): + cc._read(project) + + +def test_read_accepts_forward_compatible_minor_schema(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text( + "schema_version: '1.5'\ncatalogs: []\n", encoding="utf-8" + ) + assert cc._read(project) == [] + + +def test_read_tolerates_missing_schema_version(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text("catalogs: []\n", encoding="utf-8") + assert cc._read(project) == [] + + +def test_read_returns_empty_for_missing_or_empty_config(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + assert cc._read(project) == [] + + cc._config_path(project).write_text("schema_version: '1.0'\n", encoding="utf-8") + assert cc._read(project) == [] + + +def test_slug_lowercases_for_deterministic_ids(): + # Mixed-case local filenames must derive the same id regardless of case so + # the case-sensitive duplicate check cannot admit logical duplicates. + assert cc._slug("Team-A") == "team-a" + assert cc._derive_id("./catalogs/Team-A.json") == "team-a" + assert cc._derive_id("https://Example.com/Team-A.json") == "example-com-team-a" + + +def test_derive_id_handles_ipv6_literal(): + # An IPv6 host must not be truncated at the first colon. + derived = cc._derive_id("https://[2001:db8::1]/catalog.json") + assert derived == "2001-db8--1-catalog" + + +def test_derive_id_ignores_credentials_and_port(): + assert cc._derive_id("https://user:pw@example.com:8443/c.json") == "example-com-c" + + +def test_add_source_rejects_unsupported_scheme(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + with pytest.raises(BundlerError, match="Unsupported catalog url scheme"): + cc.add_source(project, "ssh://host/catalog.json", policy="install-allowed", priority=50) + + +def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + monkeypatch.chdir(project) + # A relative path containing ':' but no '://' is still a local path. + source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50) + assert source.url.endswith("weird:name.json") or "weird" in source.url diff --git a/tests/unit/test_bundler_conflict.py b/tests/unit/test_bundler_conflict.py new file mode 100644 index 0000000000..5dbcb3dba1 --- /dev/null +++ b/tests/unit/test_bundler_conflict.py @@ -0,0 +1,54 @@ +"""Unit tests for conflict detection (T034): integration clash and overlap precedence.""" +from __future__ import annotations + +from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef +from specify_cli.bundler.models.records import InstalledBundleRecord +from specify_cli.bundler.services.conflict import detect_conflicts +from tests.bundler_helpers import valid_manifest_dict + + +def _manifest(**overrides) -> BundleManifest: + return BundleManifest.from_dict(valid_manifest_dict(**overrides)) + + +def test_integration_clash_is_blocking(): + manifest = _manifest(integration={"id": "claude"}) + report = detect_conflicts(manifest, active_integration="copilot", installed=[]) + assert report.has_blocking_conflict is True + assert "claude" in report.integration_clash + assert "copilot" in report.integration_clash + + +def test_matching_integration_no_clash(): + manifest = _manifest(integration={"id": "copilot"}) + report = detect_conflicts(manifest, active_integration="copilot", installed=[]) + assert report.has_blocking_conflict is False + + +def test_agnostic_bundle_never_clashes(): + manifest = _manifest() # no integration + report = detect_conflicts(manifest, active_integration="copilot", installed=[]) + assert report.has_blocking_conflict is False + + +def test_overlap_with_other_bundle_is_reported(): + manifest = _manifest() + other = InstalledBundleRecord.create( + bundle_id="other", + version="1.0.0", + components=[ComponentRef(kind="presets", id="preset-a")], + ) + report = detect_conflicts(manifest, active_integration="copilot", installed=[other]) + assert any("preset-a" in o and "other" in o for o in report.overlaps) + assert report.has_blocking_conflict is False + + +def test_same_bundle_reinstall_is_not_overlap(): + manifest = _manifest() + same = InstalledBundleRecord.create( + bundle_id="demo-bundle", + version="1.2.0", + components=[ComponentRef(kind="presets", id="preset-a")], + ) + report = detect_conflicts(manifest, active_integration="copilot", installed=[same]) + assert report.overlaps == [] diff --git a/tests/unit/test_bundler_packager.py b/tests/unit/test_bundler_packager.py new file mode 100644 index 0000000000..5835047f7d --- /dev/null +++ b/tests/unit/test_bundler_packager.py @@ -0,0 +1,193 @@ +"""Unit tests for the artifact packager (T023): contents, versioning, determinism.""" +from __future__ import annotations + +import os +import zipfile +from pathlib import Path + +import pytest +import yaml + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.services.packager import build_bundle +from tests.bundler_helpers import valid_manifest_dict + + +def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path: + directory.mkdir(parents=True, exist_ok=True) + (directory / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (directory / "README.md").write_text("# Demo bundle", encoding="utf-8") + for rel, content in (extra_files or {}).items(): + target = directory / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return directory + + +def test_artifact_named_by_id_and_version(tmp_path: Path): + bundle = _make_bundle(tmp_path / "b") + result = build_bundle(bundle, output_dir=tmp_path / "out") + assert result.artifact_path.name == "demo-bundle-1.2.0.zip" + + +def test_artifact_contains_manifest_and_assets(tmp_path: Path): + bundle = _make_bundle(tmp_path / "b", extra_files={"assets/logo.txt": "logo"}) + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + names = set(archive.namelist()) + assert "bundle.yml" in names + assert "README.md" in names + assert "assets/logo.txt" in names + + +def test_build_refuses_invalid_manifest(tmp_path: Path): + bundle = tmp_path / "b" + bundle.mkdir() + data = valid_manifest_dict() + del data["bundle"]["license"] + (bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle / "README.md").write_text("# x", encoding="utf-8") + with pytest.raises(BundlerError, match="validate"): + build_bundle(bundle, output_dir=tmp_path / "out") + + +def test_build_missing_manifest_errors(tmp_path: Path): + with pytest.raises(BundlerError, match="No bundle.yml"): + build_bundle(tmp_path, output_dir=tmp_path / "out") + + +def test_build_is_deterministic(tmp_path: Path): + bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a", "z.txt": "z"}) + first = build_bundle(bundle, output_dir=tmp_path / "out1") + second = build_bundle(bundle, output_dir=tmp_path / "out2") + with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b: + # Same files, same order (sorted). + assert a.namelist() == b.namelist() + # Fixed timestamps + permissions make each member byte-identical. + for left, right in zip(a.infolist(), b.infolist()): + assert left.date_time == right.date_time + assert left.external_attr == right.external_attr + # The whole artifact is byte-for-byte reproducible. + assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes() + + +def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path): + bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"}) + out_dir = bundle / "dist" + # Build twice into a dir nested in the bundle; the second build must not + # re-package the first artifact, so contents stay identical and bounded. + first = build_bundle(bundle, output_dir=out_dir) + second = build_bundle(bundle, output_dir=out_dir) + with zipfile.ZipFile(second.artifact_path) as archive: + names = archive.namelist() + assert not any(name.startswith("dist/") for name in names) + assert not any(name.endswith(".zip") for name in names) + assert first.file_count == second.file_count + + +def test_prior_version_artifact_not_repackaged(tmp_path: Path): + # An older artifact sitting next to bundle.yml must not be packaged. + bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"}) + (bundle / "demo-bundle-0.9.0.zip").write_bytes(b"PK\x03\x04 old artifact") + result = build_bundle(bundle, output_dir=bundle) + with zipfile.ZipFile(result.artifact_path) as archive: + names = archive.namelist() + assert not any(name.endswith(".zip") for name in names) + assert "demo-bundle-0.9.0.zip" not in names + + +def test_symlinked_directory_is_not_followed(tmp_path: Path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("secret", encoding="utf-8") + bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"}) + link = bundle / "linkdir" + try: + link.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + # Build must succeed (no ensure_within failure) and must not pull in the + # out-of-tree file behind the symlinked directory. + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + names = archive.namelist() + assert "linkdir/secret.txt" not in names + assert not any("secret" in name for name in names) + + +def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path): + data = valid_manifest_dict() + data["bundle"]["id"] = "../evil" + bundle = tmp_path / "b" + bundle.mkdir(parents=True) + (bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle / "README.md").write_text("# x", encoding="utf-8") + with pytest.raises(BundlerError): + build_bundle(bundle, output_dir=tmp_path / "out") + # The traversal target must not have been written outside out_dir. + assert not (tmp_path / "evil-1.2.0.zip").exists() + + +def test_build_refuses_missing_readme(tmp_path: Path): + bundle = tmp_path / "b" + bundle.mkdir() + (bundle / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + with pytest.raises(BundlerError, match="README.md"): + build_bundle(bundle, output_dir=tmp_path / "out") + + +def test_asset_zip_starting_with_bundle_id_is_packaged(tmp_path: Path): + # A non-artifact asset whose name merely starts with the bundle id (but is + # not a semver-named build artifact) must still be included. + bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-assets.zip": "data"}) + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + names = set(archive.namelist()) + assert "demo-bundle-assets.zip" in names + + +def test_prior_semver_artifact_is_excluded(tmp_path: Path): + bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-0.9.0.zip": "old"}) + result = build_bundle(bundle, output_dir=bundle) + with zipfile.ZipFile(result.artifact_path) as archive: + names = set(archive.namelist()) + assert "demo-bundle-0.9.0.zip" not in names + + +def test_prior_artifact_with_prerelease_and_build_is_excluded(tmp_path: Path): + # A semver artifact carrying both prerelease and build metadata must still + # be recognized as a prior build artifact and excluded. + bundle = _make_bundle( + tmp_path / "b", extra_files={"demo-bundle-1.0.0-rc1+build5.zip": "old"} + ) + result = build_bundle(bundle, output_dir=bundle) + with zipfile.ZipFile(result.artifact_path) as archive: + names = set(archive.namelist()) + assert "demo-bundle-1.0.0-rc1+build5.zip" not in names + + +@pytest.mark.skipif( + os.name == "nt", + reason="Windows filesystems do not carry Unix execute bits, so chmod(0o755) " + "is a no-op and there is no executability to preserve.", +) +def test_executable_bit_preserved_in_artifact(tmp_path: Path): + bundle = _make_bundle(tmp_path / "bundle") + script = bundle / "scripts" / "hook.sh" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + script.chmod(0o755) + + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + modes = { + info.filename: (info.external_attr >> 16) & 0o777 + for info in archive.infolist() + } + # Executable source -> 0755; plain text files -> 0644. + assert modes["scripts/hook.sh"] == 0o755 + assert modes["README.md"] == 0o644 diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py new file mode 100644 index 0000000000..f662d22fc9 --- /dev/null +++ b/tests/unit/test_bundler_primitives.py @@ -0,0 +1,133 @@ +"""Unit tests for the primitive-dispatch bridge (T044). + +Covers routing, offline gating, and the network-aware ``DefaultPrimitiveInstaller`` +seam — without touching real catalogs or the network (Constitution Principle II, +offline-first). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.manifest import ComponentRef +from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller +from specify_cli.bundler.services.primitives import ( + _ExtensionKindManager, + _PresetKindManager, + _StepKindManager, + _WorkflowKindManager, + primitive_manager, +) + + +def _component(kind: str, cid: str = "x") -> ComponentRef: + return ComponentRef(kind=kind, id=cid) + + +def test_primitive_manager_routes_each_kind(tmp_path: Path): + assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager) + assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager) + assert isinstance(primitive_manager("workflows", tmp_path), _WorkflowKindManager) + assert isinstance(primitive_manager("steps", tmp_path), _StepKindManager) + + +def test_primitive_manager_rejects_unknown_kind(tmp_path: Path): + with pytest.raises(BundlerError, match="Unknown component kind"): + primitive_manager("bogus", tmp_path) + + +def test_offline_preset_not_bundled_refuses(tmp_path: Path): + manager = primitive_manager("presets", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="network access is disabled"): + manager.install(_component("presets", "definitely-not-bundled")) + + +def test_offline_extension_not_bundled_refuses(tmp_path: Path): + manager = primitive_manager("extensions", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="network access is disabled"): + manager.install(_component("extensions", "definitely-not-bundled")) + + +def test_offline_workflow_refuses_without_network(tmp_path: Path): + manager = primitive_manager("workflows", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="network access is disabled"): + manager.install(_component("workflows")) + + +def test_offline_step_refuses_without_network(tmp_path: Path): + manager = primitive_manager("steps", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="network access is disabled"): + manager.install(_component("steps")) + + +def test_default_installer_threads_allow_network(tmp_path: Path): + installer = DefaultPrimitiveInstaller(allow_network=False) + with pytest.raises(BundlerError, match="network access is disabled"): + installer.install(tmp_path, _component("workflows")) + + +def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): + # A workflow that ships with Spec Kit must install even with --offline. + import specify_cli + import specify_cli._assets as assets + + monkeypatch.setattr( + assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf" + ) + calls: list[str] = [] + monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid)) + + manager = primitive_manager("workflows", tmp_path, allow_network=False) + manager.install(_component("workflows", "bundled-wf")) + + assert calls == ["bundled-wf"] + + +def test_assert_pinned_version_matches_passes(): + from specify_cli.bundler.services.primitives import _assert_pinned_version + + # Equal (including v-prefix/normalization) is accepted; no version pins are no-ops. + _assert_pinned_version("Preset", "p", "2.0.0", "2.0.0") + _assert_pinned_version("Preset", "p", "2.0.0", "v2.0.0") + _assert_pinned_version("Preset", "p", None, "9.9.9") + _assert_pinned_version("Preset", "p", "2.0.0", None) + + +def test_assert_pinned_version_mismatch_raises(): + from specify_cli.bundler.services.primitives import _assert_pinned_version + + with pytest.raises(BundlerError, match="pinned to version 2.0.0"): + _assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0") + + +def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch): + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.setattr( + WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"} + ) + manager = primitive_manager("workflows", tmp_path, allow_network=True) + component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0") + with pytest.raises(BundlerError, match="pinned to version 0.3.0"): + manager.install(component) + + +def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeypatch): + import specify_cli._assets as assets + + calls = {} + + class _FakeManager: + def install_from_directory(self, directory, speckit_version, priority): + calls["priority"] = priority + + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: tmp_path) + + manager = primitive_manager("presets", tmp_path, allow_network=False) + manager._manager = _FakeManager() + manager.install(ComponentRef(kind="presets", id="p", priority=0)) + + # An explicit priority of 0 must be passed through, not replaced by default. + assert calls["priority"] == 0 diff --git a/tests/unit/test_bundler_records.py b/tests/unit/test_bundler_records.py new file mode 100644 index 0000000000..21771adb0f --- /dev/null +++ b/tests/unit/test_bundler_records.py @@ -0,0 +1,190 @@ +"""Unit tests for installed-bundle records and collateral-protection logic.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.manifest import ComponentRef +from specify_cli.bundler.models.records import ( + InstalledBundleRecord, + components_still_needed, + load_records, + records_path, + remove_record, + save_records, + upsert_record, +) + + +def _record(bundle_id: str, comps) -> InstalledBundleRecord: + return InstalledBundleRecord.create( + bundle_id=bundle_id, + version="1.0.0", + components=[ComponentRef(kind=k, id=i) for k, i in comps], + ) + + +def test_save_and_load_roundtrip(tmp_path: Path): + (tmp_path / ".specify").mkdir() + rec = _record("a", [("presets", "p1"), ("steps", "s1")]) + save_records(tmp_path, [rec]) + loaded = load_records(tmp_path) + assert len(loaded) == 1 + assert loaded[0].bundle_id == "a" + assert {(c.kind, c.id) for c in loaded[0].contributed_components} == { + ("presets", "p1"), + ("steps", "s1"), + } + + +def test_load_missing_file_returns_empty(tmp_path: Path): + (tmp_path / ".specify").mkdir() + assert load_records(tmp_path) == [] + + +def test_corrupt_priority_raises_actionable_error(tmp_path: Path): + (tmp_path / ".specify").mkdir() + rec = _record("a", [("presets", "p1")]) + save_records(tmp_path, [rec]) + path = records_path(tmp_path) + data = json.loads(path.read_text(encoding="utf-8")) + data["bundles"][0]["contributed_components"][0]["priority"] = "high" + path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(BundlerError, match="priority must be an integer"): + load_records(tmp_path) + + +def test_upsert_replaces_same_id(): + rec1 = _record("a", [("presets", "p1")]) + rec2 = _record("a", [("presets", "p2")]) + result = upsert_record([rec1], rec2) + assert len(result) == 1 + assert result[0].contributed_components[0].id == "p2" + + +def test_remove_record_drops_target(): + recs = [_record("a", [("presets", "p1")]), _record("b", [("steps", "s1")])] + result = remove_record(recs, "a") + assert [r.bundle_id for r in result] == ["b"] + + +def test_components_still_needed_excludes_target(): + recs = [ + _record("a", [("presets", "shared"), ("steps", "only-a")]), + _record("b", [("presets", "shared")]), + ] + needed = components_still_needed(recs, exclude_bundle_id="a") + assert ("presets", "shared") in needed + assert ("steps", "only-a") not in needed + + +def test_save_records_refuses_symlinked_specify_escape(tmp_path: Path): + # Defense-in-depth: a symlinked .specify pointing outside the project must + # not let records be written outside project_root. + project = tmp_path / "proj" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (project / ".specify").symlink_to(outside, target_is_directory=True) + + with pytest.raises(BundlerError, match="escapes the allowed root"): + save_records(project, [_record("a", [("presets", "p1")])]) + + +def test_load_records_rejects_non_list_bundles(tmp_path: Path): + (tmp_path / ".specify").mkdir() + path = records_path(tmp_path) + path.write_text(json.dumps({"schema_version": "1.0", "bundles": "oops"}), encoding="utf-8") + with pytest.raises(BundlerError, match="'bundles' must be a list"): + load_records(tmp_path) + + +def test_load_records_rejects_non_list_contributed_components(tmp_path: Path): + (tmp_path / ".specify").mkdir() + path = records_path(tmp_path) + payload = { + "schema_version": "1.0", + "bundles": [ + {"bundle_id": "a", "version": "1.0.0", "contributed_components": "oops"} + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="'contributed_components' must be a list"): + load_records(tmp_path) + + +def test_load_records_rejects_unknown_component_kind(tmp_path: Path): + (tmp_path / ".specify").mkdir() + path = records_path(tmp_path) + payload = { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [{"kind": "bogus", "id": "x"}], + } + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="must be one of"): + load_records(tmp_path) + + +def test_load_records_rejects_component_missing_id(tmp_path: Path): + (tmp_path / ".specify").mkdir() + path = records_path(tmp_path) + payload = { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [{"kind": "presets", "id": ""}], + } + ], + } + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="missing its 'id'"): + load_records(tmp_path) + + +def test_load_records_rejects_missing_schema_version(tmp_path: Path): + (tmp_path / ".specify").mkdir() + records_path(tmp_path).write_text(json.dumps({"bundles": []}), encoding="utf-8") + with pytest.raises(BundlerError, match="missing 'schema_version'"): + load_records(tmp_path) + + +def test_load_records_rejects_unknown_schema_version(tmp_path: Path): + (tmp_path / ".specify").mkdir() + payload = {"schema_version": "2.0", "bundles": []} + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="Unsupported records schema version"): + load_records(tmp_path) + + +def test_load_records_rejects_record_missing_bundle_id(tmp_path: Path): + (tmp_path / ".specify").mkdir() + payload = {"schema_version": "1.0", "bundles": [{"version": "1.0.0"}]} + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="missing its 'bundle_id'"): + load_records(tmp_path) + + +def test_load_records_rejects_record_missing_version(tmp_path: Path): + (tmp_path / ".specify").mkdir() + payload = {"schema_version": "1.0", "bundles": [{"bundle_id": "a"}]} + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(BundlerError, match="missing its 'version'"): + load_records(tmp_path) + + +def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path): + (tmp_path / ".specify").mkdir() + payload = {"schema_version": "1.5", "bundles": []} + records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8") + assert load_records(tmp_path) == [] diff --git a/tests/unit/test_bundler_references.py b/tests/unit/test_bundler_references.py new file mode 100644 index 0000000000..1291ba08bd --- /dev/null +++ b/tests/unit/test_bundler_references.py @@ -0,0 +1,41 @@ +"""Unit tests for the bundle reference checker (T047 / FR-005 / SC-007). + +Resolution is offline-first: bundled and installed components resolve without a +network; unknown ids fail online and downgrade to warnings offline. +""" +from __future__ import annotations + +from pathlib import Path + +from specify_cli.bundler.models.manifest import ComponentRef +from specify_cli.bundler.services.references import make_reference_checker +from tests.bundler_helpers import make_project + + +def _ref(kind: str, id_: str) -> ComponentRef: + return ComponentRef(kind=kind, id=id_, version="1.0.0") + + +def test_bundled_extension_resolves(tmp_path: Path): + root = make_project(tmp_path) + warnings: list[str] = [] + check = make_reference_checker(root, allow_network=True, warnings=warnings) + assert check(_ref("extensions", "agent-context")) is None + assert warnings == [] + + +def test_unknown_reference_errors_online(tmp_path: Path): + root = make_project(tmp_path) + warnings: list[str] = [] + check = make_reference_checker(root, allow_network=True, warnings=warnings) + problem = check(_ref("presets", "does-not-exist")) + assert problem is not None + assert "does-not-exist" in problem + + +def test_unknown_reference_warns_offline(tmp_path: Path): + root = make_project(tmp_path) + warnings: list[str] = [] + check = make_reference_checker(root, allow_network=False, warnings=warnings) + assert check(_ref("presets", "does-not-exist")) is None + assert any("does-not-exist" in w for w in warnings) diff --git a/tests/unit/test_bundler_resolver.py b/tests/unit/test_bundler_resolver.py new file mode 100644 index 0000000000..7068a4813e --- /dev/null +++ b/tests/unit/test_bundler_resolver.py @@ -0,0 +1,81 @@ +"""Unit tests for the resolver: version gate and integration compatibility.""" +from __future__ import annotations + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.models.manifest import BundleManifest +from specify_cli.bundler.services.resolver import resolve_install_plan +from tests.bundler_helpers import valid_manifest_dict + + +def _manifest(**overrides) -> BundleManifest: + return BundleManifest.from_dict(valid_manifest_dict(**overrides)) + + +def test_plan_expands_all_components(): + plan = resolve_install_plan( + _manifest(), speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.component_count == 4 + assert plan.bundle_id == "demo-bundle" + + +def test_version_gate_refuses_incompatible(): + manifest = _manifest(requires={"speckit_version": ">=99.0.0"}) + with pytest.raises(BundlerError, match="requires Spec Kit"): + resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + + +def test_integration_clash_halts(): + manifest = _manifest(integration={"id": "claude"}) + with pytest.raises(BundlerError, match="active integration"): + resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + + +def test_agnostic_inherits_active_integration(): + plan = resolve_install_plan( + _manifest(), speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.effective_integration == "copilot" + + +def test_matching_integration_is_allowed(): + manifest = _manifest(integration={"id": "copilot"}) + plan = resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + assert plan.effective_integration == "copilot" + + +def test_pinned_integration_with_indeterminate_active_fails(): + # FR-019 guard: a bundle that pins an integration must not silently adopt it + # when the project's active integration cannot be determined. + manifest = _manifest(integration={"id": "claude"}) + with pytest.raises(BundlerError, match="could not be determined"): + resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration=None + ) + + +def test_pinned_integration_with_indeterminate_active_allows_explicit_override(): + manifest = _manifest(integration={"id": "claude"}) + plan = resolve_install_plan( + manifest, + speckit_version="0.11.2", + active_integration="claude", + integration_explicit=True, + ) + assert plan.effective_integration == "claude" + + +def test_tool_requirements_become_warnings(): + manifest = _manifest(requires={"speckit_version": ">=0.1.0", "tools": ["docker"]}) + plan = resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + assert any("docker" in w for w in plan.warnings) diff --git a/tests/unit/test_bundler_validator.py b/tests/unit/test_bundler_validator.py new file mode 100644 index 0000000000..d69c6535e5 --- /dev/null +++ b/tests/unit/test_bundler_validator.py @@ -0,0 +1,32 @@ +"""Unit tests for the bundle manifest validator service.""" +from __future__ import annotations + +import pytest + +from specify_cli.bundler.models.manifest import BundleManifest +from specify_cli.bundler.services import validator as validator_mod +from specify_cli.bundler.services.validator import validate_manifest +from tests.bundler_helpers import valid_manifest_dict + + +def _manifest(**overrides) -> BundleManifest: + return BundleManifest.from_dict(valid_manifest_dict(**overrides)) + + +def test_invalid_speckit_constraint_reported_as_error(): + manifest = _manifest(requires={"speckit_version": ">>bad"}) + report = validate_manifest(manifest) + assert not report.ok + assert any("speckit_version" in e for e in report.errors) + + +def test_non_bundler_error_not_swallowed(monkeypatch): + # A programming error inside constraint parsing must propagate, not be + # masked behind an "invalid constraint" validation message. + def boom(_value): + raise RuntimeError("unexpected bug") + + monkeypatch.setattr(validator_mod, "parse_constraint", boom) + manifest = _manifest(requires={"speckit_version": ">=1.0.0"}) + with pytest.raises(RuntimeError, match="unexpected bug"): + validate_manifest(manifest) diff --git a/tests/unit/test_bundler_versioning.py b/tests/unit/test_bundler_versioning.py new file mode 100644 index 0000000000..15c42ea673 --- /dev/null +++ b/tests/unit/test_bundler_versioning.py @@ -0,0 +1,68 @@ +"""Unit tests for version parsing and constraint satisfaction (FR-016 gate).""" +from __future__ import annotations + +import pytest + +from specify_cli.bundler import BundlerError +from specify_cli.bundler.lib.versioning import is_semver, satisfies + + +@pytest.mark.parametrize("value,expected", [ + ("1.0.0", True), + ("0.11.2", True), + ("1.2.3-rc1", True), + ("1.2.3-alpha1", True), + ("1.2.3-beta2", True), + ("v1.2.3", True), + ("not-a-version", False), + ("", False), + # packaging.version.Version accepts these partial versions; SemVer must not. + ("1", False), + ("1.0", False), + ("1.2.3.4", False), +]) +def test_is_semver(value, expected): + assert is_semver(value) is expected + + +@pytest.mark.parametrize("installed,constraint,ok", [ + ("0.11.2", ">=0.1.0", True), + ("0.11.2", ">=1.0.0", False), + ("1.0.0", ">=1.0.0,<2.0.0", True), + ("2.0.0", ">=1.0.0,<2.0.0", False), + ("1.5.0", "", True), # empty constraint is permissive + # Prerelease spellings normalize consistently for constraint checks. + ("1.2.3-rc1", ">=1.2.0", True), + ("1.2.3-alpha1", ">=2.0.0", False), +]) +def test_satisfies(installed, constraint, ok): + assert satisfies(installed, constraint) is ok + + +def test_invalid_constraint_raises(): + with pytest.raises(BundlerError): + satisfies("1.0.0", ">>bad") + + +def test_uppercase_v_prefix_tolerated(): + # Mirrors specify_cli._version tag normalization (V -> v). + assert is_semver("V1.2.3") is True + assert satisfies("V1.2.3", ">=1.2.0") is True + + +@pytest.mark.parametrize("installed,constraint,ok", [ + # Prerelease spellings are now normalized inside constraints too, so a + # constraint like ">=1.2.3-rc1" parses (previously raised InvalidSpecifier). + ("1.2.3-rc2", ">=1.2.3-rc1", True), + ("1.2.2", ">=1.2.3-rc1", False), + ("1.5.0", ">=1.2.3-rc1,<2.0.0", True), + ("1.2.3-beta.1", ">=1.2.3-alpha1", True), +]) +def test_satisfies_prerelease_in_constraint(installed, constraint, ok): + assert satisfies(installed, constraint) is ok + + +def test_parse_constraint_empty_is_permissive(): + from specify_cli.bundler.lib.versioning import parse_constraint + + assert str(parse_constraint("")) == "" From f5f76160a3c36ac51a9a0bfa493f2800205f60a3 Mon Sep 17 00:00:00 2001 From: Huy Do Date: Mon, 22 Jun 2026 19:05:54 +0700 Subject: [PATCH 11/42] feat: surface gate detail in the workflow run/resume --json payload (#2965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: surface gate detail in the workflow run/resume --json payload A paused run was indistinguishable from any other pause in the machine-readable outcome, and the gate's prompt/options/choice never left the human-facing stream. Record each step's type in the run state's step results (one engine line) and, when the run sits at a gate, add a gate block (step_id/message/options/choice) to the payload so orchestrators can drive review gates without parsing stdout. Reference implementation for the proposal in #2964. Addresses #2964 * fix(workflow): only surface gate detail in --json when the run is paused Address review (#2965): _gate_outcome() emitted a gate block whenever current_step_id pointed at a gate step. Since RunState.current_step_id is never cleared on completion, a completed/failed run whose last step was a gate leaked stale gate detail in run/resume/status --json. Guard on status == paused. Also assert CLI success in the _run_json test helper before JSON-parsing, and add direct coverage for the suppression guard. Co-Authored-By: Claude Fable 5 * fix(workflows): surface gate block on aborted runs; stabilize message Address Copilot review: - `_gate_outcome` now also surfaces the gate block when a run is `aborted` by a gate rejection (`on_reject: abort`), not only when `paused`. Abort is the only path that sets ABORTED and it leaves current_step_id on the gate, so an orchestrator can read the recorded `choice` for the stop. - Coerce `message` to a string (it may be a non-string YAML literal that GateStep only coerces for interpolation) so the JSON schema stays stable. - Tests: add a CLI-level aborted-path test, a message-coercion test, and extend the suppression test to allow `aborted`; share the run helper via `_invoke_json` to avoid duplicating the invoke boilerplate. Co-Authored-By: Claude Opus 4.8 (1M context) * test(workflows): assert clean exit in gate-abort JSON test Address Copilot review: the gate-abort test parsed stdout without first asserting the CLI exited cleanly, so an invoke failure would surface as an opaque JSON decode error. Route it through `_run_json` (which asserts exit_code == 0 before parsing) and drop the now-redundant `_invoke_json` helper — a gate abort emits the payload and returns, so the run exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: use result.output in run-helper assert; document step_data shape Address Copilot review: - `_run_json` asserted with `result.stdout` in the message, but under `--json` step output is redirected off stdout — the useful diagnostics live on `result.output`. Switch the assertion message to `result.output` (the JSON parse still reads stdout), matching the other CLI tests. - `StepContext.steps` documented a 5-key entry shape; the engine now also persists `type` and `status`. Update the docstring to the canonical 7-key shape so step authors/debuggers see the real record. Co-Authored-By: Claude Opus 4.8 (1M context) * test(workflows): align gate-abort JSON test with aborted→exit-1 After rebasing onto main, a gate abort now emits the --json payload and then exits non-zero (`_run_outcome_exit_code` maps aborted → 1, from the merged exit-code work). Give `_run_json` an `expected_exit` parameter (default 0) so the abort case asserts exit 1 while the paused/completed cases stay at 0 — keeping a single shared helper rather than duplicating the invoke boilerplate. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(workflows): backward-compat gate detection + normalize gate options Address Copilot review: - A run paused by an older version has no persisted step `type`, so `_gate_outcome` would never surface its gate block on resume. Add `_is_gate_step`: prefer the `type` field, but when it is absent fall back to the gate's unique output signature (`on_reject`, written only by GateStep). A record with a different known `type` is still not a gate. - Normalize `options` to a list of strings (mirroring the `message` coercion) so an unvalidated workflow with non-string options can't destabilize the JSON schema. - Tests: options coercion, type-less gate detection, and a type-less non-gate negative case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(workflows): normalize non-list gate options to a stable list[str] Address Copilot review: the prior options normalization only mapped a `list`, returning the raw value for any other shape (scalar/tuple), which contradicted the "stable list[str]" intent. Extract `_normalize_gate_options`: None stays None; list/tuple maps each element through str; any other scalar becomes a single-element list (a bare string is one option, never iterated character-by-character). The emitted schema is now always list[str] | None. Extend the options test to cover list, tuple, bare string, numeric scalar, and None. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(workflows): normalize gate choice to str; portable plain-gate test Address Copilot review: - `_gate_outcome` normalized `message` and `options` but passed `choice` through as-is; an unvalidated gate can record a non-string `choice`, which contradicts the stable-schema rationale. Coerce `choice` to `str | None` (None still means "no decision yet"), consistent with the other two fields. Adds a focused choice-coercion test. - The plain (no-gate) test workflow used `run: "true"`, which fails under cmd.exe on Windows (ShellStep uses shell=True). Use the cross-platform `run: "exit 0"` (matching the exit-code suite's workflows). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 --- src/specify_cli/__init__.py | 74 ++++++++- src/specify_cli/workflows/base.py | 7 +- src/specify_cli/workflows/engine.py | 1 + tests/test_workflows.py | 231 ++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9a05b7e518..87d9a56cbf 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2099,13 +2099,85 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]: def _workflow_run_payload(state: Any) -> dict[str, Any]: """Machine-readable summary of a run/resume outcome.""" - return { + payload = { "run_id": state.run_id, "workflow_id": state.workflow_id, "status": state.status.value, "current_step_id": state.current_step_id, "current_step_index": state.current_step_index, } + gate = _gate_outcome(state) + if gate is not None: + payload["gate"] = gate + return payload + + +def _is_gate_step(step: dict[str, Any]) -> bool: + """Whether a recorded step result is a gate. + + Prefers the persisted ``type`` field, but when it is absent — a run paused + by an older version, whose step record predates ``type`` being stored — + falls back to the gate's unique output signature: only ``GateStep`` writes + an ``on_reject`` key. A record carrying a *different* known ``type`` is not + a gate, so the fallback applies only when ``type`` is missing entirely. + """ + step_type = step.get("type") + if step_type == "gate": + return True + if step_type: + return False + output = step.get("output") + return isinstance(output, dict) and "on_reject" in output + + +def _gate_outcome(state: Any) -> dict[str, Any] | None: + """Gate detail for the structured outcome, when the run rests at a gate. + + A paused or gate-aborted run is otherwise indistinguishable from any + other pause/abort in the machine-readable payload; surfacing the gate's + prompt, options, and (after an interactive choice) the decision lets + orchestrators drive review gates without parsing the human-facing stream. + """ + # Two run states rest *on* a gate: `paused` (awaiting a decision) and + # `aborted` (a gate rejected with `on_reject: abort` — the only path that + # sets ABORTED, leaving current_step_id on that gate). Any other status — + # notably `completed`/`failed` — must be suppressed: current_step_id is + # not cleared when a run whose last executed step was a gate moves on, so + # without this guard it would surface stale detail (run/resume/status). + if getattr(state.status, "value", state.status) not in ("paused", "aborted"): + return None + step = (getattr(state, "step_results", None) or {}).get(state.current_step_id) + if not isinstance(step, dict) or not _is_gate_step(step): + return None + output = step.get("output") or {} + # `message`, `options`, and `choice` may be non-string YAML literals in an + # unvalidated workflow (GateStep coerces none of them for the payload), so + # normalise all three for a stable JSON schema: message → str, options → + # list[str] | None, choice → str | None (None means no decision yet). + message = output.get("message") + choice = output.get("choice") + return { + "step_id": state.current_step_id, + "message": None if message is None else str(message), + "options": _normalize_gate_options(output.get("options")), + "choice": None if choice is None else str(choice), + } + + +def _normalize_gate_options(options: Any) -> list[str] | None: + """Normalise a gate's ``options`` to a stable ``list[str]`` (or ``None``). + + A valid gate stores a list, but an unvalidated workflow could leave a + scalar or tuple. ``None`` stays ``None`` (no options); a list/tuple maps + each element through ``str``; any other scalar becomes a single-element + list — so the emitted JSON schema is always ``list[str] | None``. A bare + string is treated as one option, never iterated character-by-character. + """ + if options is None: + return None + if isinstance(options, (list, tuple)): + return [str(o) for o in options] + return [str(options)] def _run_outcome_exit_code(status_value: str) -> int: diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py index b144ca903d..b61fdb1a08 100644 --- a/src/specify_cli/workflows/base.py +++ b/src/specify_cli/workflows/base.py @@ -47,9 +47,10 @@ class StepContext: #: 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": ...}``. + #: Accumulated step results keyed by step ID. Each entry is the dict the + #: engine persists per step: + #: ``{"type": ..., "integration": ..., "model": ..., "options": ..., + #: "input": ..., "output": ..., "status": ...}``. steps: dict[str, dict[str, Any]] = field(default_factory=dict) #: Current fan-out item (set only inside fan-out iterations). diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 0d56f7df70..f463bc66c1 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -676,6 +676,7 @@ def _execute_steps( # Record step results — prefer resolved values from step output step_data = { + "type": step_type, "integration": result.output.get("integration") or step_config.get("integration") or context.default_integration, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index a87c09cf05..8cbd4a6e8d 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -5341,3 +5341,234 @@ def test_resume_failed_run_exits_nonzero(self, tmp_path, monkeypatch): assert resumed.exit_code == 1, resumed.stdout payload = _json.loads(resumed.stdout) assert payload["status"] == "failed" + + +class TestWorkflowRunGateOutcomeJson: + """CLI-level tests: the --json payload surfaces gate pauses.""" + + _WF_GATE = """ +schema_version: "1.0" +workflow: + id: "gate-json" + name: "Gate JSON" + version: "1.0.0" +steps: + - id: review + type: gate + message: "Approve the thing?" + options: ["approve", "reject"] +""" + + _WF_PLAIN = """ +schema_version: "1.0" +workflow: + id: "plain-json" + name: "Plain JSON" + version: "1.0.0" +steps: + - id: fine + type: shell + run: "exit 0" +""" + + def _run_json(self, tmp_path, monkeypatch, content, *, expected_exit=0): + import json as _json + from typer.testing import CliRunner + from specify_cli import app + + path = tmp_path / "wf.yml" + path.write_text(content, encoding="utf-8") + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(app, ["workflow", "run", str(path), "--json"]) + # Assert the expected exit code before parsing so a real failure + # surfaces the actual output instead of an opaque JSON decode error. + # A terminal run still emits its JSON payload, then exits non-zero on + # ``failed``/``aborted`` (see ``_run_outcome_exit_code``), so callers + # pass the expected code. Use ``result.output`` for the message: + # under ``--json`` step output is redirected off stdout, so the useful + # diagnostics live there. + assert result.exit_code == expected_exit, result.output + return _json.loads(result.stdout) + + def test_gate_pause_carries_gate_block(self, tmp_path, monkeypatch): + # CliRunner stdin is not a TTY, so the gate pauses for resume. + payload = self._run_json(tmp_path, monkeypatch, self._WF_GATE) + assert payload["status"] == "paused" + assert payload["gate"] == { + "step_id": "review", + "message": "Approve the thing?", + "options": ["approve", "reject"], + "choice": None, + } + + def test_completed_run_has_no_gate_block(self, tmp_path, monkeypatch): + payload = self._run_json(tmp_path, monkeypatch, self._WF_PLAIN) + assert payload["status"] == "completed" + assert "gate" not in payload + + def test_gate_abort_carries_gate_block(self, tmp_path, monkeypatch): + # An interactive gate the operator rejects ends the run as `aborted` + # (on_reject defaults to abort), not `paused`. The JSON surface must + # still carry the gate block with the recorded choice so an + # orchestrator can see *why* the run stopped. A gate abort emits the + # payload and then exits non-zero (aborted → exit 1), so the helper + # is told to expect exit code 1. + from specify_cli.workflows.steps.gate import GateStep + + _force_gate_stdin(monkeypatch, tty=True) + monkeypatch.setattr( + GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject") + ) + payload = self._run_json( + tmp_path, monkeypatch, self._WF_GATE, expected_exit=1 + ) + assert payload["status"] == "aborted" + assert payload["gate"] == { + "step_id": "review", + "message": "Approve the thing?", + "options": ["approve", "reject"], + "choice": "reject", + } + + def test_gate_block_emitted_only_when_run_rests_at_gate(self): + # A run rests *on* a gate only while `paused` (awaiting a decision) or + # `aborted` (gate rejected with on_reject: abort). current_step_id is + # not cleared afterwards, so a `completed`/`failed` run whose last + # executed step was a gate must NOT surface a stale gate block. + from types import SimpleNamespace + from specify_cli import _gate_outcome + + gate_step = { + "type": "gate", + "output": { + "message": "m", + "options": ["approve", "reject"], + "choice": "reject", + }, + } + + def _state(status): + return SimpleNamespace( + status=SimpleNamespace(value=status), + current_step_id="review", + step_results={"review": gate_step}, + ) + + assert _gate_outcome(_state("completed")) is None + assert _gate_outcome(_state("failed")) is None + assert _gate_outcome(_state("paused")) is not None + assert _gate_outcome(_state("aborted")) is not None + + def test_gate_block_message_coerced_to_string(self): + # message may be a non-string YAML literal (e.g. a number); the JSON + # surface normalises it so the emitted schema stays stable. + from types import SimpleNamespace + from specify_cli import _gate_outcome + + state = SimpleNamespace( + status=SimpleNamespace(value="paused"), + current_step_id="review", + step_results={ + "review": { + "type": "gate", + "output": {"message": 12.5, "options": ["ok"], "choice": None}, + } + }, + ) + assert _gate_outcome(state)["message"] == "12.5" + + def test_gate_block_options_coerced_to_strings(self): + # options may be non-string / non-list literals in an unvalidated + # workflow; the JSON surface always normalises them to list[str] | None + # so the emitted schema is stable regardless of the input shape. + from types import SimpleNamespace + from specify_cli import _gate_outcome + + def _options_payload(options): + state = SimpleNamespace( + status=SimpleNamespace(value="paused"), + current_step_id="review", + step_results={ + "review": { + "type": "gate", + "output": { + "message": "m", + "options": options, + "choice": None, + }, + } + }, + ) + return _gate_outcome(state)["options"] + + assert _options_payload([1, 2.5]) == ["1", "2.5"] # list + assert _options_payload(("approve", "reject")) == ["approve", "reject"] # tuple + assert _options_payload("approve") == ["approve"] # bare scalar, not iterated + assert _options_payload(7) == ["7"] # numeric scalar + assert _options_payload(None) is None # absent stays absent + + def test_gate_block_choice_coerced_to_string(self): + # An unvalidated gate can record a non-string choice; the JSON + # surface normalises it to str (and keeps None = no decision yet), + # consistent with the message/options normalization. + from types import SimpleNamespace + from specify_cli import _gate_outcome + + def _choice_payload(choice): + state = SimpleNamespace( + status=SimpleNamespace(value="paused"), + current_step_id="review", + step_results={ + "review": { + "type": "gate", + "output": {"message": "m", "options": ["ok"], "choice": choice}, + } + }, + ) + return _gate_outcome(state)["choice"] + + assert _choice_payload(None) is None # no decision yet + assert _choice_payload("reject") == "reject" # normal string passes through + assert _choice_payload(2) == "2" # non-string coerced + + def test_gate_block_detected_without_type_field(self): + # A run paused by an older version has no persisted step `type`. The + # gate is still detected by its unique output signature (`on_reject`), + # so resume surfaces the gate block instead of silently dropping it. + from types import SimpleNamespace + from specify_cli import _gate_outcome + + state = SimpleNamespace( + status=SimpleNamespace(value="paused"), + current_step_id="review", + step_results={ + "review": { + # no "type" key — pre-dates the field being persisted + "output": { + "message": "Approve?", + "options": ["approve", "reject"], + "on_reject": "abort", + "choice": None, + }, + } + }, + ) + gate = _gate_outcome(state) + assert gate is not None + assert gate["step_id"] == "review" + assert gate["options"] == ["approve", "reject"] + + def test_non_gate_step_without_type_is_not_a_gate(self): + # A typeless record lacking the gate signature must NOT be mistaken for + # a gate (the fallback keys off `on_reject`, which only GateStep writes). + from types import SimpleNamespace + from specify_cli import _gate_outcome + + state = SimpleNamespace( + status=SimpleNamespace(value="paused"), + current_step_id="run-tests", + step_results={ + "run-tests": {"output": {"exit_code": 0, "stdout": "ok"}}, + }, + ) + assert _gate_outcome(state) is None From f9c6cf83e566b5a686d896ce5eca80b9240bada5 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:52:13 +0500 Subject: [PATCH 12/42] fix(presets): preserve argument-hint in preset SKILL.md generation (#2978) * fix(presets): preserve argument-hint in preset SKILL.md generation Preset-provided and extension-override commands that declare `argument-hint:` in their frontmatter had it dropped from the generated Claude SKILL.md, and it was re-dropped when a preset was removed and its overridden skill restored. This is the preset-side analog of the extension fix in #2903 / #2916. Factor the argument-hint carry-over into a shared CommandRegistrar.apply_argument_hint() helper and apply it at the four preset skill-generation sites (register, reconcile override-restore, and the core/extension unregister-restore paths). The extension path from The helper writes argument-hint into the frontmatter dict before serialization (so a folded multi-line description cannot be split into invalid YAML) and only for integrations that support it (those exposing inject_argument_hint -- currently Claude), leaving build_skill_frontmatter's shared shape unchanged for every other agent. Core templates carry no argument-hint, so the core-restore path is a no-op. No behavior change for non-Claude agents or the core path. Add regression tests covering a folding description (Claude) and the non-Claude gate (codex). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(presets): address review - guard skill_frontmatter type and tighten apply_argument_hint annotations Add a symmetric isinstance(skill_frontmatter, dict) guard so the helper stays a safe no-op if a caller passes a non-dict, and annotate the parameters as Dict[str, Any] with an optional integration to match real call-site usage. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/agents.py | 27 ++++++++++ src/specify_cli/extensions.py | 18 ++----- src/specify_cli/presets/__init__.py | 8 ++- tests/test_presets.py | 78 +++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 3c06418014..be9859bd04 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -356,6 +356,33 @@ def build_skill_frontmatter( } return skill_frontmatter + @staticmethod + def apply_argument_hint( + source_frontmatter: Dict[str, Any], + skill_frontmatter: Dict[str, Any], + integration: Optional[object] = None, + ) -> None: + """Carry a command's ``argument-hint`` into its generated skill frontmatter. + + Copies ``argument-hint`` from the parsed source command frontmatter into + *skill_frontmatter* (mutated in place) before serialization, so that a + folded multi-line ``description`` cannot be split into invalid YAML. Only + integrations that support the field — those exposing + ``inject_argument_hint`` (currently Claude) — receive the key, leaving + :meth:`build_skill_frontmatter`'s shared shape unchanged for every other + agent. Built-in templates carry no ``argument-hint``, so this is a no-op + for the core path. + """ + if not isinstance(source_frontmatter, dict) or not isinstance(skill_frontmatter, dict): + return + argument_hint = source_frontmatter.get("argument-hint") + if ( + argument_hint + and integration is not None + and hasattr(integration, "inject_argument_hint") + ): + skill_frontmatter["argument-hint"] = str(argument_hint) + @staticmethod def resolve_skill_placeholders( agent_name: str, frontmatter: dict, body: str, project_root: Path diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 42ba2fe888..7efd5b4246 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1061,20 +1061,10 @@ def _register_extension_skills( ) # Preserve the command's argument-hint in the generated skill, # mirroring the core template path (ClaudeIntegration.setup injects - # it for built-in commands). The value is added to the frontmatter - # dict before serialization — rather than via the string-based - # inject_argument_hint helper — so that a folded multi-line - # description cannot be split by the inserted line. Gated on the - # integration exposing inject_argument_hint so only argument-hint - # aware agents receive the key, leaving build_skill_frontmatter's - # shared shape unchanged for every other agent. - argument_hint = frontmatter.get("argument-hint") - if ( - argument_hint - and integration is not None - and hasattr(integration, "inject_argument_hint") - ): - frontmatter_data["argument-hint"] = str(argument_hint) + # it for built-in commands). See CommandRegistrar.apply_argument_hint + # for why the value is added to the dict before serialization rather + # than via the string-based inject_argument_hint helper. + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) frontmatter_text = dump_frontmatter(frontmatter_data) # Derive a human-friendly title from the command name diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f8b9bac698..b30c6f3c17 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1064,11 +1064,14 @@ def _reconcile_skills(self, command_names: List[str]) -> None: body = self._resolve_skill_command_refs( body, registrar, selected_ai ) + from ..integrations import get_integration + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None fm_data = registrar.build_skill_frontmatter( selected_ai if isinstance(selected_ai, str) else "", skill_name, desc, f"override:{cmd_name}", ) + registrar.apply_argument_hint(fm, fm_data, integration) fm_text = dump_frontmatter(fm_data) skill_title = self._skill_title_from_command(cmd_name) skill_content = ( @@ -1076,8 +1079,6 @@ def _reconcile_skills(self, command_names: List[str]) -> None: 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") @@ -1346,6 +1347,7 @@ def _register_skills( enhanced_desc, f"preset:{manifest.id}", ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) frontmatter_text = dump_frontmatter(frontmatter_data) skill_content = ( f"---\n" @@ -1442,6 +1444,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: enhanced_desc, f"templates/commands/{short_name}.md", ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) frontmatter_text = dump_frontmatter(frontmatter_data) skill_title = self._skill_title_from_command(short_name) skill_content = ( @@ -1479,6 +1482,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: frontmatter.get("description", f"Extension command: {command_name}"), extension_restore["source"], ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) frontmatter_text = dump_frontmatter(frontmatter_data) skill_content = ( f"---\n" diff --git a/tests/test_presets.py b/tests/test_presets.py index de6054d99c..a64e28bb33 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2997,6 +2997,84 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): metadata = manager.registry.get("self-test") assert "speckit-specify" in metadata.get("registered_skills", []) + def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint): + """Install a preset whose command declares argument-hint; return the SKILL.md path.""" + self._write_init_options(project_dir, ai=ai) + self._create_skill(skills_dir, "speckit-hinttest-cmd") + (project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / f"hint-preset-{ai}" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text( + "---\n" + f'description: "{description}"\n' + f'argument-hint: "{arg_hint}"\n' + "---\n\n" + "Preset command body.\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": f"hint-preset-{ai}", + "name": "Hint Preset", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.hinttest.cmd", + "file": "commands/speckit.hinttest.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") + return skills_dir / "speckit-hinttest-cmd" / "SKILL.md" + + def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir): + """argument-hint from a preset command must survive into the SKILL.md. + + Follow-up to #2903/#2916 for the preset skill generator. The + description is long enough to fold across lines when serialized, + guarding against an in-place string injection that would split the + folded scalar into invalid YAML. + """ + long_description = ( + "Build and maintain a lean, static context/ knowledge folder so " + "coding agents load only what is relevant and save tokens" + ) + arg_hint = " [area] [slug] [-- notes]" + skills_dir = project_dir / ".claude" / "skills" + + skill_file = self._install_arg_hint_preset( + project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint + ) + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + assert parsed["argument-hint"] == arg_hint + assert parsed["description"] == long_description + + def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir): + """Non-Claude skills agents must not receive argument-hint in preset skills.""" + arg_hint = " [area]" + skills_dir = project_dir / ".agents" / "skills" + + skill_file = self._install_arg_hint_preset( + project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint + ) + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + assert "argument-hint" not in parsed + def test_register_skills_resolves_command_refs(self, project_dir, temp_dir): """Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717). From 902f5431f9f34ca037795508dfe414bef673f594 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:25:29 -0500 Subject: [PATCH 13/42] Harden command registration path handling (#3088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: validate command 'file' field against path traversal in registrar CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test/fix: address review — robust absolute-path test and tolerant reads - register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: rename traversal fixtures to avoid CodeQL secret-storage false positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject Windows drive-relative 'file' values in traversal guards is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use anchor under both path flavors so POSIX-absolute is rejected on Windows On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False (no drive), so the prior native-Path check let a leading-slash 'file' value through and the manifest validator did not raise. Evaluate the value under both PurePosixPath and PureWindowsPath and reject any non-empty anchor — covering POSIX-absolute, Windows drive-relative, Windows absolute, and rooted-without-drive — in both the registrar guard and the manifest validator. The registrar join now uses the raw 'file' string so native separators are handled by the resolve()/relative_to() containment check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate command 'file' field against path traversal in registrar CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test/fix: address review — robust absolute-path test and tolerant reads - register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: rename traversal fixtures to avoid CodeQL secret-storage false positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject Windows drive-relative 'file' values in traversal guards is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use anchor under both path flavors so POSIX-absolute is rejected on Windows On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False (no drive), so the prior native-Path check let a leading-slash 'file' value through and the manifest validator did not raise. Evaluate the value under both PurePosixPath and PureWindowsPath and reject any non-empty anchor — covering POSIX-absolute, Windows drive-relative, Windows absolute, and rooted-without-drive — in both the registrar guard and the manifest validator. The registrar join now uses the raw 'file' string so native separators are handled by the resolve()/relative_to() containment check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: harden register_commands inputs and tighten manifest 'file' validation Address review feedback on #3088: - register_commands(): skip non-string/empty 'file' values instead of raising TypeError, and hoist source_dir.resolve() out of the per-command loop. - ExtensionManifest._validate(): reject 'file' values with leading/trailing whitespace with a clear ValidationError instead of a confusing missing-file failure later. - tests: add non-string 'file' and whitespace cases; use yaml.safe_dump with explicit utf-8 encoding in the manifest validation test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: align runtime '..' policy, correct comment, dedupe test helper Address review feedback on #3088: - register_commands(): also reject '..' segments under both POSIX and Windows semantics, keeping runtime policy consistent with ExtensionManifest._validate() and the skill/preset readers (not just relying on the resolve()/relative_to() containment backstop). - Replace the version-dependent is_absolute() claim in the extensions.py comment with the actual portability rationale (native Path is OS- dependent; C:foo is anchored but not absolute). - Extract the duplicated leak-detection assertion into _assert_no_marker_leak() and add an in-bounds '..' payload that exercises the new runtime '..' rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract shared path-safety policy and warn on unreadable command files Introduce relative_extension_path_violation() in _utils.py as the single source of truth for the extension-relative `file` path-safety policy, and use it from both the runtime registrar guard (agents.py) and the manifest-load validator (extensions.py) so the two cannot drift. Warn (instead of silently skipping) when an in-bounds command file exists but cannot be read/decoded, surfacing misconfigured extensions. Add unit tests for the shared helper, a read-skip warning test, and make the in-bounds `..` test create its target file so the skip is attributable to the `..` rejection rather than file absence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retrigger CI Empty commit to re-trigger code scanning / CodeQL analysis on the PR merge ref. Assisted-by: GitHub Copilot CLI (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_utils.py | 40 +++++- src/specify_cli/agents.py | 32 ++++- src/specify_cli/extensions.py | 14 +- tests/test_extensions.py | 34 +++++ tests/test_registrar_path_traversal.py | 182 ++++++++++++++++++++++++- 5 files changed, 295 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index 30c59f553a..d921e591d9 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -9,7 +9,7 @@ import subprocess import tempfile import yaml -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any from ._console import console @@ -17,6 +17,44 @@ CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" +def relative_extension_path_violation(value: Any) -> str | None: + """Return why ``value`` is unsafe as an extension-relative ``file`` path. + + Single source of truth for the path-safety policy shared by + ``ExtensionManifest._validate()`` (manifest-load validation) and + ``CommandRegistrar.register_commands()`` (runtime guard), so the two cannot + drift. Returns a human-readable reason string when ``value`` is unsafe, or + ``None`` when it is an acceptable relative path within the extension + directory. + + Policy: the value must be a non-empty string with no leading/trailing + whitespace, no absolute/anchored form, and no ``..`` traversal. The value is + evaluated under both POSIX and Windows path semantics because a native + ``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret + Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()`` + yet resolves against the CWD on its drive). Rejecting any non-empty anchor + covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows + absolute (``C:\\foo``), and UNC/rooted forms. + """ + if not isinstance(value, str) or not value: + return "must be a non-empty string" + if value.strip() != value: + return "must not have leading or trailing whitespace" + posix_path = PurePosixPath(value) + win_path = PureWindowsPath(value) + if ( + posix_path.anchor + or win_path.anchor + or ".." in posix_path.parts + or ".." in win_path.parts + ): + return ( + "must be a relative path within the extension directory " + "(no absolute paths, drive letters, or '..' segments)" + ) + return None + + def dump_frontmatter(data: dict[str, Any]) -> str: """Serialize skill/command frontmatter to a YAML string. diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index be9859bd04..0fd6437737 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -16,6 +16,7 @@ import yaml from ._init_options import is_ai_skills_enabled, load_init_options +from ._utils import relative_extension_path_violation def _build_agent_configs() -> dict[str, Any]: @@ -567,17 +568,42 @@ def register_commands( registered = [] is_cline_ext = agent_name == "cline" and source_id != "core" + source_root = source_dir.resolve() for cmd_info in commands: cmd_name = cmd_info["name"] aliases = cmd_info.get("aliases", []) cmd_file = cmd_info["file"] - source_file = source_dir / cmd_file - if not source_file.exists(): + # Guard against path traversal using the single shared policy in + # relative_extension_path_violation(), so the runtime guard stays + # aligned with ExtensionManifest._validate() and the skill/preset + # readers. Skip a malformed/unsafe ``file`` (non-string, empty, + # whitespace, absolute/anchored, or ``..`` traversal); the + # resolve()/relative_to() check below is the final containment + # backstop. + if relative_extension_path_violation(cmd_file): + continue + try: + source_file = (source_root / cmd_file).resolve() + source_file.relative_to(source_root) # raises ValueError if outside + except (OSError, ValueError): + continue + + if not source_file.is_file(): continue - content = source_file.read_text(encoding="utf-8") + try: + content = source_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + import warnings + + warnings.warn( + f"Skipping command '{cmd_name}': could not read source file " + f"'{cmd_file}' ({exc.__class__.__name__}: {exc}).", + stacklevel=2, + ) + continue frontmatter, body = self.parse_frontmatter(content) if frontmatter.get("strategy") == "wrap": diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 7efd5b4246..72d1e66e97 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -28,7 +28,7 @@ from ._init_options import is_ai_skills_enabled from ._invocation_style import is_slash_skills_agent -from ._utils import dump_frontmatter +from ._utils import dump_frontmatter, relative_extension_path_violation from .catalogs import CatalogEntry as BaseCatalogEntry from .catalogs import CatalogStackBase @@ -290,6 +290,18 @@ def _validate(self): if "name" not in cmd or "file" not in cmd: raise ValidationError("Command missing 'name' or 'file'") + # Validate the 'file' field at manifest-load time using the single + # shared policy in relative_extension_path_violation(), so manifest + # validation cannot drift from the runtime registrar guard. This is + # defense-in-depth: the command/skill/preset readers also contain + # the resolved path, but rejecting an unsafe value here surfaces a + # clear error instead of silently skipping the command. + cmd_file = cmd["file"] + reason = relative_extension_path_violation(cmd_file) + if reason: + label = repr(cmd_file) if isinstance(cmd_file, str) else f"for command '{cmd.get('name')}'" + raise ValidationError(f"Invalid command 'file' {label}: {reason}") + # Validate command name format if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]): corrected = self._try_correct_command_name(cmd["name"], ext["id"]) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e063571b14..9cf0167ce5 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -377,6 +377,40 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) + @pytest.mark.parametrize( + "bad_file", + ["../../../outside.md", "../escape.md", "a/../../escape.md", "/abs/outside.md", "C:escape.md", "C:\\Windows\\x.md", "..\\..\\escape.md"], + ) + def test_command_file_traversal_rejected(self, temp_dir, valid_manifest_data, bad_file): + """Manifest 'file' field with traversal/absolute path raises ValidationError. + + Defense-in-depth for GHSA-w5fv-7w9x-7fc5. + """ + import yaml + + valid_manifest_data["provides"]["commands"][0]["file"] = bad_file + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command 'file'"): + ExtensionManifest(manifest_path) + + @pytest.mark.parametrize("bad_file", [" commands/hello.md", "commands/hello.md ", "\tcommands/hello.md"]) + def test_command_file_whitespace_rejected(self, temp_dir, valid_manifest_data, bad_file): + """Manifest 'file' with leading/trailing whitespace raises ValidationError.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["file"] = bad_file + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="leading or trailing whitespace"): + 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 diff --git a/tests/test_registrar_path_traversal.py b/tests/test_registrar_path_traversal.py index fc423b4056..1f97a98d87 100644 --- a/tests/test_registrar_path_traversal.py +++ b/tests/test_registrar_path_traversal.py @@ -6,6 +6,7 @@ import pytest from specify_cli.agents import CommandRegistrar +from specify_cli._utils import relative_extension_path_violation TRAVERSAL_PAYLOADS = [ @@ -135,8 +136,185 @@ def test_rejects_traversal_names(self, tmp_path, bad_name): _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) -class TestSafeRegistration: - """Positive regression — well-formed names continue to register.""" +ABS_OUTSIDE = "__ABS_OUTSIDE__" + +FILE_FIELD_PAYLOADS = [ + "../outside.txt", + "../../outside.txt", + "commands/../../outside.txt", + "C:outside.txt", + ABS_OUTSIDE, +] + + +def _resolve_payload(bad_file: str, outside_file: Path) -> str: + """Map the absolute-path sentinel to the real, existing outside file. + + Using the temp file's own absolute path (instead of ``/etc/passwd``) + guarantees the file exists on every platform — so the test fails if the + absolute-path guard regresses, rather than passing because the target + happens not to exist (e.g. on Windows runners). + """ + return str(outside_file) if bad_file == ABS_OUTSIDE else bad_file + + +def _assert_no_marker_leak(project: Path, marker: str) -> None: + """Fail if ``marker`` content was written into any file under ``project``.""" + leaked = [ + p for p in project.rglob("*") + if p.is_file() and marker in p.read_text(encoding="utf-8", errors="ignore") + ] + assert leaked == [], f"Outside file leaked into generated command: {leaked}" + + +class TestCommandFileTraversal: + """The manifest ``file`` field must not read files outside source_dir. + + Regression for GHSA-w5fv-7w9x-7fc5: ``register_commands`` read + ``source_dir / cmd_file`` with no containment check, so a manifest with + a traversal (``file: ../../../outside.txt``) or an absolute path read an + arbitrary host file verbatim into the generated agent command. + """ + + @pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS) + def test_claude_skips_traversal_in_file_field(self, tmp_path, bad_file): + project, ext_dir = _project_and_source(tmp_path) + (project / ".claude" / "skills").mkdir(parents=True) + + outside_file = tmp_path / "outside.txt" + outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8") + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "claude", + [{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}], + "myext", + ext_dir, + project, + ) + + assert registered == [] + _assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER") + + @pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS) + def test_gemini_skips_traversal_in_file_field(self, tmp_path, bad_file): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + outside_file = tmp_path / "outside.txt" + outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8") + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}], + "myext", + ext_dir, + project, + ) + + assert registered == [] + _assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER") + + @pytest.mark.parametrize("bad_value", [None, 123, "", ["x"]]) + def test_non_string_file_is_skipped(self, tmp_path, bad_value): + """A non-string/empty ``file`` must be skipped, not raise TypeError.""" + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [{"name": "speckit.myext.hello", "file": bad_value, "aliases": []}], + "myext", + ext_dir, + project, + ) + + assert registered == [] + + def test_dotdot_rejected_even_when_target_is_in_bounds(self, tmp_path): + """An in-bounds ``..`` payload is rejected by the ``..`` check itself. + + ``commands/../cmd.md`` resolves to ``ext_dir/cmd.md`` — inside + source_dir — so the resolve()/relative_to() containment backstop would + allow it. Creating that target file ensures the command is skipped + because of the ``..`` rejection, not merely because the file is absent. + """ + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + (ext_dir / "cmd.md").write_text( + "---\ndescription: test\n---\n\nbody\n", encoding="utf-8" + ) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [{"name": "speckit.myext.hello", "file": "commands/../cmd.md", "aliases": []}], + "myext", + ext_dir, + project, + ) + + assert registered == [] + + +class TestRelativeExtensionPathPolicy: + """Unit tests for the shared ``relative_extension_path_violation`` policy.""" + + @pytest.mark.parametrize( + "value", + [ + "commands/hello.md", + "hello.md", + "a/b/c/hello.md", + ], + ) + def test_safe_relative_paths_have_no_violation(self, value): + assert relative_extension_path_violation(value) is None + + @pytest.mark.parametrize( + "value", + [ + None, + 123, + ["x"], + "", + " ", + " hello.md", + "hello.md ", + "/abs/outside.md", + "/etc/passwd", + "C:foo.md", + "C:\\Windows\\system32", + "\\\\server\\share\\x.md", + "../escape.md", + "commands/../../escape.md", + ], + ) + def test_unsafe_values_report_violation(self, value): + assert relative_extension_path_violation(value) is not None + + +class TestReadSkipWarning: + """Unregisterable but in-bounds files warn instead of failing silently.""" + + def test_unreadable_target_warns_and_skips(self, tmp_path): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + (ext_dir / "cmd.md").write_bytes(b"\xff\xfe\x00\x80bad") + + registrar = CommandRegistrar() + with pytest.warns(UserWarning): + registered = registrar.register_commands( + "gemini", + [{"name": "speckit.myext.hello", "file": "cmd.md", "aliases": []}], + "myext", + ext_dir, + project, + ) + + assert registered == [] def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path): """Lexical check must not block legitimately symlinked sub-directories. From a4c86b37283eac1108b9a6551af0b2187028bb3a Mon Sep 17 00:00:00 2001 From: Anton Starikov Date: Mon, 22 Jun 2026 17:31:53 +0200 Subject: [PATCH 14/42] fix(build): include specify_cli.bundler.lib in built distribution (#3085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(build): include specify_cli.bundler.lib in built distribution The root .gitignore carried unanchored `lib/` and `lib64/` patterns from the standard GitHub Python template (intended to ignore a top-level build/venv `lib` directory). Being unanchored, they also match the source package `src/specify_cli/bundler/lib/`. Hatchling applies .gitignore patterns as build-exclusion rules, so the `bundler/lib` package (project.py, versioning.py, yamlio.py) was silently dropped from the built wheel even though it is tracked in git. Since commands/bundle/__init__.py imports `specify_cli.bundler.lib.project` at module load, any install built from source (e.g. `uv tool install --from git+...`) crashed on startup with: ModuleNotFoundError: No module named 'specify_cli.bundler.lib' which broke the entire CLI — every command, including `specify init`. Anchor the patterns to the repo root (`/lib/`, `/lib64/`) so they only match the intended top-level build artifacts and no longer exclude the source package. * ci: retrigger checks Empty commit to re-dispatch a wedged CodeQL run that never started, unblocking code scanning merge protection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c3a3ab525b..21e211c7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ From f63c3d7402aa97bf94e65b30c8e7adaf586edf54 Mon Sep 17 00:00:00 2001 From: daisuke Date: Tue, 23 Jun 2026 00:35:18 +0900 Subject: [PATCH 15/42] fix: anchor lib/ and lib64/ patterns to repo root in .gitignore (#3083) The unanchored `lib/` pattern matched any nested `lib/` directory, including `src/specify_cli/bundler/lib/` added in #3070. Hatchling uses .gitignore as its file-exclusion filter, so the bundler subpackage was silently dropped from wheels built via `uvx --from git+...`, causing: ModuleNotFoundError: No module named 'specify_cli.bundler.lib' Prefixing with `/` anchors both patterns to the repository root, which is the intended scope (exclude top-level lib/ artefacts from old-style setuptools installs) without affecting nested source packages. From 1cb935997c1ab4cbecabfcb3aa8b901bce64e9e1 Mon Sep 17 00:00:00 2001 From: Huy Do Date: Mon, 22 Jun 2026 22:44:23 +0700 Subject: [PATCH 16/42] fix: fail loudly on an unknown workflow expression filter (#3074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fail loudly on an unknown workflow expression filter The expression evaluator's filter dispatch fell through to `return value` for any unregistered filter, so a typo'd or unsupported filter such as `{{ items | length }}` rendered the value unchanged with no error and the run completed — a silent wrong result. Raise a clear ValueError instead, naming the offending filter and the valid ones, mirroring the strict handling already used for `from_json`. The five registered filters (default/join/map/contains/from_json) are unchanged; the `name(arg)` form of an unknown filter is now caught too. * fix: distinguish a misused registered filter from an unknown one; cover map Address the review feedback on the unknown-filter fail-loud path: - A *registered* filter used in an unsupported form (e.g. `| join` or `| map` with no argument) raised the misleading "unknown filter ''" — the filter is registered, the syntax isn't. It now raises a message naming it as a known filter misused. A new `_REGISTERED_FILTERS` constant drives the distinction. - `test_registered_filters_unaffected` now also exercises `map('attr')`, which it previously claimed to cover but didn't. Add `test_registered_filter_unsupported_form_raises` to pin the new path. * fix: include the no-arg default form in the filter-error hint Copilot review: the hint listed default('x') but omitted the valid no-argument default form (| default), which this module supports. --- src/specify_cli/workflows/expressions.py | 35 ++++++++++++- tests/test_workflows.py | 67 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 6259b59de0..ca10b24d1b 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -12,6 +12,19 @@ from typing import Any +# The filters the expression evaluator recognizes. Used to tell a +# *registered* filter used in an unsupported form (e.g. `| join` with no +# argument) apart from a genuinely unknown filter name, so each raises an +# error that names the real problem. +_REGISTERED_FILTERS: tuple[str, ...] = ( + "default", + "join", + "map", + "contains", + "from_json", +) + + # -- Custom filters ------------------------------------------------------- def _filter_default(value: Any, default_value: Any = "") -> Any: @@ -192,7 +205,27 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: filter_name = filter_expr.strip() if filter_name == "default": return _filter_default(value) - return value + # No recognized filter matched. Fail loudly rather than silently + # returning the unfiltered value: a passthrough turns a mis-typed or + # unsupported filter into a wrong result with no signal. Mirrors the + # strict `from_json` handling above. Distinguish a *registered* filter + # used in an unsupported form (e.g. `| join` or `| map` with no + # argument) from a genuinely unknown filter name, so the message names + # the real problem instead of calling a known filter "unknown". + leading_name = re.match(r"\w+", filter_expr) + name = leading_name.group(0) if leading_name else filter_expr + expected = ( + "expected one of default or default('x'), join('sep'), " + "map('attr'), contains('s'), or from_json" + ) + if name in _REGISTERED_FILTERS: + raise ValueError( + f"filter '{name}' used in an unsupported form (got " + f"'| {filter_expr}'): {expected}" + ) + raise ValueError( + f"unknown filter '{name}': {expected} (got '| {filter_expr}')" + ) # Boolean operators — parse 'or' first (lower precedence) so that # 'a or b and c' is evaluated as 'a or (b and c)'. diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 8cbd4a6e8d..512b354158 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -342,6 +342,73 @@ def test_filter_from_json_rejects_malformed_forms(self): "{{ steps.emit.output.stdout | " + bad + " }}", ctx ) + def test_filter_unknown_name_raises(self): + # An unregistered filter name must fail loudly rather than silently + # returning the unfiltered value (which hides a typo / unsupported + # filter as a wrong result). + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"items": [1, 2, 3]}) + with pytest.raises(ValueError, match="unknown filter 'length'"): + evaluate_expression("{{ inputs.items | length }}", ctx) + + def test_filter_unknown_name_with_args_raises(self): + # The unknown-filter path must also catch the `name(arg)` form, which + # otherwise falls through the recognized-args branch silently. + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"text": "hello"}) + with pytest.raises(ValueError, match="unknown filter 'upper'"): + evaluate_expression("{{ inputs.text | upper('x') }}", ctx) + + def test_registered_filters_unaffected(self): + # Regression: all five registered filters keep working unchanged. + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + inputs={ + "tags": ["a", "b", "c"], + "text": "hello world", + "missing": "", + "rows": [{"id": "a"}, {"id": "b"}], + }, + steps={"emit": {"output": {"stdout": '{"n": 1}'}}}, + ) + assert ( + evaluate_expression("{{ inputs.missing | default('fb') }}", ctx) == "fb" + ) + assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c" + assert evaluate_expression("{{ inputs.rows | map('id') }}", ctx) == ["a", "b"] + assert ( + evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True + ) + assert evaluate_expression( + "{{ steps.emit.output.stdout | from_json }}", ctx + ) == {"n": 1} + + def test_registered_filter_unsupported_form_raises(self): + # A *registered* filter used in an unsupported form (e.g. `| join` with + # no argument) must fail loudly with a message that names it as a known + # filter misused, not as an "unknown filter". + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"tags": ["a", "b", "c"]}) + with pytest.raises( + ValueError, match="filter 'join' used in an unsupported form" + ): + evaluate_expression("{{ inputs.tags | join }}", ctx) + with pytest.raises( + ValueError, match="filter 'map' used in an unsupported form" + ): + evaluate_expression("{{ inputs.tags | map }}", ctx) + def test_condition_evaluation(self): from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.base import StepContext From e39cb51338878b220e92a587afaa2f47386f4b07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:59:32 -0500 Subject: [PATCH 17/42] Update Linear Integration extension to v0.7.0 (#3089) Update linear extension submitted by @ashbrener: - extensions/catalog.community.json (version, download_url, updated_at) - docs/community/extensions.md community extensions table (no display fields changed) Closes #3087 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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 78b7ceab48..637d671284 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-18T00:00:00Z", + "updated_at": "2026-06-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1540,8 +1540,8 @@ "id": "linear", "description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).", "author": "Ash Brener", - "version": "0.6.0", - "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.6.0.zip", + "version": "0.7.0", + "download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.7.0.zip", "repository": "https://github.com/ashbrener/spec-kit-linear-sync", "homepage": "https://github.com/ashbrener/spec-kit-linear-sync", "documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md", @@ -1568,7 +1568,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-06-01T00:00:00Z", - "updated_at": "2026-06-17T00:00:00Z" + "updated_at": "2026-06-22T00:00:00Z" }, "loop": { "name": "Loop Engineering", From 85d59d2d70c7bc9b232d674804123d063cb34dec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:10:53 -0500 Subject: [PATCH 18/42] [extension] Add Tasks to GitHub Project extension to community catalog (#3090) * Add Tasks to GitHub Project extension to community catalog Add tasks-to-project extension submitted by @mancioshell to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3082 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert catalog re-serialization churn and drop git tool requirement Restore extensions/catalog.community.json to upstream content and add only the tasks-to-project entry, removing the unrelated Unicode-escape and tool-object expansion churn across the catalog. Drop the git tool from the entry's requirements to match the published extension.yml (gh + python3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index e158c62e9d..889b8424d6 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -128,6 +128,7 @@ The following community-contributed extensions are available in [`catalog.commun | Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) | | Superspec | 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) | +| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) | | Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) | | Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 637d671284..7b41c6aeb6 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -3541,6 +3541,44 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "tasks-to-project": { + "name": "Tasks to GitHub Project", + "id": "tasks-to-project", + "description": "Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board.", + "author": "Alessandro Mancini", + "version": "0.2.0", + "download_url": "https://github.com/mancioshell/spec-kit-tasks-to-project/archive/refs/tags/v0.2.0.zip", + "repository": "https://github.com/mancioshell/spec-kit-tasks-to-project", + "homepage": "https://github.com/mancioshell/spec-kit-tasks-to-project", + "documentation": "https://github.com/mancioshell/spec-kit-tasks-to-project/blob/main/README.md", + "changelog": "https://github.com/mancioshell/spec-kit-tasks-to-project/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "integration", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.2.0", + "tools": [ + { "name": "gh", "required": true }, + { "name": "python3", "required": true } + ] + }, + "provides": { + "commands": 2, + "hooks": 2 + }, + "tags": [ + "github", + "project", + "kanban", + "automation", + "tasks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-22T00:00:00Z" + }, "team-assign": { "name": "Team Assign", "id": "team-assign", From 5012ba46135b2b528906440eb95d7f53fb4dfa44 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:14:48 -0500 Subject: [PATCH 19/42] chore: release 0.11.4, begin 0.11.5.dev0 development (#3092) * chore: bump version to 0.11.4 * chore: begin 0.11.5.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 66d82ebe3b..cbac946304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.11.4] - 2026-06-22 + +### Changed + +- [extension] Add Tasks to GitHub Project extension to community catalog (#3090) +- Update Linear Integration extension to v0.7.0 (#3089) +- fix: fail loudly on an unknown workflow expression filter (#3074) +- fix: anchor lib/ and lib64/ patterns to repo root in .gitignore (#3083) +- fix(build): include specify_cli.bundler.lib in built distribution (#3085) +- Harden command registration path handling (#3088) +- fix(presets): preserve argument-hint in preset SKILL.md generation (#2978) +- feat: surface gate detail in the workflow run/resume --json payload (#2965) +- feat: add `specify bundle` command (#3070) +- chore: release 0.11.3, begin 0.11.4.dev0 development (#3072) + ## [0.11.3] - 2026-06-19 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 8e0dfc3191..29f1e0245e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.4.dev0" +version = "0.11.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 79a34b892dde008b8b7d742b7f3c8c7c8970bb9b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:33:44 -0500 Subject: [PATCH 20/42] fix(presets): use _repo_root() for bundled-core source-checkout fallback (#3086) (#3091) * fix(presets): use _repo_root() for bundled-core source-checkout fallback The tier-5 fallback in PresetResolver.resolve() and _find_bundled_core() computed the repo root as Path(__file__).parent.parent.parent. After presets.py was moved to presets/__init__.py (#2826) that chain is one level short, resolving to src/ and looking for src/templates/commands/.md, which never exists. As a result, wrap-strategy presets found no core base layer in source/editable installs. Use the shared _repo_root() helper so both fallbacks resolve against the actual repo-root templates/ tree. Wheel installs were unaffected (core_pack path), so this only impacts source/editable checkouts. Refs #3086 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(presets): restore dropped def for oserror-manifest test A prior edit accidentally removed the def test_resolve_extension_command_via_manifest_skips_oserror_manifests line, orphaning its body inside the new bundled-core test. Restore the test definition so pytest collects it again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(presets): move bundled-core tests into TestPresetResolver The two tier-5 fallback regression tests exercise collect_all_layers() and resolve(), not resolve_core(), so they belong in TestPresetResolver rather than TestResolveCore. Relocate them for clearer suite navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 8 ++++---- tests/test_presets.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index b30c6f3c17..66f1bbc5e5 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2707,7 +2707,7 @@ def resolve( # (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 + from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles _core_pack = _locate_core_pack() if _core_pack is not None: # Wheel install path @@ -2727,7 +2727,7 @@ def resolve( return candidate else: # Source-checkout / editable install: templates live at repo root - repo_root = Path(__file__).parent.parent.parent + repo_root = _repo_root() if template_type == "template": candidate = repo_root / "templates" / f"{template_name}.md" elif template_type == "command": @@ -3079,7 +3079,7 @@ def _find_bundled_core( ``.specify/templates/`` doesn't contain the core file. """ try: - from specify_cli import _locate_core_pack + from specify_cli import _locate_core_pack, _repo_root except ImportError: return None @@ -3102,7 +3102,7 @@ def _find_bundled_core( if c.exists(): return c else: - repo_root = Path(__file__).parent.parent.parent + repo_root = _repo_root() for name in names: if template_type == "template": c = repo_root / "templates" / f"{name}.md" diff --git a/tests/test_presets.py b/tests/test_presets.py index a64e28bb33..58574bbc9c 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1033,6 +1033,32 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): result = resolver.resolve("hidden-template") assert result is None + def test_collect_all_layers_finds_bundled_core_without_specify_commands( + self, project_dir + ): + """Tier-5 fallback locates the bundled core command when + .specify/templates/commands/ has no matching file. + + Regression test for #3086: a stale ``.parent`` chain made the + source-checkout fallback resolve to ``src/templates/...`` (which does + not exist), so ``wrap`` presets found no base layer. The fallback must + resolve against the real repo-root ``templates/commands`` tree. + """ + # project_dir's commands dir is empty, so tier-4 cannot satisfy this. + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.implement", "command") + assert layers, "expected a bundled core base layer to be found" + assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") + + def test_resolve_command_falls_back_to_bundled_core(self, project_dir): + """resolve() tier-5 returns the bundled core command when + .specify/templates/commands/ lacks it (regression for #3086).""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("speckit.implement", "command") + assert result is not None + assert result.parts[-2:] == ("commands", "implement.md") + class TestResolveCore: """Test PresetResolver.resolve_core() skips the installed-presets tier.""" From cac16dd1d700ed9a45f66473a3a1769c3ff17249 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:37:04 -0500 Subject: [PATCH 21/42] =?UTF-8?q?Update=20DocGuard=20=E2=80=94=20CDD=20Enf?= =?UTF-8?q?orcement=20extension=20to=20v0.27.0=20(#3094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update docguard extension submitted by @raccioly: - extensions/catalog.community.json (version, download_url, updated_at) Closes #3093 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 7b41c6aeb6..6a3835b09c 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1006,8 +1006,8 @@ "id": "docguard", "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.", "author": "raccioly", - "version": "0.26.0", - "download_url": "https://github.com/raccioly/docguard/releases/download/v0.26.0/spec-kit-docguard-v0.26.0.zip", + "version": "0.27.0", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.27.0/spec-kit-docguard-v0.27.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", @@ -1043,7 +1043,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-13T00:00:00Z", - "updated_at": "2026-06-11T00:00:00Z" + "updated_at": "2026-06-22T00:00:00Z" }, "doctor": { "name": "Project Health Check", From bbdf1b8f40006795d842b2de5bf6d64cbc6079df Mon Sep 17 00:00:00 2001 From: "Austin Z." <124941127+AustinZ21@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:10:55 -0700 Subject: [PATCH 22/42] fix(agent-context): support multiple context files safely (#2969) * fix(agent-context): support multiple context files safely * fix(agent-context): harden context file validation * fix(agent-context): preserve disabled context target * fix(agent-context): address review follow-ups * fix(agent-context): dedupe PowerShell context files * fix(agent-context): align context file dedupe * fix(agent-context): align bash context file dedupe * fix(agent-context): preserve disabled display target * fix(agent-context): require yaml-capable updater python * fix(agent-context): preserve context files config * fix(agent-context): align context file fallbacks * fix(agent-context): share context file resolution --------- Co-authored-by: AustinZ21 --- extensions/agent-context/README.md | 9 + .../agent-context/agent-context-config.yml | 7 +- .../commands/speckit.agent-context.update.md | 5 +- .../scripts/bash/update-agent-context.sh | 150 +++- .../powershell/update-agent-context.ps1 | 232 ++++-- src/specify_cli/__init__.py | 14 + src/specify_cli/agents.py | 28 +- src/specify_cli/integrations/_helpers.py | 12 +- src/specify_cli/integrations/base.py | 354 ++++++-- .../integrations/copilot/__init__.py | 3 +- .../integrations/forge/__init__.py | 3 +- .../integrations/generic/__init__.py | 3 +- .../integrations/hermes/__init__.py | 3 +- .../test_extension_agent_context.py | 780 ++++++++++++++++++ tests/integrations/test_integration_codex.py | 74 ++ 15 files changed, 1480 insertions(+), 197 deletions(-) diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index dba004eb80..091e2b4802 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -10,6 +10,7 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context - **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. - **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. - **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). ## Commands @@ -27,6 +28,12 @@ All configuration flows through the extension's own config file at # Path to the coding agent context file managed by this extension context_file: CLAUDE.md +# Optional list of coding agent context files to manage together. +# When non-empty, this takes precedence over context_file. +context_files: + - AGENTS.md + - CLAUDE.md + # Delimiters for the managed Spec Kit section context_markers: start: "" @@ -34,6 +41,7 @@ context_markers: ``` - `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. ## Requirements @@ -55,3 +63,4 @@ specify extension disable agent-context ``` When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). +Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml index 8c8d308b27..e73f8c7c50 100644 --- a/extensions/agent-context/agent-context-config.yml +++ b/extensions/agent-context/agent-context-config.yml @@ -2,12 +2,17 @@ # These values are populated automatically by `specify init` and # `specify integration use` / `specify integration install`. -# Path (relative to the project root) to the coding agent context file +# Path (relative to the project root) to the default coding agent context file # managed by this extension (e.g. CLAUDE.md, AGENTS.md, # .github/copilot-instructions.md). Set automatically from the active # integration and regenerated during `specify init` or integration switches. context_file: "" +# Optional list of project-relative coding agent context files managed by this +# extension. When non-empty, this list takes precedence over `context_file`. +# Use this for projects that intentionally keep multiple agent anchors in sync. +context_files: [] + # Delimiters for the managed Spec Kit section. # Edit these to use custom markers. context_markers: diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md index 02f1706926..a654eb5a0e 100644 --- a/extensions/agent-context/commands/speckit.agent-context.update.md +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -1,5 +1,5 @@ --- -description: "Refresh the managed Spec Kit section in the coding agent context file" +description: "Refresh the managed Spec Kit section in coding agent context file(s)" --- # Update Coding Agent Context @@ -12,11 +12,12 @@ The script reads the agent-context extension config at `.specify/extensions/agent-context/agent-context-config.yml` to discover: - `context_file` — the path of the coding agent context file to manage. +- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`. - `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs//plan.md`). -If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully. +If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected. ## Execution diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 42ce44df9a..9d57b08cf5 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # update-agent-context.sh # -# Refresh the managed Spec Kit section in the coding agent's context file +# Refresh the managed Spec Kit section in the coding agent's context file(s) # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from the +# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the # agent-context extension config: # .specify/extensions/agent-context/agent-context-config.yml # @@ -26,22 +26,41 @@ if [[ ! -f "$EXT_CONFIG" ]]; then exit 0 fi -# Locate a suitable Python interpreter (python3, then python). +# Locate a Python 3 interpreter with PyYAML available. _python="" -if command -v python3 >/dev/null 2>&1; then - _python="python3" -elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then - _python="python" -fi +_python_candidates=() +[[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON") +_python_candidates+=("python3" "python") +for _candidate in "${_python_candidates[@]}"; do + if command -v "$_candidate" >/dev/null 2>&1 \ + && "$_candidate" - <<'PY' >/dev/null 2>&1 +import sys +try: + import yaml # noqa: F401 +except ImportError: + sys.exit(1) +sys.exit(0 if sys.version_info[0] == 3 else 1) +PY + then + _python="$_candidate" + break + fi +done +unset _candidate _python_candidates if [[ -z "$_python" ]]; then - echo "agent-context: Python 3 not found on PATH; skipping update." >&2 + echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2 + echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2 exit 0 fi +_case_insensitive_context_files=0 +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;; +esac -# Parse extension config once; emit three newline-separated fields: -# context_file, context_markers.start, context_markers.end -if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +# Parse extension config once; emit context files as JSON, followed by marker strings. +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY' +import json import sys try: import yaml @@ -73,7 +92,28 @@ def get_str(obj, *keys): else: return "" return node if isinstance(node, str) else "" -print(get_str(data, "context_file")) +context_files = [] +seen_context_files = set() +case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin")) +raw_files = data.get("context_files") +if isinstance(raw_files, list): + for value in raw_files: + if not isinstance(value, str): + continue + candidate = value.strip() + if not candidate: + continue + key = candidate.casefold() if case_insensitive else candidate + if key in seen_context_files: + continue + context_files.append(candidate) + seen_context_files.add(key) +if not context_files: + raw_file = get_str(data, "context_file") + candidate = raw_file.strip() + if candidate: + context_files.append(candidate) +print(json.dumps(context_files)) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) PY @@ -87,31 +127,71 @@ while IFS= read -r _line || [[ -n "$_line" ]]; do _opts_lines+=("$_line") done < <(printf '%s\n' "$_raw_opts") if (( ${#_opts_lines[@]} < 3 )); then - echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 + echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 exit 0 fi -CONTEXT_FILE="${_opts_lines[0]}" +CONTEXT_FILES_JSON="${_opts_lines[0]}" MARKER_START="${_opts_lines[1]}" MARKER_END="${_opts_lines[2]}" -if [[ -z "$CONTEXT_FILE" ]]; then - echo "agent-context: context_file not set in extension config; nothing to do." >&2 +if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY' +import json +import sys +try: + data = json.loads(sys.argv[1]) +except Exception: + data = [] +if not isinstance(data, list): + data = [] +for value in data: + if isinstance(value, str) and value: + print(value) +PY +)"; then + echo "agent-context: malformed context_files parser output; skipping update." >&2 exit 0 fi -# Reject absolute paths, backslash separators, and '..' path segments in context_file -if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then - echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2 - exit 1 -fi -if [[ "$CONTEXT_FILE" == *\\* ]]; then - echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2 - exit 1 +CONTEXT_FILES=() +while IFS= read -r _line || [[ -n "$_line" ]]; do + [[ -n "$_line" ]] && CONTEXT_FILES+=("$_line") +done < <(printf '%s\n' "$_context_files_raw") + +if (( ${#CONTEXT_FILES[@]} == 0 )); then + echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2 + exit 0 fi -IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE" -for _seg in "${_cf_parts[@]}"; do - if [[ "$_seg" == ".." ]]; then - echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 + +for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do + # Reject absolute paths, backslash separators, and '..' path segments in context files + if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then + echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2 + exit 1 + fi + if [[ "$CONTEXT_FILE" == *\\* ]]; then + echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2 + exit 1 + fi + IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE" + for _seg in "${_cf_parts[@]}"; do + if [[ "$_seg" == ".." ]]; then + echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2 + exit 1 + fi + done + if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY' +import sys +from pathlib import Path + +root = Path(sys.argv[1]).resolve() +target = (root / sys.argv[2]).resolve(strict=False) +try: + target.relative_to(root) +except ValueError: + sys.exit(1) +PY + then + echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2 exit 1 fi done @@ -142,9 +222,6 @@ PY fi fi -CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" -mkdir -p "$(dirname "$CTX_PATH")" - # Build the managed section TMP_SECTION="$(mktemp)" trap 'rm -f "$TMP_SECTION"' EXIT @@ -158,7 +235,11 @@ trap 'rm -f "$TMP_SECTION"' EXIT echo "$MARKER_END" } > "$TMP_SECTION" -"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do + CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" + mkdir -p "$(dirname "$CTX_PATH")" + + "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' import sys, os ctx_path, start, end, section_path = sys.argv[1:5] with open(section_path, "r", encoding="utf-8") as fh: @@ -197,4 +278,5 @@ with open(ctx_path, "wb") as fh: fh.write(new_content.encode("utf-8")) PY -echo "agent-context: updated $CONTEXT_FILE" + echo "agent-context: updated $CONTEXT_FILE" +done diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index dad309c03a..d31fcd64c0 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -1,10 +1,10 @@ #!/usr/bin/env pwsh # update-agent-context.ps1 # -# Refresh the managed Spec Kit section in the coding agent's context file +# Refresh the managed Spec Kit section in the coding agent's context file(s) # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from the +# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the # agent-context extension config: # .specify/extensions/agent-context/agent-context-config.yml # @@ -52,6 +52,66 @@ function Test-ConfigObject { return $false } +function Resolve-ContextPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$RelativePath + ) + + $rootFull = [System.IO.Path]::GetFullPath($Root) + $segments = $RelativePath -split '/' + $resolved = $rootFull + + foreach ($segment in $segments) { + if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') { + continue + } + + $candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment)) + if (Test-Path -LiteralPath $candidate) { + $item = Get-Item -LiteralPath $candidate -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + $target = $item.Target + if ($target -is [System.Array]) { + $target = $target[0] + } + if ($target) { + if ([System.IO.Path]::IsPathRooted($target)) { + $candidate = [System.IO.Path]::GetFullPath($target) + } else { + $candidate = [System.IO.Path]::GetFullPath( + (Join-Path (Split-Path -Parent $candidate) $target) + ) + } + } + } + } + $resolved = $candidate + } + + return $resolved +} + +function Test-IsSubPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Path + ) + + $comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + [System.StringComparison]::OrdinalIgnoreCase + } else { + [System.StringComparison]::Ordinal + } + $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $pathFull = [System.IO.Path]::GetFullPath($Path) + return $pathFull.Equals($rootFull, $comparison) -or + $pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison) +} + $ErrorActionPreference = 'Stop' $DefaultStart = '' $DefaultEnd = '' @@ -75,11 +135,16 @@ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { if ($null -eq $Options) { # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. $pythonCmd = $null - foreach ($candidate in @('python3', 'python')) { + $pythonCandidates = @() + if ($env:SPECKIT_PYTHON) { + $pythonCandidates += $env:SPECKIT_PYTHON + } + $pythonCandidates += @('python3', 'python') + foreach ($candidate in $pythonCandidates) { if (Get-Command $candidate -ErrorAction SilentlyContinue) { - # Verify it is Python 3 - $verOut = & $candidate --version 2>&1 - if ($verOut -match 'Python 3') { + # Verify it is Python 3 with PyYAML available. + $null = & $candidate -c "import sys; import yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { $pythonCmd = $candidate break } @@ -87,8 +152,10 @@ if ($null -eq $Options) { } if ($pythonCmd) { + $pyScript = $null try { - $jsonOut = & $pythonCmd -c @' + $pyScript = [System.IO.Path]::GetTempFileName() + Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @' import json import sys try: @@ -114,12 +181,17 @@ if not isinstance(data, dict): data = {} print(json.dumps(data)) -'@ $ExtConfig +'@ + $jsonOut = & $pythonCmd $pyScript $ExtConfig if ($LASTEXITCODE -eq 0 -and $jsonOut) { $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop } } catch { $Options = $null + } finally { + if ($pyScript -and (Test-Path -LiteralPath $pyScript)) { + Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue + } } } @@ -134,21 +206,63 @@ if (-not (Test-ConfigObject -Object $Options)) { exit 0 } -$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' -if (-not $ContextFile) { - Write-Warning 'agent-context: context_file not set in extension config; nothing to do.' +$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files' +$ContextFiles = @() +if ($null -ne $ConfiguredContextFiles) { + foreach ($item in @($ConfiguredContextFiles)) { + if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) { + $ContextFiles += $item.Trim() + } + } +} +if ($ContextFiles.Count -eq 0) { + $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' + if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) { + $ContextFiles += $ContextFile.Trim() + } +} +$pathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + [System.StringComparer]::OrdinalIgnoreCase +} else { + [System.StringComparer]::Ordinal +} +$seenContextFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparison) +$dedupedContextFiles = @() +foreach ($ContextFile in $ContextFiles) { + if ($seenContextFiles.Add($ContextFile)) { + $dedupedContextFiles += $ContextFile + } +} +$ContextFiles = $dedupedContextFiles +if ($ContextFiles.Count -eq 0) { + Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.' exit 0 } -# Reject absolute paths and '..' path segments in context_file -if ([System.IO.Path]::IsPathRooted($ContextFile)) { - Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'." - exit 1 -} -$cfSegments = $ContextFile -split '[/\\]' -if ($cfSegments -contains '..') { - Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'." - exit 1 +foreach ($ContextFile in $ContextFiles) { + # Reject absolute paths, drive-qualified paths, backslash separators, and '..' path segments in context files + if ($ContextFile -match '^[A-Za-z]:') { + Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'." + exit 1 + } + if ([System.IO.Path]::IsPathRooted($ContextFile)) { + Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'." + exit 1 + } + if ($ContextFile.Contains('\')) { + Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'." + exit 1 + } + $cfSegments = $ContextFile -split '[/\\]' + if ($cfSegments -contains '..') { + Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'." + exit 1 + } + $resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile + if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) { + Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'." + exit 1 + } } $MarkerStart = $DefaultStart @@ -184,12 +298,6 @@ if (-not $PlanPath) { } } -$CtxPath = Join-Path $ProjectRoot $ContextFile -$CtxDir = Split-Path -Parent $CtxPath -if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { - New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null -} - $lines = @($MarkerStart, 'For additional context about technologies to be used, project structure,', 'shell commands, and other important information, read the current plan') @@ -199,39 +307,47 @@ if ($PlanPath) { $lines += $MarkerEnd $Section = ($lines -join "`n") + "`n" -if (Test-Path -LiteralPath $CtxPath) { - $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) - # Strip UTF-8 BOM if present - if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { - $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) - } else { - $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) - } - - $s = $content.IndexOf($MarkerStart) - $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } - - if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { - $endOfMarker = $e + $MarkerEnd.Length - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } - $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) - } elseif ($s -ge 0) { - $newContent = $content.Substring(0, $s) + $Section - } elseif ($e -ge 0) { - $endOfMarker = $e + $MarkerEnd.Length - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } - if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } - $newContent = $Section + $content.Substring($endOfMarker) +foreach ($ContextFile in $ContextFiles) { + $CtxPath = Join-Path $ProjectRoot $ContextFile + $CtxDir = Split-Path -Parent $CtxPath + if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { + New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null + } + + if (Test-Path -LiteralPath $CtxPath) { + $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) + # Strip UTF-8 BOM if present + if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) + } else { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) + } + + $s = $content.IndexOf($MarkerStart) + $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } + + if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) + } elseif ($s -ge 0) { + $newContent = $content.Substring(0, $s) + $Section + } elseif ($e -ge 0) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $Section + $content.Substring($endOfMarker) + } else { + if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } + if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + } } else { - if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } - if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + $newContent = $Section } -} else { - $newContent = $Section -} -$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") -[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) + $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") + [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) -Write-Host "agent-context: updated $ContextFile" + Write-Host "agent-context: updated $ContextFile" +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 87d9a56cbf..9f9efa09b1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -277,6 +277,7 @@ def _load_agent_context_config(project_root: Path) -> dict[str, Any]: defaults: dict[str, Any] = { "context_file": "", + "context_files": [], "context_markers": { "start": IntegrationBase.CONTEXT_MARKER_START, "end": IntegrationBase.CONTEXT_MARKER_END, @@ -308,6 +309,7 @@ def _update_agent_context_config_file( context_file: str | None, *, preserve_markers: bool = True, + preserve_context_files: bool = True, ) -> None: """Update the agent-context extension config with *context_file*. @@ -315,11 +317,23 @@ def _update_agent_context_config_file( ``context_markers`` values are kept unchanged so user customisations survive integration changes and reinit. When False, the default markers are written unconditionally. + + When *preserve_context_files* is True (default), an existing + ``context_files`` list is kept unchanged, including an empty list. This + lets projects opt into updating multiple agent context files while still + preserving the legacy singular ``context_file`` value for compatibility. """ from .integrations.base import IntegrationBase cfg = _load_agent_context_config(project_root) cfg["context_file"] = context_file or "" + existing_context_files = cfg.get("context_files") + if preserve_context_files: + cfg["context_files"] = ( + existing_context_files if isinstance(existing_context_files, list) else [] + ) + else: + cfg.pop("context_files", None) if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): cfg["context_markers"] = { "start": IntegrationBase.CONTEXT_MARKER_START, diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 0fd6437737..b1a5a932c2 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -427,14 +427,34 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) # Resolve __CONTEXT_FILE__ from the agent-context extension config. - # Fall back to init-options.json for projects that haven't migrated. + # When disabled, ignore stale context_files but keep the singular + # context_file value so generated commands still point at the agent + # context file managed before the extension was disabled. + from .integrations.base import IntegrationBase + # Local import: _load_agent_context_config lives in __init__.py which # imports agents.py, so a top-level import would be circular. from . import _load_agent_context_config + ac_cfg = _load_agent_context_config(project_root) - context_file = ac_cfg.get("context_file") or "" - if not context_file: - context_file = init_opts.get("context_file") or "" + extension_enabled = IntegrationBase._agent_context_extension_enabled( + project_root + ) + if extension_enabled: + context_files = IntegrationBase._resolve_context_file_values( + project_root, + ac_cfg, + legacy_context_file=init_opts.get("context_file"), + ) + else: + context_files = IntegrationBase._resolve_context_file_values( + project_root, + ac_cfg, + legacy_context_file=init_opts.get("context_file"), + include_context_files=False, + validate=False, + ) + context_file = IntegrationBase._format_context_file_values(context_files) body = body.replace("__CONTEXT_FILE__", context_file) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..c48cbad125 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -131,7 +131,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG if ext_cfg_path.exists(): _update_agent_context_config_file( - project_root, "", preserve_markers=True + project_root, "", preserve_markers=True, preserve_context_files=False ) elif has_legacy_context_keys: save_init_options(project_root, opts) @@ -277,12 +277,14 @@ def _update_init_options_for_integration( """Update init-options.json and the agent-context extension config to reflect *integration* as the active one. - ``context_file`` and ``context_markers`` are stored in the agent-context + ``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context extension config (``.specify/extensions/agent-context/agent-context-config.yml``), not in ``init-options.json``. Existing user-customised markers are - always preserved when the config already exists; invalid marker values - are silently ignored at runtime by ``_resolve_context_markers()`` which - falls back to the class-level defaults. + always preserved when the config already exists. Existing ``context_files`` + lists are also preserved so projects can keep multi-agent context anchors + during integration switches. Invalid marker values are + silently ignored at runtime by ``_resolve_context_markers()`` which falls + back to the class-level defaults. """ from .. import ( _AGENT_CTX_EXT_CONFIG, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index fbab1a2b37..3798778cce 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -20,7 +20,7 @@ import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING, Any import yaml @@ -93,6 +93,11 @@ class IntegrationBase(ABC): * ``context_file`` — path (relative to project root) of the agent context/instructions file (e.g. ``"CLAUDE.md"``) + + Projects may additionally opt into managing multiple context files by + setting ``context_files`` in the agent-context extension config. The + integration class still declares one default ``context_file`` for backwards + compatibility and command-template rendering. """ # -- Must be set by every subclass ------------------------------------ @@ -632,6 +637,11 @@ def _agent_context_extension_enabled(project_root: Path) -> bool: return True return entry.get("enabled", True) is not False + @staticmethod + def _context_file_dedupe_key(path: str) -> str: + """Return the comparison key for context file de-duplication.""" + return path.casefold() if os.name == "nt" else path + def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: """Return the (start, end) context markers to use for *project_root*. @@ -681,51 +691,156 @@ def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: end = cm_end # type: ignore[assignment] return start, end - 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 the configured - start/end markers (default ```` / - ````) is replaced, or appended when no markers - are found. Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. + @staticmethod + def _validate_context_file_path(project_root: Path, context_file: str) -> str: + """Return a safe project-relative context file path. - Returns the path to the context file, or ``None`` when - ``context_file`` is not set or the ``agent-context`` extension is - disabled. + The agent-context scripts reject paths that can escape the project + root; the Python integration path must apply the same guard before + setup or teardown touches context files. """ - if not self.context_file: - return None + candidate = context_file.strip() + if not candidate: + raise ValueError("agent-context: context file path must not be empty") - if not self._agent_context_extension_enabled(project_root): - return None + win_path = PureWindowsPath(candidate) + if Path(candidate).is_absolute() or win_path.drive or win_path.root: + raise ValueError( + "agent-context: context files must be project-relative paths; " + f"got {candidate!r}" + ) + if "\\" in candidate: + raise ValueError( + "agent-context: context files must not contain backslash " + f"separators; got {candidate!r}" + ) - from .._console import console # local import to avoid cycles + parts = [part for part in re.split(r"[\\/]+", candidate) if part] + if ".." in parts: + raise ValueError( + "agent-context: context files must not contain '..' path " + f"segments; got {candidate!r}" + ) - console.print( - "[yellow]Deprecation:[/yellow] Inline agent-context updates during " - "integration setup will be disabled in v0.12.0. Context file " - "management has moved to the bundled [bold]agent-context[/bold] " - "extension. Run [cyan]specify extension disable agent-context[/cyan] " - "to opt out early.", - highlight=False, - ) + root = project_root.resolve() + target = (root / candidate).resolve(strict=False) + try: + target.relative_to(root) + except ValueError as exc: + raise ValueError( + "agent-context: context file path resolves outside the project " + f"root; got {candidate!r}" + ) from exc - marker_start, marker_end = self._resolve_context_markers(project_root) + return candidate - ctx_path = project_root / self.context_file - section = ( - f"{marker_start}\n" - f"{self._build_context_section(plan_path)}\n" - f"{marker_end}\n" + @classmethod + def _resolve_context_file_values( + cls, + project_root: Path, + cfg: dict[str, Any] | None, + *, + fallback_context_file: Any = None, + legacy_context_file: Any = None, + include_context_files: bool = True, + validate: bool = True, + ) -> list[str]: + """Resolve context file config with shared precedence and de-duplication.""" + files: list[str] = [] + seen: set[str] = set() + + def add_context_file(value: Any) -> None: + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + if validate: + candidate = cls._validate_context_file_path(project_root, candidate) + key = cls._context_file_dedupe_key(candidate) + if key in seen: + return + files.append(candidate) + seen.add(key) + + if isinstance(cfg, dict) and include_context_files: + configured = cfg.get("context_files") + if isinstance(configured, list): + for value in configured: + add_context_file(value) + if files: + return files + + if isinstance(cfg, dict): + add_context_file(cfg.get("context_file")) + if files: + return files + + add_context_file(fallback_context_file) + if files: + return files + + add_context_file(legacy_context_file) + return files + + @staticmethod + def _format_context_file_values(context_files: list[str]) -> str: + """Return context file targets as the template display string.""" + return ", ".join(context_files) + + def _resolve_context_files(self, project_root: Path) -> list[str]: + """Return project-relative context files managed for *project_root*. + + ``context_files`` in the agent-context extension config, when present + and non-empty, takes precedence over the config's singular + ``context_file``. The integration class default is used only when the + extension config has no context file target. + Raises ``ValueError`` when a configured path can escape the project + root. + """ + config_path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + try: + raw = config_path.read_text(encoding="utf-8") + cfg = yaml.safe_load(raw) + except (OSError, UnicodeError, ValueError, yaml.YAMLError): + cfg = None + return self._resolve_context_file_values( + project_root, + cfg, + fallback_context_file=self.context_file, + ) + + def _context_file_display(self, project_root: Path) -> str: + """Return human-readable context file target(s) for templates.""" + if not self._agent_context_extension_enabled(project_root): + from .. import _load_agent_context_config + + context_files = self._resolve_context_file_values( + project_root, + _load_agent_context_config(project_root), + fallback_context_file=self.context_file, + include_context_files=False, + validate=False, + ) + return context_files[0] if context_files else "" + return self._format_context_file_values( + self._resolve_context_files(project_root) ) + @staticmethod + def _upsert_context_file( + ctx_path: Path, + section: str, + marker_start: str, + marker_end: str, + ) -> None: + """Create or update one managed context section.""" if ctx_path.exists(): content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(marker_start) @@ -765,18 +880,70 @@ def upsert_context_section( # Ensure .mdc files have required YAML frontmatter if ctx_path.suffix == ".mdc": - new_content = self._ensure_mdc_frontmatter(new_content) + new_content = IntegrationBase._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) + new_content = IntegrationBase._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 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 the configured + start/end markers (default ```` / + ````) is replaced, or appended when no markers + are found. Markers are read from the agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) + when present, falling back to the class-level constants. + + Returns the path to the first context file, or ``None`` when no context + files are configured or the ``agent-context`` extension is + disabled. + """ + if not self._agent_context_extension_enabled(project_root): + return None + + context_files = self._resolve_context_files(project_root) + if not context_files: + return None + + from .._console import console # local import to avoid cycles + + console.print( + "[yellow]Deprecation:[/yellow] Inline agent-context updates during " + "integration setup will be disabled in v0.12.0. Context file " + "management has moved to the bundled [bold]agent-context[/bold] " + "extension. Run [cyan]specify extension disable agent-context[/cyan] " + "to opt out early.", + highlight=False, + ) + + marker_start, marker_end = self._resolve_context_markers(project_root) + + section = ( + f"{marker_start}\n" + f"{self._build_context_section(plan_path)}\n" + f"{marker_end}\n" + ) + + first_path: Path | None = None + for context_file in context_files: + ctx_path = project_root / context_file + self._upsert_context_file(ctx_path, section, marker_start, marker_end) + if first_path is None: + first_path = ctx_path + return first_path def remove_context_section(self, project_root: Path) -> bool: """Remove the managed section from the agent context file. @@ -787,68 +954,73 @@ def remove_context_section(self, project_root: Path) -> bool: (``.specify/extensions/agent-context/agent-context-config.yml``) when present, falling back to the class-level constants. """ - if not self.context_file: - return False - if not self._agent_context_extension_enabled(project_root): return False - ctx_path = project_root / self.context_file - if not ctx_path.exists(): + context_files = self._resolve_context_files(project_root) + if not context_files: return False marker_start, marker_end = self._resolve_context_markers(project_root) + removed_any = False - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) + for context_file in context_files: + ctx_path = project_root / context_file + if not ctx_path.exists(): + continue - # 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 + content = ctx_path.read_text(encoding="utf-8-sig") + start_idx = content.find(marker_start) + end_idx = content.find( + marker_end, + start_idx if start_idx != -1 else 0, + ) - removal_start = start_idx - removal_end = end_idx + len(marker_end) + # 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: + continue - # 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 + removal_start = start_idx + removal_end = end_idx + len(marker_end) - # 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 + # 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 - new_content = content[:removal_start] + content[removal_end:] + # 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 - # Normalize line endings before comparisons - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + new_content = content[:removal_start] + content[removal_end:] - # For .mdc files, treat Speckit-generated frontmatter-only content as empty - if ctx_path.suffix == ".mdc": - import re + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - # 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 + # For .mdc files, treat Speckit-generated frontmatter-only content as empty + if ctx_path.suffix == ".mdc": + import re - if not normalized.strip(): - ctx_path.unlink() - else: - ctx_path.write_bytes(normalized.encode("utf-8")) + # 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() + removed_any = True + continue + + if not normalized.strip(): + ctx_path.unlink() + else: + ctx_path.write_bytes(normalized.encode("utf-8")) + removed_any = True - return True + return removed_any @staticmethod def resolve_command_refs(content: str, separator: str = ".") -> str: @@ -1119,12 +1291,13 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] + context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1324,13 +1497,14 @@ def setup( else "{{args}}" ) created: list[Path] = [] + context_file_display = self._context_file_display(project_root) 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, - context_file=self.context_file or "", + context_file=context_file_display, ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1519,6 +1693,7 @@ def setup( else "{{args}}" ) created: list[Path] = [] + context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1534,7 +1709,7 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=context_file_display, ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1709,6 +1884,7 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] + context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1732,7 +1908,7 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 71b6d6919d..2659b3f252 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -354,13 +354,14 @@ def _setup_default( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + context_file_display = self._context_file_display(project_root) # 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, - context_file=self.context_file or "", + context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index 47a90687dc..d1cd7a49a8 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -128,13 +128,14 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{parameters}}") created: list[Path] = [] + context_file_display = self._context_file_display(project_root) 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, - context_file=self.context_file or "", + context_file=context_file_display, invoke_separator=self.invoke_separator, ) diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index fdaee4ed04..3d6dd19d44 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -119,12 +119,13 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$ARGUMENTS" created: list[Path] = [] + context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1e739002fc..1d475c72e2 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -114,6 +114,7 @@ def setup( global_skills_dir.mkdir(parents=True, exist_ok=True) created: list[Path] = [] + context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -140,7 +141,7 @@ def setup( self.key, script_type, arg_placeholder, - context_file=self.context_file or "", + context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..ab4194efd8 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -3,8 +3,13 @@ from __future__ import annotations import json +import os +import shutil +import subprocess +import sys from pathlib import Path +import pytest import yaml from specify_cli import ( @@ -13,18 +18,25 @@ load_init_options, save_init_options, ) +from specify_cli.agents import CommandRegistrar from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.claude import ClaudeIntegration +from tests.conftest import requires_bash PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" +BASH = shutil.which("bash") +POWERSHELL = ( + shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell") +) def _write_ext_config(project_root: Path, **overrides: object) -> None: """Write a minimal agent-context extension config.""" cfg: dict = { "context_file": overrides.get("context_file", ""), + "context_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { @@ -72,6 +84,14 @@ def test_command_file_exists(self): assert cmd.is_file() assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8") + def test_command_file_documents_context_file_constraints(self): + text = ( + EXT_DIR / "commands" / "speckit.agent-context.update.md" + ).read_text(encoding="utf-8") + assert "context file(s)" in text + assert "Windows drive paths" in text + assert "backslash separators" in text + def test_bundled_scripts_exist(self): assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file() assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file() @@ -107,6 +127,184 @@ class _CtxIntegration(ClaudeIntegration): """Use Claude as a concrete integration with a context_file.""" +class _NoContextIntegration(IntegrationBase): + """Minimal integration with no context_file for base-class fallback tests.""" + + +def _install_agent_context_config(project_root: Path, **overrides: object) -> None: + _write_ext_config(project_root, **overrides) + + +def _bash_posix_path(path: Path) -> str: + """Convert a Windows path to the POSIX form used by the available bash.""" + resolved = str(path.resolve()) + if os.name != "nt": + return resolved + + if BASH: + converted = subprocess.run( + [ + BASH, + "-lc", + "command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"", + "bash", + resolved, + ], + capture_output=True, + text=True, + timeout=30, + ) + if converted.returncode == 0 and converted.stdout.strip(): + return converted.stdout.strip() + + drive = path.drive.rstrip(":").lower() + posix = path.as_posix() + return f"/mnt/{drive}{posix[2:]}" if drive else posix + + +def _ensure_test_python_on_path(project_root: Path) -> Path: + """Create python/python3 shims that run the current pytest interpreter.""" + shim_dir = project_root / ".test-python-bin" + shim_dir.mkdir(exist_ok=True) + python_exe = Path(sys.executable).resolve() + shell_python = _bash_posix_path(python_exe) + + for name in ("python", "python3"): + shell_shim = shim_dir / name + shell_shim.write_text( + f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n", + encoding="utf-8", + newline="\n", + ) + shell_shim.chmod(0o755) + + if os.name == "nt": + cmd_shim = shim_dir / f"{name}.cmd" + cmd_shim.write_text( + f'@echo off\r\n"{python_exe}" %*\r\n', + encoding="utf-8", + ) + + return shim_dir + + +def _current_pythonpath() -> str: + """Return sys.path entries needed by child script interpreters.""" + entries = [ + entry + for entry in sys.path + if isinstance(entry, str) and entry + ] + existing = os.environ.get("PYTHONPATH") + if existing: + entries.extend(entry for entry in existing.split(os.pathsep) if entry) + return os.pathsep.join(dict.fromkeys(entries)) + + +def _bundled_script_env( + project_root: Path, + *, + for_bash: bool = False, + speckit_python: str | None = None, +) -> dict[str, str]: + env = os.environ.copy() + shim_dir = _ensure_test_python_on_path(project_root) + env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "") + env["SPECKIT_PYTHON"] = ( + speckit_python + if speckit_python is not None + else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable) + ) + pythonpath = _current_pythonpath() + if pythonpath: + env["PYTHONPATH"] = pythonpath + return env + + +def _run_bash_agent_context_script( + project_root: Path, + *, + speckit_python: str | None = None, +) -> subprocess.CompletedProcess: + script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh" + env = _bundled_script_env( + project_root, + for_bash=True, + speckit_python=speckit_python, + ) + if os.name == "nt": + root = _bash_posix_path(project_root) + script_path = _bash_posix_path(script) + shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root)) + command = ( + f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; " + f"cd {shlex_quote(root)} && {shlex_quote(script_path)}" + ) + return subprocess.run( + [BASH, "-lc", command], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + return subprocess.run( + [BASH, str(script)], + cwd=project_root, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + +def shlex_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess: + script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + env = _bundled_script_env(project_root) + return subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + ], + cwd=project_root, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + +def _run_powershell_agent_context_script_with_env( + project_root: Path, + *, + speckit_python: str, +) -> subprocess.CompletedProcess: + script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + env = _bundled_script_env(project_root, speckit_python=speckit_python) + return subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + ], + cwd=project_root, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + class TestContextMarkerResolution: def test_defaults_when_ext_config_missing(self, tmp_path): i = _CtxIntegration() @@ -200,6 +398,142 @@ def test_upsert_replaces_existing_custom_section(self, tmp_path): assert text.startswith("# header\n") assert "footer" in text + def test_upsert_uses_configured_context_files(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + result = i.upsert_context_section( + tmp_path, plan_path="specs/001-foo/plan.md" + ) + assert result == tmp_path / "AGENTS.md" + for name in ("AGENTS.md", "CLAUDE.md"): + text = (tmp_path / name).read_text(encoding="utf-8") + assert IntegrationBase.CONTEXT_MARKER_START in text + assert "specs/001-foo/plan.md" in text + + def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): + duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md", duplicate], + ) + + files = _CtxIntegration()._resolve_context_files(tmp_path) + + assert files == ["AGENTS.md", "CLAUDE.md"] + + def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=[], + ) + + files = _CtxIntegration()._resolve_context_files(tmp_path) + + assert files == ["AGENTS.md"] + + def test_config_context_file_takes_precedence_over_class_default(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + ) + + i = _CtxIntegration() + result = i.upsert_context_section( + tmp_path, plan_path="specs/001-foo/plan.md" + ) + + assert result == tmp_path / "AGENTS.md" + assert (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / "CLAUDE.md").exists() + + def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="../outside.md", + context_files=[], + ) + + with pytest.raises(ValueError, match="project-relative|must not contain"): + _CtxIntegration()._resolve_context_files(tmp_path) + + def test_remove_uses_configured_context_files(self, tmp_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + for name in ("AGENTS.md", "CLAUDE.md"): + (tmp_path / name).write_text( + f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n", + encoding="utf-8", + ) + assert i.remove_context_section(tmp_path) is True + for name in ("AGENTS.md", "CLAUDE.md"): + text = (tmp_path / name).read_text(encoding="utf-8") + assert "body" not in text + assert "head" in text + assert "tail" in text + + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "nested/../../outside.md", + "nested\\outside.md", + str(Path("/tmp/outside.md")), + "C:/tmp/outside.md", + "C:tmp/outside.md", + ], + ) + def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", bad_path], + ) + i = _CtxIntegration() + with pytest.raises(ValueError, match="project-relative|must not contain"): + i.upsert_context_section(tmp_path) + + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path.parent / "outside.md").exists() + + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "nested\\outside.md", + str(Path("/tmp/outside.md")), + "C:/tmp/outside.md", + "C:tmp/outside.md", + ], + ) + def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", bad_path], + ) + outside = tmp_path.parent / "outside.md" + outside.write_text( + f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\n", + encoding="utf-8", + ) + i = _CtxIntegration() + with pytest.raises(ValueError, match="project-relative|must not contain"): + i.remove_context_section(tmp_path) + + assert "body" in outside.read_text(encoding="utf-8") + def test_remove_uses_custom_markers(self, tmp_path): i = self._setup( tmp_path, {"start": "", "end": ""} @@ -270,6 +604,17 @@ def test_upsert_skipped_when_disabled(self, tmp_path): assert result is None assert not (tmp_path / "CLAUDE.md").exists() + def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["../disabled-upsert-outside.md"], + ) + i = _CtxIntegration() + assert i.upsert_context_section(tmp_path) is None + assert not (tmp_path.parent / "disabled-upsert-outside.md").exists() + def test_remove_skipped_when_disabled(self, tmp_path): _write_registry(tmp_path, enabled=False) i = _CtxIntegration() @@ -283,6 +628,382 @@ def test_remove_skipped_when_disabled(self, tmp_path): # File must be unchanged when extension is disabled assert ctx.read_text(encoding="utf-8") == original + def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["../disabled-remove-outside.md"], + ) + outside = tmp_path.parent / "disabled-remove-outside.md" + outside.write_text( + f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\n", + encoding="utf-8", + ) + i = _CtxIntegration() + assert i.remove_context_section(tmp_path) is False + assert "body" in outside.read_text(encoding="utf-8") + + def test_context_file_display_disabled_uses_config_context_file( + self, tmp_path + ): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["../outside.md"], + ) + i = _CtxIntegration() + assert i._context_file_display(tmp_path) == "AGENTS.md" + + def test_context_file_display_disabled_without_context_file_returns_string( + self, tmp_path + ): + _write_registry(tmp_path, enabled=False) + i = _NoContextIntegration() + assert i._context_file_display(tmp_path) == "" + + +class TestSkillPlaceholderContextValidation: + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "nested/../../outside.md", + "nested\\outside.md", + str(Path("/tmp/outside.md")), + "C:/tmp/outside.md", + "C:tmp/outside.md", + ], + ) + def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["AGENTS.md", bad_path], + ) + + with pytest.raises(ValueError, match="project-relative|must not contain"): + CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + @pytest.mark.parametrize( + "bad_path", + [ + "../outside.md", + "C:tmp/outside.md", + ], + ) + def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path): + _write_ext_config( + tmp_path, + context_file=bad_path, + context_files=[], + ) + + with pytest.raises(ValueError, match="project-relative|must not contain"): + CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + def test_enabled_extension_rejects_invalid_legacy_init_options_path( + self, tmp_path + ): + save_init_options(tmp_path, {"context_file": "../outside.md"}) + + with pytest.raises(ValueError, match="must not contain"): + CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + def test_disabled_extension_ignores_invalid_context_files(self, tmp_path): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["../outside.md"], + ) + save_init_options(tmp_path, {"context_file": "AGENTS.md"}) + + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + assert content == "Read AGENTS.md" + + def test_disabled_extension_uses_extension_context_file_before_init_options( + self, tmp_path + ): + _write_registry(tmp_path, enabled=False) + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["CLAUDE.md"], + ) + save_init_options(tmp_path, {"context_file": "LEGACY.md"}) + + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + assert content == "Read AGENTS.md" + + def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): + duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["AGENTS.md", "CLAUDE.md", duplicate], + ) + + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + + assert content == "Read AGENTS.md, CLAUDE.md" + + +class TestBundledUpdaterPathValidation: + def test_bundled_script_env_makes_yaml_importable(self, tmp_path): + env = _bundled_script_env(tmp_path) + + result = subprocess.run( + [env["SPECKIT_PYTHON"], "-c", "import yaml"], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + result.stdout + + @requires_bash + def test_bash_script_trims_context_file_fallback(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file=" AGENTS.md ", + context_files=[], + ) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + assert not (project / " AGENTS.md ").exists() + + @requires_bash + def test_bash_script_rejects_symlink_escape(self, tmp_path): + project = tmp_path / "project" + outside = tmp_path / "outside" + project.mkdir() + outside.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["link/out.md"], + ) + + if os.name == "nt": + root = _bash_posix_path(tmp_path) + create_link = subprocess.run( + [ + BASH, + "-lc", + f"ln -s {shlex_quote(root + '/outside')} " + f"{shlex_quote(root + '/project/link')}", + ], + capture_output=True, + text=True, + timeout=30, + ) + if create_link.returncode != 0: + pytest.skip(f"symlink unavailable: {create_link.stderr}") + else: + try: + (project / "link").symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlink unavailable: {exc}") + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 1 + assert "resolves outside the project root" in result.stderr + assert not (outside / "out.md").exists() + + @requires_bash + def test_bash_script_deduplicates_context_files_in_order(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["AGENTS.md", "CLAUDE.md", duplicate], + ) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + output = result.stderr + result.stdout + assert output.count("agent-context: updated AGENTS.md") == 1 + assert output.count("agent-context: updated CLAUDE.md") == 1 + assert "agent-context: updated agents.md" not in output + + @requires_bash + def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["AGENTS.md"], + ) + + result = _run_bash_agent_context_script( + project, + speckit_python="/definitely/missing/python", + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_rejects_backslash_context_files(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["nested\\AGENTS.md"], + ) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 1 + assert "must not contain backslash separators" in ( + result.stderr + result.stdout + ) + assert not (project / "nested" / "AGENTS.md").exists() + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["C:tmp/outside.md"], + ) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 1 + assert "must be project-relative paths" in (result.stderr + result.stdout) + assert not (project / "tmp" / "outside.md").exists() + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["AGENTS.md", "CLAUDE.md", duplicate], + ) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + output = result.stderr + result.stdout + assert output.count("agent-context: updated AGENTS.md") == 1 + assert output.count("agent-context: updated CLAUDE.md") == 1 + assert "agent-context: updated agents.md" not in output + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["AGENTS.md"], + ) + + result = _run_powershell_agent_context_script_with_env( + project, + speckit_python=str(project / "missing-python"), + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + + @pytest.mark.skipif( + POWERSHELL is None or os.name != "nt", + reason="Windows PowerShell junction test requires Windows", + ) + def test_powershell_script_rejects_junction_escape(self, tmp_path): + project = tmp_path / "project" + outside = tmp_path / "outside" + project.mkdir() + outside.mkdir() + _install_agent_context_config( + project, + context_file="AGENTS.md", + context_files=["link/out.md"], + ) + + create_link = subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + ( + "New-Item -ItemType Junction " + f"-Path {str(project / 'link')!r} " + f"-Target {str(outside)!r} | Out-Null" + ), + ], + capture_output=True, + text=True, + timeout=30, + ) + if create_link.returncode != 0: + pytest.skip(f"junction unavailable: {create_link.stderr}") + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 1 + assert "resolves outside the project root" in (result.stderr + result.stdout) + assert not (outside / "out.md").exists() + # ── Extension config writers ───────────────────────────────────────────────── @@ -349,6 +1070,65 @@ def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): assert cfg["context_file"] == i.context_file assert "context_markers" in cfg + def test_update_init_options_preserves_context_files(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + + def test_update_init_options_preserves_empty_context_files(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + _write_ext_config( + tmp_path, + context_file="AGENTS.md", + context_files=[], + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert cfg["context_files"] == [] + + def test_update_init_options_normalizes_invalid_context_files(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + _write_ext_config(tmp_path, context_file="AGENTS.md") + cfg = _load_agent_context_config(tmp_path) + cfg["context_files"] = "AGENTS.md" + _save_agent_context_config(tmp_path, cfg) + + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert cfg["context_files"] == [] + + def test_clear_init_options_clears_context_files(self, tmp_path): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + {"integration": "claude", "ai": "claude"}, + ) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_files=["AGENTS.md", "CLAUDE.md"], + ) + _clear_init_options_for_integration(tmp_path, "claude") + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" + assert "context_files" not in cfg + def test_update_init_options_preserves_custom_markers(self, tmp_path): from specify_cli import _update_init_options_for_integration diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index bb3b477fcc..6766375871 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -29,6 +29,80 @@ def test_integration_codex_creates_skills(self, tmp_path): assert result.exit_code == 0, f"init --integration codex failed: {result.output}" assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + def test_plan_skill_references_configured_context_files(self, tmp_path): + """Plan skill should render all configured agent context files.""" + from specify_cli import _save_agent_context_config + + target = tmp_path / "test-proj" + target.mkdir() + _save_agent_context_config( + target, + { + "context_file": "AGENTS.md", + "context_files": ["AGENTS.md", "CLAUDE.md"], + "context_markers": { + "start": "", + "end": "", + }, + }, + ) + + integration = get_integration("codex") + manifest = IntegrationManifest("codex", target) + integration.setup(target, manifest, script_type="sh") + + plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" + content = plan_skill.read_text(encoding="utf-8") + assert "AGENTS.md, CLAUDE.md" in content + assert "__CONTEXT_FILE__" not in content + + def test_plan_skill_ignores_context_files_when_agent_context_disabled( + self, tmp_path + ): + """Disabled agent-context must not leak stale context_files into commands.""" + from specify_cli import _save_agent_context_config + + target = tmp_path / "test-proj" + target.mkdir() + registry = target / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text( + """ +{ + "schema_version": "1.0", + "extensions": { + "agent-context": { + "version": "1.0.0", + "enabled": false + } + } +} +""".strip(), + encoding="utf-8", + ) + _save_agent_context_config( + target, + { + "context_file": "AGENTS.md", + "context_files": ["../outside.md", "CLAUDE.md"], + "context_markers": { + "start": "", + "end": "", + }, + }, + ) + + integration = get_integration("codex") + manifest = IntegrationManifest("codex", target) + integration.setup(target, manifest, script_type="sh") + + plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" + content = plan_skill.read_text(encoding="utf-8") + assert "AGENTS.md, CLAUDE.md" not in content + assert "../outside.md" not in content + assert "AGENTS.md" in content + assert "__CONTEXT_FILE__" not in content + class TestCodexHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections. From 6a3ee9b64e06d259a218aa8eb5f84de80a053928 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:14:18 +0800 Subject: [PATCH 23/42] feat: add ZCode (Z.AI) integration (#3063) * feat: add ZCode (Z.AI) integration Add a skills-based integration for ZCode, Z.AI's Claude-Code-style agent. ZCode uses the same SKILL.md layout as Claude Code, so spec-kit installs workflows into .zcode/skills/speckit-/SKILL.md, invoked in chat as $speckit-. - ZcodeIntegration(SkillsIntegration) with .zcode/ folder and --skills option - Register in INTEGRATION_REGISTRY - Catalog entry (tags: cli, skills, z-ai) - Tests via SkillsIntegrationTests mixin - Document in integrations reference and README Co-Authored-By: Claude Opus 4.8 * fix: render $speckit-* invocations for ZCode skills ZCode is documented as a skills agent invoked with $speckit-, but the central invocation rendering only special-cased codex, so specify init Next Steps and extension hooks rendered the dotted /speckit. form instead. Centralize the $speckit-* decision in a DOLLAR_SKILLS_AGENTS set with an is_dollar_skills_agent() helper, and route both init Next Steps and HookExecutor._render_hook_invocation through it. Add ZCode invocation regression tests mirroring the existing Codex/Kimi coverage. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- docs/reference/integrations.md | 1 + integrations/catalog.json | 9 ++++ src/specify_cli/_invocation_style.py | 14 ++++++ src/specify_cli/commands/init.py | 14 +++++- src/specify_cli/extensions.py | 6 +-- src/specify_cli/integrations/__init__.py | 2 + .../integrations/zcode/__init__.py | 43 +++++++++++++++++++ tests/integrations/test_integration_zcode.py | 38 ++++++++++++++++ tests/test_extensions.py | 18 ++++++++ 10 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/specify_cli/integrations/zcode/__init__.py create mode 100644 tests/integrations/test_integration_zcode.py diff --git a/README.md b/README.md index 34e1403324..15d016ef95 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ specify init . --force --integration copilot specify init --here --force --integration 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: +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, Mistral Vibe, or ZCode 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 --integration copilot --ignore-agent-tools diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a790389774..1fe4a53640 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -38,6 +38,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [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` | | +| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | | [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | | Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | diff --git a/integrations/catalog.json b/integrations/catalog.json index 33c6ddd931..f89af37d5c 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -299,6 +299,15 @@ "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] + }, + "zcode": { + "id": "zcode", + "name": "ZCode", + "version": "1.0.0", + "description": "Z.AI ZCode CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills", "z-ai"] } } } diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index a61f699a53..627967cfbd 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -8,6 +8,9 @@ from __future__ import annotations +# Agents that render $speckit- (chat invocation) when in skills mode. +DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"}) + # Agents that always render /speckit-, regardless of ai_skills. ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"}) @@ -26,6 +29,17 @@ ) +def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: + """Return ``True`` if *selected_ai* uses ``$speckit-`` invocations. + + Agents in `DOLLAR_SKILLS_AGENTS` (e.g. ``codex``, ``zcode``) render + ``$speckit-`` chat invocations when installed in skills mode. + """ + if not isinstance(selected_ai, str): + return False + return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled + + def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: """Return ``True`` if *selected_ai* uses ``/speckit-`` invocations. diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 997b9ee679..fc82334da2 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -693,6 +693,7 @@ def init( ) or getattr(resolved_integration, "_skills_mode", False) codex_skill_mode = selected_ai == "codex" and _is_skills_integration + zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration claude_skill_mode = selected_ai == "claude" and _is_skills_integration kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration @@ -706,6 +707,7 @@ def init( cline_skill_mode = selected_ai == "cline" native_skill_mode = ( codex_skill_mode + or zcode_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode @@ -721,6 +723,11 @@ def init( f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" ) step_num += 1 + if zcode_skill_mode: + steps_lines.append( + f"{step_num}. Start ZCode in this project directory; spec-kit skills were installed to [cyan].zcode/skills[/cyan]" + ) + step_num += 1 if claude_skill_mode: steps_lines.append( f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]" @@ -743,7 +750,10 @@ def init( step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" - from .._invocation_style import is_slash_skills_agent as _is_slash_skills_agent + from .._invocation_style import ( + is_dollar_skills_agent as _is_dollar_skills_agent, + is_slash_skills_agent as _is_slash_skills_agent, + ) # `_is_skills_integration` means the integration is installed in # skills mode, which is the semantic equivalent of `ai_skills_enabled` @@ -751,7 +761,7 @@ def init( _ai_skills_enabled = _is_skills_integration def _display_cmd(name: str) -> str: - if codex_skill_mode: + if _is_dollar_skills_agent(selected_ai, _ai_skills_enabled): return f"$speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 72d1e66e97..a66511b3c2 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -27,7 +27,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from ._init_options import is_ai_skills_enabled -from ._invocation_style import is_slash_skills_agent +from ._invocation_style import is_dollar_skills_agent, is_slash_skills_agent from ._utils import dump_frontmatter, relative_extension_path_violation from .catalogs import CatalogEntry as BaseCatalogEntry from .catalogs import CatalogStackBase @@ -2886,12 +2886,12 @@ def _render_hook_invocation(self, command: Any) -> str: selected_ai = init_options.get("ai") ai_skills_enabled = is_ai_skills_enabled(init_options) - codex_skill_mode = selected_ai == "codex" and ai_skills_enabled + dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled) kimi_skill_mode = selected_ai == "kimi" cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) - if codex_skill_mode and skill_name: + if dollar_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/__init__.py b/src/specify_cli/integrations/__init__.py index 07d3cc1a6d..a81d705543 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -80,6 +80,7 @@ def _register_builtins() -> None: from .trae import TraeIntegration from .vibe import VibeIntegration from .windsurf import WindsurfIntegration + from .zcode import ZcodeIntegration from .zed import ZedIntegration # -- Registration (alphabetical) -------------------------------------- @@ -116,6 +117,7 @@ def _register_builtins() -> None: _register(TraeIntegration()) _register(VibeIntegration()) _register(WindsurfIntegration()) + _register(ZcodeIntegration()) _register(ZedIntegration()) diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py new file mode 100644 index 0000000000..ea47f31555 --- /dev/null +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -0,0 +1,43 @@ +"""ZCode integration — skills-based agent (Z.AI). + +ZCode uses the ``.zcode/skills/speckit-/SKILL.md`` layout, matching +the Claude Code skill format. Skills are invoked in chat with +``$speckit-``. Z.AI recommends skills (over simple ``/`` commands) +for template- and script-driven workflows such as spec-kit. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class ZcodeIntegration(SkillsIntegration): + """Integration for ZCode CLI (Z.AI).""" + + key = "zcode" + config = { + "name": "ZCode", + "folder": ".zcode/", + "commands_subdir": "skills", + "install_url": "https://zcode.z.ai/", + "requires_cli": True, + } + registrar_config = { + "dir": ".zcode/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "ZCODE.md" + multi_install_safe = True + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for ZCode)", + ), + ] diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py new file mode 100644 index 0000000000..3eb82ed4f2 --- /dev/null +++ b/tests/integrations/test_integration_zcode.py @@ -0,0 +1,38 @@ +"""Tests for ZcodeIntegration — skills-based integration (Z.AI).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestZcodeIntegration(SkillsIntegrationTests): + KEY = "zcode" + FOLDER = ".zcode/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".zcode/skills" + CONTEXT_FILE = "ZCODE.md" + + +class TestZcodeInvocation: + """ZCode renders $speckit-* chat invocations (like Codex).""" + + def test_next_steps_show_dollar_skill_invocation(self, tmp_path): + """ZCode next-steps guidance should display $speckit-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "zcode-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", "zcode", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9cf0167ce5..36f0818e25 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6052,6 +6052,24 @@ def test_codex_hooks_render_dollar_skill_invocation(self, project_dir): assert execution["command"] == "speckit.tasks" assert execution["invocation"] == "$speckit-tasks" + def test_zcode_hooks_render_dollar_skill_invocation(self, project_dir): + """ZCode projects with skills mode 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": "zcode", "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_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir): """Corrupted truthy ai_skills values should not enable skill invocation.""" init_options = project_dir / ".specify" / "init-options.json" From 826e193ceeb4d46f883a5c0f6ec5dc76e1e8ae33 Mon Sep 17 00:00:00 2001 From: darion-yaphet Date: Tue, 23 Jun 2026 02:40:57 +0800 Subject: [PATCH 24/42] refactor: move extension command handlers to extensions/_commands.py (PR-7/8) (#3014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: move extension command handlers to extensions/_commands.py (PR-7/8) Convert the flat extensions.py module into an extensions/ package and extract all extension_app and catalog_app command handlers plus their private helpers (_resolve_installed_extension, _resolve_catalog_extension, _print_extension_info) out of __init__.py into the new extensions/_commands.py, mirroring the domain-dir layout used for presets/_commands.py (PR-6) and integrations/_commands.py (PR-5). - extensions.py -> extensions/__init__.py (pure rename, 99%); intra-module relative imports bumped from `.x` to `..x` since they reference root siblings. - Root helpers (_require_specify_project, _locate_bundled_extension, load_init_options, _display_project_path) are reached through thin shims that re-fetch from the parent package at call time, so test monkeypatching of specify_cli. keeps working unchanged. - __init__.py drops ~1444 lines (3511 -> 2067); CLI surface preserved via register(app). No behavior change. Full suite failure set is identical before/after (82 pre-existing env failures, 0 new). * fix(extensions): preserve per-command path in update backup for skills agents Skills agents (extension == "/SKILL.md") name every command file SKILL.md, each in its own per-command subdir (e.g. speckit-plan/SKILL.md). The update backup keyed the backup path on cmd_file.name alone, so all of an agent's skill files collided onto a single backup path — each shutil.copy2 overwrote the previous one, and rollback restored one skill's content over all the others, corrupting or losing the rest. Mirror the real on-disk layout by using cmd_file.relative_to(commands_dir), keeping each backup path unique. This also makes backed_up_command_files values unique so restore copies the correct content back to each command. Add a regression test asserting two distinct skill files survive a backup -> failed-update -> rollback cycle with their own content. * style(extensions): use yaml.safe_dump when writing catalog config The catalog add/remove handlers wrote the integration catalog config with yaml.dump. Switch to yaml.safe_dump to align with the SafeDumper used by the presets commands and to refuse emitting !!python/object tags if a non-basic value ever reaches the config dict. Output is unchanged for the current basic-type payload (str/int/bool/dict/ list) — this is a defensive/consistency change, not a behavioral fix. * fix(extensions): correct _print_cli_warning import path in skill registration register_enabled_extensions_for_agent imported _print_cli_warning from `.` (the extensions package), but the helper lives in the parent specify_cli package. The wrong level raised ImportError inside the error handlers, aborting extension/skill registration on the first failure instead of warning and continuing. Use `..` to match the other parent-package imports. * fix(extensions): escape untrusted values in Rich markup output User-provided arguments and extension/catalog metadata (names, descriptions, versions, IDs, paths) were interpolated into Rich markup strings without escaping. Values containing markup sequences (e.g. [red]...) would be parsed as markup, allowing output injection that could corrupt or mislead CLI messages. Wrap all such interpolations with rich.markup.escape across the extension/catalog command handlers: list, search, info (_print_extension_info), add (including --dev paths), remove, enable, disable, set-priority, update, and the ambiguous-match resolvers (error strings and Table rows). Reuse the already-computed safe_extension where available. Escaping is a no-op for benign strings, so normal output is unchanged. * Prevent Rich markup injection in extension CLI output User-controlled catalog URLs and extension IDs are rendered through Rich-enabled console paths, so every remaining output-only interpolation now escapes markup while leaving stored values and filesystem behavior unchanged. Regression tests cover catalog add, install hints, remove hints, and state command messages with bracketed markup-like values. * Prevent markup injection from exception text Rich markup remains enabled for styled CLI messages, so exception text and config path labels must be escaped before rendering. YAML parser errors, URL validation failures, download errors, and extension validation errors can include user-controlled catalog or manifest values. Constraint: Preserve existing exception handling and user-facing error paths Rejected: Disable Rich markup for these messages | existing output intentionally uses markup for labels and styling Confidence: high Scope-risk: narrow Directive: Escape user-controlled exception text before interpolating into Rich-rendered strings Tested: .venv/bin/python -m pytest tests/test_extensions.py -q Co-authored-by: OmX * Prevent path and manifest review regressions Catalog path labels are rendered through Rich markup and downloaded update manifests are trusted long enough to validate extension IDs. Escape displayed project paths before rendering, and reject non-mapping extension.yml payloads before ID validation so bad archives fail with a clear rollback reason. --------- Co-authored-by: OmX --- src/specify_cli/__init__.py | 1455 +-------------- .../{extensions.py => extensions/__init__.py} | 44 +- src/specify_cli/extensions/_commands.py | 1556 +++++++++++++++++ tests/integrations/test_cli.py | 72 + tests/test_extension_update_hardening.py | 48 + tests/test_extensions.py | 543 ++++++ 6 files changed, 2246 insertions(+), 1472 deletions(-) rename src/specify_cli/{extensions.py => extensions/__init__.py} (99%) create mode 100644 src/specify_cli/extensions/_commands.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9f9efa09b1..b2e8defb18 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -29,12 +29,11 @@ import contextlib import os import sys -import zipfile import json import yaml from pathlib import Path -from typing import Any, Optional +from typing import Any import typer from rich.panel import Panel @@ -56,7 +55,7 @@ show_banner, ) from ._assets import ( - _locate_bundled_extension, + _locate_bundled_extension as _locate_bundled_extension, _locate_bundled_preset as _locate_bundled_preset, _locate_bundled_workflow as _locate_bundled_workflow, _locate_core_pack, @@ -577,19 +576,9 @@ def version( # ===== Extension Commands ===== -extension_app = typer.Typer( - name="extension", - help="Manage spec-kit extensions", - add_completion=False, -) -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") +# Moved to extensions/_commands.py — registered here to preserve CLI surface. +from .extensions._commands import register as _register_extension_cmds # noqa: E402 +_register_extension_cmds(app) # ===== Integration Commands ===== @@ -630,1440 +619,6 @@ def _require_specify_project() -> Path: _register_bundle_cmds(app) -# ===== Extension Commands ===== - - -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"), - all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), -): - """List installed extensions.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - if not installed and not (available or all_extensions): - console.print("[yellow]No extensions installed.[/yellow]") - console.print("\nInstall an extension with:") - console.print(" specify extension add ") - return - - if installed: - console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") - - for ext in installed: - status_icon = "✓" if ext["enabled"] else "✗" - 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']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") - console.print() - - if available or all_extensions: - console.print("\nInstall an extension:") - 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 = _require_specify_project() - 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: {_display_project_path(project_root, config_path)}[/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 = _require_specify_project() - specify_dir = project_root / ".specify" - - # 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(encoding="utf-8")) or {} - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {config_label}: {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, 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})") - console.print(f" URL: {url}") - console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") - - -@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 = _require_specify_project() - specify_dir = project_root / ".specify" - - 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(encoding="utf-8")) 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, allow_unicode=True), encoding="utf-8") - - 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"), - dev: bool = typer.Option(False, "--dev", help="Install from local directory"), - from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), - force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), - 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, REINSTALL_COMMAND - - project_root = _require_specify_project() - # 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() - - if force: - console.print("[yellow]--force:[/yellow] Will overwrite if already installed") - - # Prompt for URL-based installs BEFORE the spinner so the user can - # actually see and respond to the confirmation (the Rich status - # spinner overwrites the typer.confirm prompt line, making it appear - # as though the command is hung). - # Guard with ``not dev`` so that --dev + --from does not show a - # confusing confirmation for a URL that will be ignored. - if from_url and not dev: - from urllib.parse import urlparse - from rich.markup import escape as _escape_markup - - 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("[red]Error:[/red] URL must use HTTPS for security.") - console.print("HTTP is only allowed for localhost URLs.") - raise typer.Exit(1) - - safe_url = _escape_markup(from_url) - - # Warn about untrusted sources — default-deny confirmation - console.print() - console.print(Panel( - f"[bold]You are installing an extension from an external URL that is not\n" - f"listed in any of your configured extension catalogs.[/bold]\n\n" - f"URL: {safe_url}\n\n" - f"Only install extensions from sources you trust.", - title="[bold yellow]⚠ Untrusted Source[/bold yellow]", - border_style="yellow", - padding=(1, 2), - )) - console.print() - confirm = typer.confirm("Continue with installation?", default=False) - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - try: - with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): - if dev: - # Install from local directory - source_path = Path(extension).expanduser().resolve() - if not source_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {source_path}") - raise typer.Exit(1) - - if not (source_path / "extension.yml").exists(): - console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") - raise typer.Exit(1) - - if force: - console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...") - - manifest = manager.install_from_directory( - source_path, - speckit_version, - priority=priority, - link_commands=True, - force=force - ) - - elif from_url: - # Install from URL (ZIP file) - import urllib.error - - console.print(f"Downloading from {safe_url}...") - - # Download ZIP to temp location - download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" - download_dir.mkdir(parents=True, exist_ok=True) - zip_path = download_dir / f"{extension}-url-download.zip" - - try: - from specify_cli.authentication.http import open_url as _open_url - - with _open_url(from_url, timeout=60) as response: - zip_data = response.read() - zip_path.write_bytes(zip_data) - - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}") - raise typer.Exit(1) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() - - else: - # 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, force=force - ) - 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) - - # 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, force=force - ) - - 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") - 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) - - try: - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) - 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})") - console.print(f" {manifest.description}") - - for warning in manifest.warnings: - console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") - - is_cline = load_init_options(project_root).get("ai") == "cline" - - if is_cline: - from specify_cli.integrations.cline import format_cline_command_name - - console.print("\n[bold cyan]Provided commands:[/bold cyan]") - for cmd in manifest.commands: - cmd_name = cmd['name'] - if is_cline: - cmd_name = format_cline_command_name(cmd_name) - 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}/") - - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except CompatibilityError as e: - console.print(f"\n[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) - - -@extension_app.command("remove") -def extension_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"), -): - """Uninstall an extension.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - 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, "remove") - - # Get extension info for command and skill counts - ext_manifest = manager.get_extension(extension_id) - reg_meta = manager.registry.get(extension_id) - # Derive cmd_count from the registry's registered_commands (includes aliases) - # rather than from the manifest (primary commands only). Use max() across - # agents to get the per-agent count; sum() would double-count since users - # think in logical commands, not per-agent file counts. - # Use get() without a default so we can distinguish "key missing" (fall back - # to manifest) from "key present but empty dict" (zero commands registered). - registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None - if isinstance(registered_commands, dict): - cmd_count = max( - (len(v) for v in registered_commands.values() if isinstance(v, list)), - default=0, - ) - else: - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 - 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} command{'s' if cmd_count != 1 else ''} per 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)") - console.print() - - confirm = typer.confirm("Continue?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Remove extension - success = manager.remove(extension_id, keep_config=keep_config) - - if success: - console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") - if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") - else: - 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) - - -@extension_app.command("search") -def extension_search( - query: str = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), - verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), -): - """Search for available extensions in catalog.""" - from .extensions import ExtensionCatalog, ExtensionError - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - - try: - console.print("🔍 Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) - - if not results: - console.print("\n[yellow]No extensions found matching criteria[/yellow]") - if query or tag or author or verified: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify extension search (show all)") - raise typer.Exit(0) - - console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") - - for ext in results: - # Extension header - verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") - console.print(f" {ext['description']}") - - # Metadata - console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") - if ext.get('tags'): - 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: - stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get('stars') is not None: - stats.append(f"Stars: {ext['stars']}") - if stats: - console.print(f" [dim]{' | '.join(stats)}[/dim]") - - # Links - if ext.get('repository'): - console.print(f" [dim]Repository:[/dim] {ext['repository']}") - - # 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: - console.print(f"\n[red]Error:[/red] {e}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") - raise typer.Exit(1) - - -@extension_app.command("info") -def extension_info( - extension: str = typer.Argument(help="Extension ID or name"), -): - """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - # 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 - ) - - # 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") - - # Case 1: Found in catalog - show full catalog info - if ext_info: - _print_extension_info(ext_info, manager) - return - - # 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) - 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{version})") - console.print(f"ID: {resolved_installed_id}") - console.print() - - 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}") - if ext_manifest.category: - console.print(f"[dim]Category:[/dim] {ext_manifest.category}") - if ext_manifest.effect: - console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}") - 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)") - - 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 - - # 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) - - -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}") - console.print(f"ID: {ext_info['id']}") - console.print() - - # Description - console.print(f"{ext_info['description']}") - 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')}") - - # Category and Effect - if ext_info.get('category'): - console.print(f"[dim]Category:[/dim] {ext_info['category']}") - if ext_info.get('effect'): - console.print(f"[dim]Effect:[/dim] {ext_info['effect']}") - - # 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 - 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]") - 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]") - 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 or name to update (or all)"), -): - """Update extension(s) to latest version.""" - from .extensions import ( - ExtensionManager, - ExtensionCatalog, - ExtensionError, - ValidationError, - CommandRegistrar, - HookExecutor, - normalize_priority, - ) - from packaging import version as pkg_version - import shutil - - project_root = _require_specify_project() - 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 - resolve ID from argument (handles ambiguous names) - extension_id, _ = _resolve_installed_extension(extension, installed, "update") - extensions_to_update = [extension_id] - else: - # Update all extensions - extensions_to_update = [ext["id"] for ext in installed] - - if not extensions_to_update: - console.print("[yellow]No extensions installed[/yellow]") - raise typer.Exit(0) - - console.print("🔄 Checking for updates...\n") - - updates_available = [] - - for ext_id in extensions_to_update: - # Get installed version - metadata = manager.registry.get(ext_id) - 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: - 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) - if not ext_info: - console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") - continue - - # 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"), - } - ) - else: - console.print(f"✓ {ext_id}: Up to date (v{installed_version})") - - if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") - raise typer.Exit(0) - - # Show available updates - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {update['id']}: {update['installed']} → {update['available']}" - ) - - console.print() - confirm = typer.confirm("Update these extensions?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Perform updates with atomic backup/restore - console.print() - updated_extensions = [] - failed_updates = [] - registrar = CommandRegistrar() - hook_executor = HookExecutor(project_root) - from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths - - # UNSET sentinel: backup not yet captured (exception before backup step) - UNSET = object() - - for update in updates_available: - extension_id = update["id"] - ext_name = update["name"] # Use display name for user-facing messages - console.print(f"📦 Updating {ext_name}...") - - # 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" - - # Store backup state - backup_registry_entry = None # None means registry entry not yet captured - backup_installed = UNSET # Original installed list from extensions.yml - backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured - 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", {}) if isinstance(backup_registry_entry, dict) else {} - 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 = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) - - for cmd_name in cmd_names: - 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) - 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 and installed list from extensions.yml - # get_project_config() always normalizes installed->[] and hooks->{}, - # so no sentinel is needed to distinguish key-absent from key-empty. - config = hook_executor.get_project_config() - if isinstance(config, dict): - import copy - # Deep-copy so nested mapping entries (e.g. version-pin dicts) - # are not affected by in-place mutations during the update. - backup_installed = copy.deepcopy(config.get("installed", [])) - backup_hooks = {} - for hook_name, hook_list in config.get("hooks", {}).items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and 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 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 or not isinstance(current_metadata, dict): - raise RuntimeError( - f"Registry entry for '{extension_id}' missing or corrupted 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"] - - # 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 - - # 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 or not isinstance(new_registry_entry, dict): - 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 = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) - - for cmd_name in cmd_names: - 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() - - # 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 metadata in extensions.yml (hooks and installed list). - # Only run if backup step 4 was reached (backup_hooks is not None); - # otherwise we have no safe baseline to restore from and could corrupt - # the config by removing pre-existing hooks. - if backup_hooks is not None: - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} - - modified = False - - # 1. Restore hooks in extensions.yml - if not isinstance(config.get("hooks"), dict): - config["hooks"] = {} - modified = True - - # Remove any hooks for this extension added by the failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): - config["hooks"][hook_name] = [] - modified = True - continue - - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed-up hooks - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if not isinstance(config["hooks"].get(hook_name), list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True - - # 2. Restore installed list in extensions.yml - if backup_installed is not UNSET: - if config.get("installed") != backup_installed: - config["installed"] = backup_installed - 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) - - -@extension_app.command("enable") -def extension_enable( - extension: str = typer.Argument(help="Extension ID or name to enable"), -): - """Enable a disabled extension.""" - from .extensions import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # 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_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) - - if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": True}) - - # 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_id: - hook["enabled"] = True - hook_executor.save_project_config(config) - - console.print(f"[green]✓[/green] Extension '{display_name}' enabled") - - -@extension_app.command("disable") -def extension_disable( - extension: str = typer.Argument(help="Extension ID or name to disable"), -): - """Disable an extension without removing it.""" - from .extensions import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # 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_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) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": False}) - - # 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_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - - 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_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 = _require_specify_project() - # 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]") - - # ===== Workflow Commands ===== workflow_app = typer.Typer( diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions/__init__.py similarity index 99% rename from src/specify_cli/extensions.py rename to src/specify_cli/extensions/__init__.py index a66511b3c2..e64d856a7e 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions/__init__.py @@ -26,11 +26,11 @@ from packaging import version as pkg_version from packaging.specifiers import InvalidSpecifier, SpecifierSet -from ._init_options import is_ai_skills_enabled -from ._invocation_style import is_dollar_skills_agent, is_slash_skills_agent -from ._utils import dump_frontmatter, relative_extension_path_violation -from .catalogs import CatalogEntry as BaseCatalogEntry -from .catalogs import CatalogStackBase +from .._init_options import is_ai_skills_enabled +from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent +from .._utils import dump_frontmatter, relative_extension_path_violation +from ..catalogs import CatalogEntry as BaseCatalogEntry +from ..catalogs import CatalogStackBase _FALLBACK_CORE_COMMAND_NAMES = frozenset( { @@ -905,7 +905,7 @@ def _get_skills_dir(self) -> Optional[Path]: be created due to symlink, containment, or permission issues so that callers can fall back gracefully. """ - from . import ( + from .. import ( _print_cli_warning, load_init_options, resolve_active_skills_dir, @@ -948,7 +948,7 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]: if not isinstance(selected_ai, str) or not selected_ai: return _ensure_usable(skills_dir) - from .agents import CommandRegistrar + from ..agents import CommandRegistrar registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(selected_ai) @@ -985,9 +985,9 @@ def _register_extension_skills( if not skills_dir: return [] - from . import load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + from .. import load_init_options + from ..agents import CommandRegistrar + from ..integrations import get_integration written: List[str] = [] opts = load_init_options(self.project_root) @@ -1201,7 +1201,7 @@ def _unregister_extension_skills( shutil.rmtree(skill_subdir) else: # Fallback: scan all possible agent skills directories - from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR + from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR candidate_dirs: set[Path] = set() for cfg in AGENT_CONFIG.values(): @@ -1616,7 +1616,7 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: # Resolve the skills directory for the specific agent so cleanup is # agent-scoped and does not depend on the currently-active agent in # init-options. Use the same helper that extension install uses. - from . import _get_skills_dir as resolve_skills_dir + from .. import _get_skills_dir as resolve_skills_dir agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) @@ -1692,7 +1692,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: if not agent_name: return - from . import load_init_options + from .. import load_init_options registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(agent_name) @@ -1750,7 +1750,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: # Skills are a companion artifact. If command registration # already succeeded, still persist it so later cleanup can # find those command files. - from . import _print_cli_warning + from .. import _print_cli_warning _print_cli_warning( "register extension skills for", @@ -1775,7 +1775,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: except Exception as ext_err: # Best-effort per extension: warn and move on so a single bad # extension cannot silently drop the others. See #2950. - from . import _print_cli_warning + from .. import _print_cli_warning _print_cli_warning( "register extension artifacts for", @@ -1882,31 +1882,31 @@ class CommandRegistrar: """ # Re-export AGENT_CONFIGS at class level for direct attribute access - from .agents import CommandRegistrar as _AgentRegistrar + from ..agents import CommandRegistrar as _AgentRegistrar AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS def __init__(self): - from .agents import CommandRegistrar as _Registrar + from ..agents import CommandRegistrar as _Registrar self._registrar = _Registrar() # Delegate static/utility methods @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: - from .agents import CommandRegistrar as _Registrar + from ..agents import CommandRegistrar as _Registrar return _Registrar.parse_frontmatter(content) @staticmethod def render_frontmatter(fm: dict) -> str: - from .agents import CommandRegistrar as _Registrar + from ..agents import CommandRegistrar as _Registrar return _Registrar.render_frontmatter(fm) @staticmethod def _write_copilot_prompt(project_root, cmd_name: str) -> None: - from .agents import CommandRegistrar as _Registrar + from ..agents import CommandRegistrar as _Registrar _Registrar.write_copilot_prompt(project_root, cmd_name) @@ -2857,7 +2857,7 @@ def _load_init_options(self) -> Dict[str, Any]: instance to avoid repeated filesystem reads during hook rendering. """ if self._init_options_cache is None: - from . import load_init_options + from .. import load_init_options payload = load_init_options(self.project_root) self._init_options_cache = payload if isinstance(payload, dict) else {} @@ -2896,7 +2896,7 @@ def _render_hook_invocation(self, command: Any) -> str: if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" if cline_mode: - from .integrations.cline import format_cline_command_name + from ..integrations.cline import format_cline_command_name return f"/{format_cline_command_name(command_id)}" diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py new file mode 100644 index 0000000000..3b60b6d52d --- /dev/null +++ b/src/specify_cli/extensions/_commands.py @@ -0,0 +1,1556 @@ +"""specify extension * and catalog * command handlers — app objects and register(). + +Moved out of __init__.py (PR-7/8). Handlers reference helpers that remain in +the package root (`_require_specify_project`, `_locate_bundled_extension`, +`load_init_options`, `_display_project_path`) through the thin shims below, +which re-fetch from the parent package at call time so test monkeypatching of +`specify_cli.` keeps working. +""" +from __future__ import annotations + +import os +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import Optional + +import typer +import yaml +from rich.markup import escape as _escape_markup +from rich.panel import Panel +from rich.table import Table + +from .._console import console +from .._assets import get_speckit_version + +extension_app = typer.Typer( + name="extension", + help="Manage spec-kit extensions", + add_completion=False, +) + +catalog_app = typer.Typer( + name="catalog", + help="Manage extension catalogs", + add_completion=False, +) +extension_app.add_typer(catalog_app, name="catalog") + + +# Root helpers re-fetched at call time so test monkeypatching of +# `specify_cli.` keeps working after the move. +def _require_specify_project(*args, **kwargs): + from .. import _require_specify_project as _f + return _f(*args, **kwargs) + + +def _locate_bundled_extension(*args, **kwargs): + from .. import _locate_bundled_extension as _f + return _f(*args, **kwargs) + + +def load_init_options(*args, **kwargs): + from .. import load_init_options as _f + return _f(*args, **kwargs) + + +def _display_project_path(*args, **kwargs): + from .. import _display_project_path as _f + return _f(*args, **kwargs) + + +def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict: + """Load extension catalog CLI config with user-facing shape errors.""" + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as e: + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {_escape_markup(str(e))}") + raise typer.Exit(1) + + if config is None: + return {} + if not isinstance(config, dict): + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print( + f"[red]Error:[/red] Invalid catalog config {config_label}: " + "expected a YAML mapping at the root." + ) + raise typer.Exit(1) + return config + + +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 + """ + # 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 '{_escape_markup(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( + _escape_markup(str(ext.get("id", ""))), + _escape_markup(str(ext.get("name", ""))), + _escape_markup(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 '{_escape_markup(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 . 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 '{_escape_markup(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( + _escape_markup(str(ext.get("id", ""))), + _escape_markup(str(ext.get("name", ""))), + _escape_markup(str(ext.get("version", ""))), + _escape_markup(str(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"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), +): + """List installed extensions.""" + from . import ExtensionManager + + project_root = _require_specify_project() + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return + + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + + for ext in installed: + status_icon = "✓" if ext["enabled"] else "✗" + status_color = "green" if ext["enabled"] else "red" + + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{_escape_markup(ext['name'])}[/bold] (v{_escape_markup(str(ext['version']))})") + console.print(f" [dim]{_escape_markup(ext['id'])}[/dim]") + console.print(f" {_escape_markup(ext['description'])}") + 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: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") + + +@catalog_app.command("list") +def catalog_list(): + """List all active extension catalogs.""" + from . import ExtensionCatalog, ValidationError + + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except ValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(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]{_escape_markup(entry.name)}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {_escape_markup(entry.description)}") + console.print(f" URL: {_escape_markup(str(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: + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"[dim]Config: {config_label}[/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 . import ExtensionCatalog, ValidationError + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + + # Load existing config + if config_path.exists(): + config = _load_catalog_command_config(project_root, config_path) + 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) + + safe_name = _escape_markup(name) + safe_url = _escape_markup(url) + + # 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 '{safe_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.safe_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]{safe_name}[/bold]' ({install_label})") + console.print(f" URL: {safe_url}") + console.print(f" Priority: {priority}") + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"\nConfig saved to {config_label}") + + +@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 = _require_specify_project() + specify_dir = project_root / ".specify" + + 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) + + config = _load_catalog_command_config(project_root, config_path) + + 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) + safe_name = _escape_markup(name) + 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 '{safe_name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + console.print(f"[green]✓[/green] Removed catalog '{safe_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"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install an extension.""" + from . import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + + project_root = _require_specify_project() + # 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() + + if force: + console.print("[yellow]--force:[/yellow] Will overwrite if already installed") + + # Prompt for URL-based installs BEFORE the spinner so the user can + # actually see and respond to the confirmation (the Rich status + # spinner overwrites the typer.confirm prompt line, making it appear + # as though the command is hung). + # Guard with ``not dev`` so that --dev + --from does not show a + # confusing confirmation for a URL that will be ignored. + if from_url and not dev: + from urllib.parse import 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("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for localhost URLs.") + raise typer.Exit(1) + + safe_url = _escape_markup(from_url) + + # Warn about untrusted sources — default-deny confirmation + console.print() + console.print(Panel( + f"[bold]You are installing an extension from an external URL that is not\n" + f"listed in any of your configured extension catalogs.[/bold]\n\n" + f"URL: {safe_url}\n\n" + f"Only install extensions from sources you trust.", + title="[bold yellow]⚠ Untrusted Source[/bold yellow]", + border_style="yellow", + padding=(1, 2), + )) + console.print() + confirm = typer.confirm("Continue with installation?", default=False) + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + safe_extension = _escape_markup(extension) + + try: + with console.status(f"[cyan]Installing extension: {safe_extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + safe_source_path = _escape_markup(str(source_path)) + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {safe_source_path}") + raise typer.Exit(1) + + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {safe_source_path}") + raise typer.Exit(1) + + if force: + console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{safe_source_path}[/cyan] (will overwrite if already installed)...") + + manifest = manager.install_from_directory( + source_path, + speckit_version, + priority=priority, + link_commands=True, + force=force + ) + + elif from_url: + # Install from URL (ZIP file) + import urllib.error + + console.print(f"Downloading from {safe_url}...") + + # Download ZIP to temp location + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix="extension-url-download-", + suffix=".zip", + dir=download_dir, + delete=False, + ) as download_file: + zip_path = Path(download_file.name) + + try: + from specify_cli.authentication.http import open_url as _open_url + + with _open_url(from_url, timeout=60) as response: + zip_data = response.read() + zip_path.write_bytes(zip_data) + + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) + except urllib.error.URLError as e: + console.print( + f"[red]Error:[/red] Failed to download from {safe_url}: " + f"{_escape_markup(str(e))}" + ) + raise typer.Exit(1) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + else: + # 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, force=force + ) + 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: {_escape_markup(str(catalog_error))}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{safe_extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + 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, force=force + ) + + 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 '{_escape_markup(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 = _escape_markup(str(ext_info.get("_catalog_name", "community"))) + console.print( + f"[red]Error:[/red] '{safe_extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{safe_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 {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(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, force=force) + 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]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})") + console.print(f" {_escape_markup(str(manifest.description))}") + + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") + + is_cline = load_init_options(project_root).get("ai") == "cline" + + if is_cline: + from specify_cli.integrations.cline import format_cline_command_name + + console.print("\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + cmd_name = cmd['name'] + if is_cline: + cmd_name = format_cline_command_name(cmd_name) + console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(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/{_escape_markup(str(manifest.id))}/") + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + +@extension_app.command("remove") +def extension_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"), +): + """Uninstall an extension.""" + from . import ExtensionManager + + project_root = _require_specify_project() + 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, "remove") + safe_extension_id = _escape_markup(str(extension_id)) + + # Get extension info for command and skill counts + ext_manifest = manager.get_extension(extension_id) + reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + 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} command{'s' if cmd_count != 1 else ''} per agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") + console.print(f" • Extension directory: .specify/extensions/{safe_extension_id}/") + if not keep_config: + console.print(" • Config files (will be backed up)") + console.print() + + confirm = typer.confirm("Continue?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Remove extension + success = manager.remove(extension_id, keep_config=keep_config) + + if success: + console.print(f"\n[green]✓[/green] Extension '{_escape_markup(str(display_name))}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/") + console.print(f"\nTo reinstall: specify extension add {safe_extension_id}") + else: + console.print("[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) + + +@extension_app.command("search") +def extension_search( + query: str = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +): + """Search for available extensions in catalog.""" + from . import ExtensionCatalog, ExtensionError + + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) + + try: + console.print("🔍 Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) + + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + + for ext in results: + # Extension header + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}") + console.print(f" {_escape_markup(str(ext['description']))}") + + # Metadata + console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}") + if ext.get('tags'): + tags_str = ", ".join(str(t) for t in ext['tags']) + console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}") + + # Source catalog + catalog_name = _escape_markup(str(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: + stats.append(f"Downloads: {ext['downloads']:,}") + if ext.get('stars') is not None: + stats.append(f"Stars: {ext['stars']}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") + + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {_escape_markup(str(ext['repository']))}") + + # Install command (show warning if not installable) + safe_id = _escape_markup(str(ext['id'])) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {safe_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 {safe_id} --from " + ) + console.print() + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + +@extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +): + """Show detailed information about an extension.""" + from . import ExtensionCatalog, ExtensionManager, normalize_priority + + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + # 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 + ) + + # 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") + + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return + + # 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) + 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]{_escape_markup(str(resolved_installed_name))}[/bold] (v{_escape_markup(str(version))})") + console.print(f"ID: {_escape_markup(str(resolved_installed_id))}") + console.print() + + if ext_manifest: + console.print(f"{_escape_markup(str(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] {_escape_markup(str(author))}") + if ext_manifest.category: + console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_manifest.category))}") + if ext_manifest.effect: + console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_manifest.effect))}") + console.print() + + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {_escape_markup(str(cmd['name']))}: {_escape_markup(str(cmd.get('description', '')))}") + console.print() + + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {_escape_markup(str(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)") + + 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 {_escape_markup(str(resolved_installed_id))}") + return + + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{_escape_markup(extension)}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) + + +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + from . import normalize_priority + + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{_escape_markup(str(ext_info['name']))}[/bold] (v{_escape_markup(str(ext_info['version']))}){verified_badge}") + console.print(f"ID: {_escape_markup(str(ext_info['id']))}") + console.print() + + # Description + console.print(f"{_escape_markup(str(ext_info['description']))}") + console.print() + + # Author and License + console.print(f"[dim]Author:[/dim] {_escape_markup(str(ext_info.get('author', 'Unknown')))}") + console.print(f"[dim]License:[/dim] {_escape_markup(str(ext_info.get('license', 'Unknown')))}") + + # Category and Effect + if ext_info.get('category'): + console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_info['category']))}") + if ext_info.get('effect'): + console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_info['effect']))}") + + # 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] {_escape_markup(str(ext_info['_catalog_name']))}{install_note}") + console.print() + + # 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: {_escape_markup(str(reqs['speckit_version']))}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = _escape_markup(str(tool['name'])) + tool_version = _escape_markup(str(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: {_escape_markup(str(provides['commands']))}") + if provides.get('hooks'): + console.print(f" • Hooks: {_escape_markup(str(provides['hooks']))}") + console.print() + + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(str(t) for t in ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {_escape_markup(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: {_escape_markup(str(ext_info['repository']))}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {_escape_markup(str(ext_info['homepage']))}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {_escape_markup(str(ext_info['documentation']))}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {_escape_markup(str(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) + safe_id = _escape_markup(str(ext_info['id'])) + 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 {safe_id}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {safe_id}") + else: + catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community"))) + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{safe_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 or name to update (or all)"), +): + """Update extension(s) to latest version.""" + from . import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + normalize_priority, + ) + from packaging import version as pkg_version + + project_root = _require_specify_project() + 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 - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] + else: + # Update all extensions + extensions_to_update = [ext["id"] for ext in installed] + + if not extensions_to_update: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) + + console.print("🔄 Checking for updates...\n") + + updates_available = [] + + for ext_id in extensions_to_update: + safe_ext_id = _escape_markup(str(ext_id)) + # Get installed version + metadata = manager.registry.get(ext_id) + if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: + console.print(f"⚠ {safe_ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {safe_ext_id}: Invalid installed version '{_escape_markup(str(metadata.get('version')))}' in registry (skipping)" + ) + continue + + # Get catalog info + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {safe_ext_id}: Not found in catalog (skipping)") + continue + + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {safe_ext_id}: Updates not allowed from '{_escape_markup(str(ext_info.get('_catalog_name', 'catalog')))}' (skipping)") + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {safe_ext_id}: Invalid catalog version '{_escape_markup(str(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"), + } + ) + else: + console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") + + if not updates_available: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {_escape_markup(str(update['id']))}: {update['installed']} → {update['available']}" + ) + + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Perform updates with atomic backup/restore + console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + from ..agents import CommandRegistrar as _AgentReg # used in backup and rollback paths + + # UNSET sentinel: backup not yet captured (exception before backup step) + UNSET = object() + + for update in updates_available: + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + safe_ext_name = _escape_markup(str(ext_name)) + console.print(f"📦 Updating {safe_ext_name}...") + + # 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" + + # Store backup state + backup_registry_entry = None # None means registry entry not yet captured + backup_installed = UNSET # Original installed list from extensions.yml + backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured + 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", {}) if isinstance(backup_registry_entry, dict) else {} + 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 = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) + + for cmd_name in cmd_names: + 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(): + # Mirror the real on-disk layout under the backup dir. + # Skills agents (extension == "/SKILL.md") name every + # command file "SKILL.md", living in a per-command + # subdir (e.g. speckit-plan/SKILL.md). Using cmd_file.name + # alone would collide all of them onto one backup path and + # break rollback; keep the relative path to stay unique. + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.relative_to(commands_dir) + 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 and installed list from extensions.yml + # get_project_config() always normalizes installed->[] and hooks->{}, + # so no sentinel is needed to distinguish key-absent from key-empty. + config = hook_executor.get_project_config() + if isinstance(config, dict): + import copy + # Deep-copy so nested mapping entries (e.g. version-pin dicts) + # are not affected by in-place mutations during the update. + backup_installed = copy.deepcopy(config.get("installed", [])) + backup_hooks = {} + for hook_name, hook_list in config.get("hooks", {}).items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and 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: + parsed_manifest = yaml.safe_load(f) + manifest_data = parsed_manifest if parsed_manifest is not None else {} + 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: + parsed_manifest = yaml.safe_load(f) + manifest_data = parsed_manifest if parsed_manifest is not None else {} + + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") + if not isinstance(manifest_data, dict): + raise ValueError( + "Invalid extension manifest in downloaded archive: expected YAML mapping" + ) + extension_data = manifest_data.get("extension", {}) + if not isinstance(extension_data, dict): + raise ValueError( + "Invalid extension manifest in downloaded archive: expected 'extension' mapping" + ) + + zip_extension_id = extension_data.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 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 or not isinstance(current_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing or corrupted 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"] + + # 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 + + # 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: {_escape_markup(str(e))}") + failed_updates.append((ext_name, str(e))) + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {safe_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 or not isinstance(new_registry_entry, dict): + 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 = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) + + for cmd_name in cmd_names: + 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() + + # 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 metadata in extensions.yml (hooks and installed list). + # Only run if backup step 4 was reached (backup_hooks is not None); + # otherwise we have no safe baseline to restore from and could corrupt + # the config by removing pre-existing hooks. + if backup_hooks is not None: + config = hook_executor.get_project_config() + if not isinstance(config, dict): + config = {} + + modified = False + + # 1. Restore hooks in extensions.yml + if not isinstance(config.get("hooks"), dict): + config["hooks"] = {} + modified = True + + # Remove any hooks for this extension added by the failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] + modified = True + continue + + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed-up hooks + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if not isinstance(config["hooks"].get(hook_name), list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + # 2. Restore installed list in extensions.yml + if backup_installed is not UNSET: + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + 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: {_escape_markup(str(rollback_error))}") + console.print(f" [dim]Backup preserved at: {_escape_markup(str(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" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}") + raise typer.Exit(1) + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + +@extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID or name to enable"), +): + """Enable a disabled extension.""" + from . import ExtensionManager, HookExecutor + + project_root = _require_specify_project() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # 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_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": True}) + + # 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_id: + hook["enabled"] = True + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled") + + +@extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID or name to disable"), +): + """Disable an extension without removing it.""" + from . import ExtensionManager, HookExecutor + + project_root = _require_specify_project() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # 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_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already disabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": False}) + + # 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_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' disabled") + console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {_escape_markup(str(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 . import ExtensionManager + + project_root = _require_specify_project() + # 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 '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + from . 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 '{_escape_markup(str(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 '{_escape_markup(str(display_name))}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + +def register(app: typer.Typer) -> None: + """Attach the extension command group to the root Typer app.""" + app.add_typer(extension_app, name="extension") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 30bcb015d1..565b8c84be 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1315,6 +1315,78 @@ def test_catalog_config_output_uses_posix_paths(self, tmp_path): assert extension_list.exit_code == 0, extension_list.output assert "Config: .specify/extension-catalogs.yml" in extension_list.output + def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path): + project = self._make_project(tmp_path) + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + result = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", "demo-extensions", + ], project) + + assert result.exit_code == 1, result.output + output = _normalize_cli_output(result.output) + assert "Invalid catalog config .specify/extension-catalogs.yml" in output + assert "expected a YAML mapping at the root" in output + assert "AttributeError" not in output + + def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path): + project = self._make_project(tmp_path) + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + result = self._invoke(["extension", "catalog", "remove", "demo"], project) + + assert result.exit_code == 1, result.output + output = _normalize_cli_output(result.output) + assert "Invalid catalog config .specify/extension-catalogs.yml" in output + assert "expected a YAML mapping at the root" in output + assert "AttributeError" not in output + + def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path): + project = self._make_project(tmp_path) + catalog_name = "[red]demo[/red]" + + result = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", catalog_name, + ], project) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + assert f"Added catalog '{catalog_name}'" in output + + def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path): + project = self._make_project(tmp_path) + catalog_name = "[red]demo[/red]" + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": catalog_name, + "url": "https://example.com/extension-catalog.yml", + "priority": 10, + "install_allowed": False, + "description": "", + } + ] + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + result = self._invoke(["extension", "catalog", "remove", catalog_name], project) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + assert f"Removed catalog '{catalog_name}'" in output + # -- search ------------------------------------------------------------ def test_search_lists_all(self, tmp_path, monkeypatch): diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 426e5ec7e9..8e8ed18543 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -107,3 +107,51 @@ def mock_download_fail(*args, **kwargs): assert isinstance(restored_config, dict) assert "hooks" in restored_config assert restored_config["hooks"] == {} + + +def test_extension_update_skills_backup_no_collision(project_dir, monkeypatch): + """Regression: skills agents name every command file SKILL.md (one per + command subdirectory). Backup must keep the per-command path so rollback + restores each skill's own content instead of overwriting them onto a + single backup path.""" + monkeypatch.chdir(project_dir) + + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": {}})) + + # Two skill command files with DISTINCT content, mirroring the claude + # skills layout (.claude/skills//SKILL.md). + skills_root = project_dir / ".claude" / "skills" + plan_file = skills_root / "speckit-plan" / "SKILL.md" + tasks_file = skills_root / "speckit-tasks" / "SKILL.md" + plan_file.parent.mkdir(parents=True) + tasks_file.parent.mkdir(parents=True) + plan_file.write_text("PLAN CONTENT") + tasks_file.write_text("TASKS CONTENT") + + monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) + monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"claude": ["speckit.plan", "speckit.tasks"]}, + }) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) + + # Fail at download (step 5, after the command backup in step 3). Delete the + # originals first to simulate an install clobbering them, forcing rollback + # to rely entirely on the backups. + def mock_download_fail(self, ext_id): + plan_file.unlink() + tasks_file.unlink() + raise Exception("Download failed") + + monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail) + monkeypatch.setattr("typer.confirm", lambda _: True) + + result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) + + assert result.exit_code == 1 + # Rollback must restore EACH skill's own content, not a single collided copy. + assert plan_file.exists() and tasks_file.exists() + assert plan_file.read_text() == "PLAN CONTENT" + assert tasks_file.read_text() == "TASKS CONTENT" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 36f0818e25..c60a7e430f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4670,6 +4670,177 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): class TestExtensionAddCLI: """CLI integration tests for extension add command.""" + def test_catalog_add_escapes_url_markup(self, tmp_path): + """Catalog add should render user-supplied URLs literally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + url = "https://example.com/[red]catalog[/red].json" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + url, + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"URL: {url}" in result.output + + def test_catalog_add_escapes_config_saved_path_markup(self, tmp_path): + """Catalog add's saved-path label should render literally under Rich.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + display_path = "project[red]/.specify/extension-catalogs.yml" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"Config saved to {display_path}" in result.output + + def test_catalog_list_escapes_config_path_markup(self, tmp_path): + """Catalog list's config-path label should render literally under Rich.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import yaml + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": "community", + "url": "https://example.com/catalog.json", + "priority": 10, + "install_allowed": False, + } + ] + } + ), + encoding="utf-8", + ) + + display_path = "project[red]/.specify/extension-catalogs.yml" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): + result = runner.invoke( + app, + ["extension", "catalog", "list"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"Config: {display_path}" in result.output + + def test_catalog_add_escapes_config_read_exception_markup(self, tmp_path): + """Catalog config parse errors can include user-controlled file content.""" + import yaml + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text("[red]bad[/red]", encoding="utf-8") + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch( + "specify_cli.extensions._commands.yaml.safe_load", + side_effect=yaml.YAMLError("bad [red]catalog[/red] yaml"), + ): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "bad [red]catalog[/red]" in result.output + assert "yaml" in result.output + + def test_catalog_add_escapes_url_validation_exception_markup(self, tmp_path): + """URL validation errors may include user-controlled URL text.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, + "_validate_catalog_url", + side_effect=ValidationError("bad [red]url[/red]"), + ): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/[red]catalog[/red].json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "bad [red]url[/red]" in result.output + def test_add_dev_links_copilot_agent_when_supported( self, extension_dir, project_dir, temp_dir ): @@ -4883,6 +5054,85 @@ def record_status(*args, **kwargs): f"confirm must precede spinner, got: {call_order}" assert result.exit_code == 0 # user declined → clean exit + def test_add_status_escapes_extension_markup(self, tmp_path): + """User-controlled extension names must not be parsed as Rich markup.""" + from rich.markup import escape as escape_markup + from typer.testing import CliRunner + from unittest.mock import MagicMock, patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + status_messages: list[str] = [] + + def record_status(message, *args, **kwargs): + status_messages.append(message) + return MagicMock() + + extension_name = "[red]bad[/red]" + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.console.status", side_effect=record_status): + result = runner.invoke( + app, + ["extension", "add", extension_name, "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert status_messages == [ + f"[cyan]Installing extension: {escape_markup(extension_name)}[/cyan]" + ] + + def test_add_post_install_hint_escapes_manifest_id_markup(self, tmp_path): + """Extension IDs printed in Rich-rendered hints must stay literal.""" + import io + from types import SimpleNamespace + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manifest_id = "[red]bad[/red]" + + def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False): + return SimpleNamespace( + id=manifest_id, + name="Bad Extension", + version="1.0.0", + description="Test extension", + warnings=[], + commands=[], + hooks=[], + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ + patch.object(ExtensionRegistry, "get", return_value={}): + result = runner.invoke( + app, + ["extension", "add", "bad", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert ".specify/extensions/[red]bad[/red]/" in result.output + def test_add_from_url_cancel_exits_cleanly(self, tmp_path): """Declining the --from confirmation should exit with code 0.""" from typer.testing import CliRunner @@ -4905,6 +5155,131 @@ def test_add_from_url_cancel_exits_cleanly(self, tmp_path): assert result.exit_code == 0 assert "Cancelled" in result.output + def test_add_from_url_escapes_download_exception_markup(self, tmp_path): + """Download errors can include user-controlled URL text.""" + import urllib.error + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch( + "specify_cli.authentication.http.open_url", + side_effect=urllib.error.URLError("bad [red]download[/red]"), + ): + result = runner.invoke( + app, + [ + "extension", + "add", + "my-ext", + "--from", + "https://example.com/[red]ext[/red].zip", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "https://example.com/[red]ext[/red].zip" in result.output + assert "bad [red]download[/red]" in result.output + + @pytest.mark.parametrize( + ("exc_type", "label"), + [ + (ValidationError, "Validation Error"), + (CompatibilityError, "Compatibility Error"), + (ExtensionError, "Error"), + ], + ) + def test_add_exception_handlers_escape_markup(self, tmp_path, exc_type, label): + """Extension install exceptions can include manifest-controlled values.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + (ext_dir / "extension.yml").write_text("extension:\n id: test\n", encoding="utf-8") + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionManager, + "install_from_directory", + side_effect=exc_type("bad [red]extension[/red]"), + ): + result = runner.invoke( + app, + ["extension", "add", str(ext_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert f"{label}:" in result.output + assert "bad [red]extension[/red]" in result.output + + def test_add_from_url_uses_cache_tempfile_for_untrusted_extension_name(self, tmp_path): + """The extension argument must not control the downloaded ZIP path.""" + import io + from types import SimpleNamespace + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads" + installed = {} + + def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False): + captured_path = Path(zip_path) + installed["zip_path"] = captured_path + installed["zip_bytes"] = captured_path.read_bytes() + return SimpleNamespace( + id="escape", + name="Escape Test", + version="1.0.0", + description="Test extension", + warnings=[], + commands=[], + hooks=[], + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = runner.invoke( + app, + ["extension", "add", "../outside", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0 + assert installed["zip_bytes"] == b"zip-bytes" + assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve()) + assert installed["zip_path"].name.startswith("extension-url-download-") + assert not installed["zip_path"].exists() + class TestDownloadExtensionBundled: """Tests for download_extension handling of bundled extensions.""" @@ -5169,6 +5544,62 @@ def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, m for cmd_file in command_files: assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" + @pytest.mark.parametrize( + ("manifest_text", "expected_detail"), + [ + ("- not\n- a\n- mapping\n", "YAML mapping"), + ("extension: []\n", "'extension' mapping"), + ], + ) + def test_update_rejects_malformed_zip_manifest( + self, tmp_path, monkeypatch, manifest_text, expected_detail + ): + """Downloaded extension.yml shape must be valid before ID validation.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import zipfile + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").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") + original_registry_entry = manager.registry.get("test-ext") + + zip_path = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("extension.yml", manifest_text) + + 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): + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "Invalid extension manifest in downloaded archive" in result.output + assert expected_detail in result.output + assert "AttributeError" not in result.output + assert ExtensionManager(project_dir).registry.get("test-ext") == original_registry_entry + class TestExtensionListCLI: """Test extension list CLI output format.""" @@ -6263,6 +6694,118 @@ def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): assert "2 commands" in result.output + def test_remove_output_escapes_extension_id_markup(self, tmp_path): + """Removal paths and reinstall hints must not parse extension IDs as markup.""" + from types import SimpleNamespace + 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() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": True, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionManager, "get_extension", return_value=SimpleNamespace(commands=[])), \ + patch.object(ExtensionRegistry, "get", return_value={"registered_commands": {}, "registered_skills": []}), \ + patch.object(ExtensionManager, "remove", return_value=True): + result = runner.invoke( + app, + ["extension", "remove", extension_id, "--force"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert ".specify/extensions/.backup/[red]bad[/red]/" in result.output + assert "specify extension add [red]bad[/red]" in result.output + + +class TestExtensionStateCLI: + """CLI tests for installed extension state commands.""" + + def test_enable_registry_error_escapes_extension_id_markup(self, tmp_path): + """Registry-corruption errors should render extension IDs literally.""" + 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() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": False, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionRegistry, "get", return_value=None): + result = runner.invoke( + app, + ["extension", "enable", extension_id], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "Extension '[red]bad[/red]' not found in registry" in result.output + + def test_disable_reenable_hint_escapes_extension_id_markup(self, tmp_path): + """Disable success hints should not parse extension IDs as markup.""" + 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() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": True, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionRegistry, "get", return_value={"enabled": True}), \ + patch.object(ExtensionRegistry, "update", return_value=None), \ + patch.object(HookExecutor, "get_project_config", return_value={}): + result = runner.invoke( + app, + ["extension", "disable", extension_id], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "specify extension enable [red]bad[/red]" in result.output + class TestClineExtensionHyphenation: """Test that Cline integration uses hyphenated commands and frontmatter references.""" From a233f3a67bd08b8189bb2e914c99a299d492dbf9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:58:55 -0500 Subject: [PATCH 25/42] feat: add PyPI publishing workflow and readme metadata (#2915) * feat: add PyPI publishing workflow and readme metadata - Add readme = "README.md" to pyproject.toml for PyPI project description - Add manual publish-pypi.yml workflow using trusted publishers (OIDC) - Update release.yml install instructions to prefer PyPI The publish workflow is manually triggered after a release, checks out the specified tag, verifies version consistency, builds with uv, and publishes using trusted publishing (no API tokens required). Prerequisites before first use: - Take ownership of the specify-cli PyPI project (#2908) - Create a 'pypi' environment in repo settings - Configure trusted publisher on PyPI for this repo/workflow Closes #2908 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review feedback on publish workflow - Add actions: read permission (required for artifact upload/download) - Move version check after uv install and use uv run python (ensures Python >=3.11 with tomllib is available regardless of runner image) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use absolute URLs for README images (PyPI compatibility) PyPI does not host images from the repository, so relative paths like ./media/logo.webp render as broken images. Switch to absolute raw.githubusercontent.com URLs so images display on both GitHub and PyPI. Ref: https://github.com/pypi/warehouse/issues/5246 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address second review round - Convert remaining /media/ image path to absolute URL for PyPI - Pin release install to specific version (specify-cli==X.Y.Z) - Align setup-uv to v8.2.0 matching rest of CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address third review round - Use job-level permissions: actions:write on build (for upload-artifact), actions:read on publish (for download-artifact) - Include both @latest and pinned version in release notes - Add note that PyPI may lag behind the GitHub release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add contents:read to build job, clarify manual publish - Build job needs contents:read for checkout (job-level perms replace workflow-level) - Clarify that PyPI publishing is manually triggered, not automatic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: force tag resolution and validate before checkout Move tag format validation before checkout and use refs/tags/ prefix to ensure we always check out a tag, not a branch with the same name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review - links, install cmd, python pin - Convert all relative .md links in README to absolute GitHub URLs for PyPI rendering compatibility - Fix release notes: use 'uv tool install specify-cli' (no @latest) - Pin Python 3.13 via uv python install for deterministic builds and use python3 directly instead of uv run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review - python setup, docs alignment, publish flag - Use actions/setup-python (pinned v6, Python 3.13) instead of uv python install for deterministic builds - Use python instead of python3 for setup-python compatibility - Remove unsupported --trusted-publishing always flag from uv publish (OIDC is auto-detected with id-token: write) - Update README install to lead with PyPI, source as fallback - Update installation guide: replace PyPI disclaimer with official package note, add PyPI as primary install method - Release notes: pin to exact version, clarify PyPI timing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: clarify PyPI availability timing in docs - README: note source install is useful when PyPI version lags - Installation guide: explain PyPI follows GitHub releases and may lag briefly; source installs are always immediately available Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: quote version specifier in release notes install command uv tool install accepts PEP 508 specifiers when quoted. Add quotes around 'specify-cli==VERSION' so users can copy-paste directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use specify-cli@latest consistently Use @latest to force a fresh PyPI resolve (bypasses uv's cached tool version), matching the issue acceptance criteria. Source install remains as fallback when PyPI lags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: pin release notes to exact version, clarify manual publish Release notes (versioned changelog) must always reference the specific release version, not @latest. Use 'specify-cli==VERSION' for reproducibility. Also clarify that PyPI publishing is 'performed after' (not 'follows') each release, making the manual nature clearer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: keep source install as primary, PyPI as alternative Until PyPI ownership is fully transferred and first publish is confirmed, source installs from GitHub remain the primary recommended method. PyPI install is listed as a convenient alternative. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align checkout pin, soften PyPI wording, absolute links - Align actions/checkout to v7.0.0 (same SHA as test.yml/release.yml) - Remove assertion that PyPI is published by maintainers (ownership transfer still pending); keep as availability statement - Use 'once published for this release' wording in release notes - Convert remaining relative links in README to absolute URLs for PyPI rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align docs and release notes with pre-transfer state - docs/installation.md: qualify PyPI as available 'once official publishing is enabled' (ownership transfer still pending) - release.yml: use specify-cli@VERSION syntax (consistent with README/docs @latest form) - PR description updated to match Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: revert release notes to match main The release.yml release notes template should not change in this PR. PyPI install instructions can be added to release notes in a future PR once publishing is confirmed working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: revert README and installation docs to match main Do not mention PyPI in documentation until the first official PyPI release has been published. This PR only adds the workflow and readme metadata in pyproject.toml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fail fast if build produces no artifacts Add if-no-files-found: error to upload-artifact so a missing/empty dist/ directory fails the build job immediately rather than causing a confusing failure in the publish job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: align artifact action pins with repo lockfiles Update upload-artifact to v7.0.1 and download-artifact to v8.0.1, matching the pins used in the repo's gh-aw workflow lockfiles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-pypi.yml | 80 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 81 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000000..a3bfc8fbeb --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,80 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish (e.g., v0.10.1)' + required: true + type: string + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Verify tag format + run: | + TAG="${{ inputs.tag }}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$TAG' is not a valid release tag (expected vX.Y.Z)" + exit 1 + fi + + - name: Checkout release tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: refs/tags/${{ inputs.tag }} + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.13" + + - name: Verify tag matches package version + run: | + TAG_VERSION="${{ inputs.tag }}" + TAG_VERSION="${TAG_VERSION#v}" + PROJECT_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then + echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PROJECT_VERSION)" + exit 1 + fi + + - name: Build package + run: uv build + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + actions: read + steps: + - name: Download build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Publish to PyPI + run: uv publish diff --git a/pyproject.toml b/pyproject.toml index 29f1e0245e..b336dd2726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "specify-cli" version = "0.11.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." +readme = "README.md" requires-python = ">=3.11" dependencies = [ "typer>=0.24.0", From 5224f33d7dd5f5092db311af231fa092ffdae8ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:15:28 -0500 Subject: [PATCH 26/42] Update Game Narrative Writing preset to v1.1.0 (#3099) Update game-narrative-writing preset submitted by @adaumann: - presets/catalog.community.json (version, download_url, description, provides, tags, updated_at) - docs/community/presets.md community presets table Closes #3096 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 30 +++++++++++------------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index d2c491a364..5e6d27e9cd 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -17,7 +17,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | 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 principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | -| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | +| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | | iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 7186873825..1c9e628eb7 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-16T00:00:00Z", + "updated_at": "2026-06-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -308,11 +308,11 @@ "game-narrative-writing": { "name": "Game Narrative Writing", "id": "game-narrative-writing", - "version": "1.0.0", - "description": "Spec-Driven Development for interactive game-narrative pre-production in video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture.", + "version": "1.1.0", + "description": "Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-game-narrative-writing", - "download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/archive/refs/tags/v1.0.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/releases/download/v1.1.0/v1.1.0-import.zip", "homepage": "https://github.com/adaumann/speckit-preset-game-narrative-writing", "documentation": "https://github.com/adaumann/speckit-preset-game-narrative-writing/blob/main/game-narrative-writing/README.md", "license": "MIT", @@ -320,27 +320,19 @@ "speckit_version": ">=0.5.0" }, "provides": { - "templates": 22, - "commands": 36, - "scripts": 2 + "templates": 37, + "commands": 34, + "scripts": 5 }, "tags": [ - "game-writing", "interactive-fiction", - "twine", - "ink", - "renpy", - "point-and-click", - "branching-narrative", - "choice-if", - "visual-novel", - "mechanic-hooks", "game-narrative", - "export", - "series" + "branching", + "twine", + "ink" ], "created_at": "2026-05-05T08:00:00Z", - "updated_at": "2026-05-05T08:00:00Z" + "updated_at": "2026-06-22T00:00:00Z" }, "isaqb-architecture-governance": { "name": "iSAQB Architecture Governance", From afe7657d2cbcc7c70ea9992484e1b30fdf64525b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:10:00 -0500 Subject: [PATCH 27/42] Add SicarioSpec Core preset to community catalog (#3102) Add sicario-core preset submitted by @SiCar10mw to: - presets/catalog.community.json (alphabetical order) - docs/community/presets.md community presets table Closes #3101 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/community/presets.md b/docs/community/presets.md index 5e6d27e9cd..750abc0809 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -25,6 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. 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 | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | +| SicarioSpec Core | Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | 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 1c9e628eb7..eb751f0997 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -564,6 +564,34 @@ "created_at": "2026-04-27T00:00:00Z", "updated_at": "2026-06-14T00:00:00Z" }, + "sicario-core": { + "name": "SicarioSpec Core", + "id": "sicario-core", + "version": "0.4.0", + "description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.", + "author": "SicarioSpec Contributors", + "repository": "https://github.com/dfirs1car1o/sicario-spec", + "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip", + "homepage": "https://github.com/dfirs1car1o/sicario-spec", + "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "templates": 5, + "commands": 0 + }, + "tags": [ + "security", + "governance", + "security-ops", + "secure-by-default", + "evidence" + ], + "created_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-22T00:00:00Z" + }, "spec2cloud": { "name": "Spec2Cloud", "id": "spec2cloud", From ce01877610b79e4ec9a2c25b64d15cbd3fbc70f0 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Tue, 23 Jun 2026 00:48:55 +0200 Subject: [PATCH 28/42] fix: register enabled extensions for agent on integration use/upgrade (#2949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: register enabled extensions for agent on integration install/upgrade install and upgrade only set up the integration's own core commands; only switch re-registered the enabled extensions' commands for the target agent. A second integration added via install (or refreshed via upgrade) was therefore silently missing the extension commands the existing agents already had (e.g. the bundled agent-context extension). Extract switch's registration into a shared _register_extensions_for_agent helper and call it from install and upgrade too, so every installed agent ends up with every enabled extension's commands — full parity with switch. Closes #2886 * test: pin skills-mode secondary-agent registration; document #2948 limitation Extension skill rendering is scoped to the active agent (init-options track a single ai / ai_skills pair), so a skills-mode agent registered while not active (e.g. Copilot --skills installed as a secondary integration) gets command files rather than skills. install/upgrade match extension add here; only switch renders skills, because it activates the target first. Add a regression test pinning this behavior and document the limitation on the shared helper. Per-agent skills parity is tracked separately in #2948. * fix: don't re-render the active agent's skills when registering a non-active agent register_enabled_extensions_for_agent runs an active-agent-scoped skills pass (_register_extension_skills resolves the skills dir from init-options["ai"], ignoring the passed agent). Routing install/upgrade of a secondary integration through it re-rendered the *active* skills-mode agent's extension skills as a side effect — resurrecting skill files the user had deliberately deleted. Gate the skills pass on the target being the active agent; switch is unaffected because it activates the target first. Also harden the skills-mode install test (assert a core skill so --skills is load-bearing, drop a vacuous registered_skills assertion) and add a regression test. Surfaced by review of the PR; skills parity for non-active agents stays tracked in #2948. * refactor: share the extension-op scaffold and run (un)registration post-commit Review cleanups, no behavior change on the success path: - Extract the best-effort ExtensionManager scaffold (lazy import, instantiate, except -> _print_cli_warning) into _best_effort_extension_op. Both _register_extensions_for_agent and a new _unregister_extensions_for_agent delegate to it, removing the duplicate block left inline in switch. - Invoke the best-effort extension registration AFTER the install/switch/upgrade try/except has committed, so a failure in it can never trigger the rollback (install and switch teardown on except). * docs: clarify extension registration parity scope * fix(integrations): defer extension registration until use * fix(tests): remove redundant shutil import * fix(integrations): backfill extensions for installed switch targets --- src/specify_cli/extensions/__init__.py | 79 ++--- src/specify_cli/integrations/_helpers.py | 89 +++++- .../integrations/_migrate_commands.py | 65 +++-- .../integrations/_query_commands.py | 6 + .../test_integration_subcommand.py | 269 +++++++++++++++++- 5 files changed, 437 insertions(+), 71 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e64d856a7e..19cc0f0910 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1678,16 +1678,12 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: def register_enabled_extensions_for_agent(self, agent_name: str) -> None: """Register installed, enabled extensions for ``agent_name``. - This is intended to be called after switching integrations. Command - registration is scoped to the explicit ``agent_name`` argument, but some - behavior still depends on the current init-options state (for example, - skills-mode handling uses the active ``ai`` / ``ai_skills`` settings). - - Callers should therefore pass the agent that has just been made active - in init-options; in normal use, ``agent_name`` is expected to match the - current ``ai`` value. This mirrors extension install behavior while - avoiding stale default-mode command directories when that active agent - is running in skills mode (notably Copilot ``--skills``). + Command-file registration is scoped to the explicit ``agent_name`` + argument, so this method can be used after install, upgrade, or switch. + Extension skill rendering is still scoped to the active ``ai`` / + ``ai_skills`` settings in init-options, so non-active skills-mode + targets receive command files here. Per-agent skills parity is tracked + separately in #2948. """ if not agent_name: return @@ -1744,31 +1740,46 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: if new_registered != registered_commands: updates["registered_commands"] = new_registered - try: - registered_skills = self._register_extension_skills(manifest, ext_dir) - except Exception as skills_err: - # Skills are a companion artifact. If command registration - # already succeeded, still persist it so later cleanup can - # find those command files. - from .. import _print_cli_warning - - _print_cli_warning( - "register extension skills for", - "extension", - ext_id, - skills_err, - continuing=( - "Continuing with available registration results for this " - "extension and the remaining extensions." - ), - ) - else: - if registered_skills: - existing_skills = self._valid_name_list( - metadata.get("registered_skills", []) + # Extension *skills* are only ever rendered for the active agent: + # `_register_extension_skills` resolves the skills dir and + # frontmatter from init-options["ai"], ignoring ``agent_name``. + # When this method runs for a non-active agent — as install/upgrade + # now do for a secondary integration (#2886) — the skills pass would + # re-render the *active* agent's extension skills as a side effect, + # resurrecting skill files the user deliberately deleted. Skip it + # unless the target is the active agent; `switch` is unaffected + # because it activates the target before registering. (Rendering + # skills for a non-active target is tracked separately in #2948.) + if agent_name == active_agent: + try: + registered_skills = self._register_extension_skills( + manifest, ext_dir + ) + except Exception as skills_err: + # Skills are a companion artifact. If command registration + # already succeeded, still persist it so later cleanup can + # find those command files. + from .. import _print_cli_warning + + _print_cli_warning( + "register extension skills for", + "extension", + ext_id, + skills_err, + continuing=( + "Continuing with available registration results for this " + "extension and the remaining extensions." + ), ) - merged_skills = list(dict.fromkeys(existing_skills + registered_skills)) - updates["registered_skills"] = merged_skills + else: + if registered_skills: + existing_skills = self._valid_name_list( + metadata.get("registered_skills", []) + ) + merged_skills = list( + dict.fromkeys(existing_skills + registered_skills) + ) + updates["registered_skills"] = merged_skills if updates: self.registry.update(ext_id, updates) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index c48cbad125..f8a696a866 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -3,7 +3,7 @@ import os from pathlib import Path -from typing import Any +from typing import Any, Callable import typer @@ -387,6 +387,93 @@ def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None: raise typer.Exit(1) +# --------------------------------------------------------------------------- +# Extension (un)registration helpers (shared by use / switch / upgrade) +# --------------------------------------------------------------------------- + +def _best_effort_extension_op( + project_root: Path, + agent_key: str, + op: Callable[[Any, str], None], + *, + phase: str, + continuing: str, +) -> None: + """Run a best-effort ``ExtensionManager`` operation for ``agent_key``. + + ``op`` receives the ``ExtensionManager`` and ``agent_key``. Any failure is + surfaced as a warning via ``_print_cli_warning`` and never aborts the + surrounding integration operation. ``continuing`` describes what already + succeeded so the warning makes the partial outcome clear. + """ + try: + from ..extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + op(ext_mgr, agent_key) + except Exception as ext_err: + from .. import _print_cli_warning + + _print_cli_warning(phase, "integration", agent_key, ext_err, continuing=continuing) + + +def _register_extensions_for_agent( + project_root: Path, + agent_key: str, + *, + continuing: str, +) -> None: + """Register all enabled extensions' commands/skills for ``agent_key``. + + ``use`` / ``switch`` re-register enabled extensions for the agent they + activate; ``upgrade`` backfills them for the refreshed agent. Plain + ``install`` deliberately does not call this helper so adding a secondary + integration has no extension side effects until it is selected or upgraded. + See issue #2886. + + Known limitation: extension *skill* rendering is scoped to the active + agent (init-options track a single ``ai`` / ``ai_skills`` pair). A + skills-mode agent registered while it is *not* the active agent (e.g. + Copilot ``--skills`` registered while non-active) therefore + receives command files rather than skills here — matching ``extension + add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they + make the target the active agent first. Per-agent skills parity is tracked in + #2948. + + Best-effort: never aborts the surrounding integration operation. Callers + invoke it *after* the use/upgrade/switch transaction has committed so a + failure here cannot trigger a rollback. + """ + _best_effort_extension_op( + project_root, + agent_key, + lambda mgr, key: mgr.register_enabled_extensions_for_agent(key), + phase="register extension artifacts for", + continuing=continuing, + ) + + +def _unregister_extensions_for_agent( + project_root: Path, + agent_key: str, + *, + continuing: str, +) -> None: + """Best-effort removal of ``agent_key``'s extension artifacts. + + Used by ``switch`` when uninstalling the previous integration so its + extension command/skill files don't linger as orphans in the old agent's + directory. + """ + _best_effort_extension_op( + project_root, + agent_key, + lambda mgr, key: mgr.unregister_agent_artifacts(key), + phase="clean up extension artifacts for", + continuing=continuing, + ) + + # --------------------------------------------------------------------------- # CLI formatting helpers (re-exported from _commands.py) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 06c51bd1ec..6568d1af18 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -27,12 +27,14 @@ _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, + _register_extensions_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_integration_script_type, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, + _unregister_extensions_for_agent, _update_init_options_for_integration, _write_integration_json, ) @@ -120,6 +122,14 @@ def integration_switch( parsed_options=parsed_options, refresh_templates_force=force, ) + _register_extensions_for_agent( + project_root, + target, + continuing=( + "The integration switch succeeded, but installed extensions may " + "need re-registration." + ), + ) console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") raise typer.Exit(0) @@ -171,19 +181,11 @@ def integration_switch( # Unregister extension commands for the old agent so they don't # remain as orphans in the old agent's directory. - try: - from ..extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.unregister_agent_artifacts(installed_key) - except Exception as ext_err: - _print_cli_warning( - "clean up extension artifacts for", - "integration", - installed_key, - ext_err, - continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", - ) + _unregister_extensions_for_agent( + project_root, + installed_key, + continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", + ) # Clear metadata so a failed Phase 2 doesn't leave stale references installed_keys = [installed for installed in installed_keys if installed != installed_key] @@ -270,22 +272,6 @@ def integration_switch( parsed_options=parsed_options, ) - # Re-register extension commands for the new agent so that - # previously-installed extensions are available in the new integration. - try: - from ..extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.register_enabled_extensions_for_agent(target) - except Exception as ext_err: - _print_cli_warning( - "register extension artifacts for", - "integration", - target, - ext_err, - continuing="The integration switch succeeded, but installed extensions may need re-registration.", - ) - except Exception as exc: # Attempt rollback of any files written by setup try: @@ -333,6 +319,15 @@ def integration_switch( ) raise typer.Exit(1) + # Re-register extension commands for the new agent so previously-installed + # extensions are available in it. Done after the try/except (the switch has + # committed) so this best-effort step can never trigger the rollback above. + _register_extensions_for_agent( + project_root, + target, + continuing="The integration switch succeeded, but installed extensions may need re-registration.", + ) + name = (target_integration.config or {}).get("name", target) console.print(f"\n[green]✓[/green] Switched to integration '{name}'") @@ -496,5 +491,17 @@ def integration_upgrade( if stale_removed: console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + # Re-register enabled extensions for the upgraded agent so its extension + # commands are (re)created — including agents installed before this + # back-fill existed. Mirrors switch for command registration; see #2886. + # Done after the upgrade has fully settled (Phase 2 included) and outside + # the try/except above so this best-effort step cannot affect upgrade + # success. + _register_extensions_for_agent( + project_root, + key, + continuing="The integration was upgraded, but installed extensions may need re-registration.", + ) + name = (integration.config or {}).get("name", key) console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py index dda74b0615..bb47e6142e 100644 --- a/src/specify_cli/integrations/_query_commands.py +++ b/src/specify_cli/integrations/_query_commands.py @@ -17,6 +17,7 @@ from ._commands import integration_app, integration_catalog_app from ._helpers import ( _read_integration_json, + _register_extensions_for_agent, _resolve_integration_options, _set_default_integration_or_exit, ) @@ -242,6 +243,11 @@ def integration_use( f"[cyan]specify integration use {key} --force[/cyan]." ), ) + _register_extensions_for_agent( + project_root, + key, + continuing="The integration was selected, but installed extensions may need re-registration.", + ) console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 985d8bce36..c3ebb9773d 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -15,19 +15,22 @@ runner = CliRunner() -def _init_project(tmp_path, integration="copilot"): +def _init_project(tmp_path, integration="copilot", integration_options=None): """Helper: init a spec-kit project with the given integration.""" project = tmp_path / "proj" project.mkdir() + args = [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--ignore-agent-tools", + ] + if integration_options: + args += ["--integration-options", integration_options] old_cwd = os.getcwd() try: os.chdir(project) - result = runner.invoke(app, [ - "init", "--here", - "--integration", integration, - "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) + result = runner.invoke(app, args, catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" @@ -1237,6 +1240,137 @@ def test_install_bare_project_gets_shared_infra(self, tmp_path): assert "/speckit-specify" in script_content assert "/speckit.specify" not in script_content + def test_install_defers_extension_commands_until_use(self, tmp_path): + """Installing a second integration does not register enabled extensions. + + Maintainer-requested behavior for #2886: extension command back-fill is + limited to ``integration use`` / ``switch`` / ``upgrade``. Plain + ``install`` only adds the integration; selecting it with ``use`` then + registers the enabled extensions for that agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered + assert "codex" not in registered, "precondition: codex not yet installed" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Install alone does not back-fill the git extension for the secondary + # agent. + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered, "existing agent registration preserved" + assert "codex" not in registered + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, "use should register extension commands (#2886)" + assert ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_install_does_not_register_disabled_extensions(self, tmp_path): + """A disabled extension must not be registered for a newly installed agent.""" + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + registry_path = project / ".specify" / "extensions" / ".registry" + git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"] + assert git_meta["enabled"] is False + assert "codex" not in git_meta["registered_commands"] + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path): + """A non-active skills-mode agent gets extension artifacts only on use. + + Plain ``install`` has no extension side effects. Once the secondary + Copilot ``--skills`` integration is selected with ``use``, it becomes the + active agent and receives extension skills. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Copilot is not multi_install_safe, so --force is required to add it + # alongside the existing default integration. + result = _run_in_project(project, [ + "integration", "install", "copilot", + "--script", "sh", + "--integration-options", "--skills", + "--force", + ]) + assert result.exit_code == 0, result.output + + # Precondition that makes --skills load-bearing: copilot IS in skills + # mode, so its own core commands are scaffolded as skills. + assert ( + project / ".github" / "skills" / "speckit-specify" / "SKILL.md" + ).exists(), "precondition: copilot installed in skills mode" + + # The git extension is not registered for the non-active copilot agent + # during install. + git_meta = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + )["extensions"]["git"] + assert "copilot" not in git_meta["registered_commands"] + assert not ( + project / ".github" / "agents" / "speckit.git.feature.agent.md" + ).exists() + assert not ( + project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + result = _run_in_project(project, ["integration", "use", "copilot"]) + assert result.exit_code == 0, result.output + + git_meta = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + )["extensions"]["git"] + # `use` makes copilot active, so extension artifacts follow copilot's + # skills-mode layout. + assert "copilot" not in git_meta["registered_commands"] + assert "speckit-git-feature" in git_meta["registered_skills"] + assert not ( + project / ".github" / "agents" / "speckit.git.feature.agent.md" + ).exists() + assert ( + project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + # ── uninstall ──────────────────────────────────────────────────────── @@ -1724,6 +1858,40 @@ def test_switch_migrates_extension_commands(self, tmp_path): assert "claude" in registered_commands assert "opencode" not in registered_commands + def test_switch_installed_target_backfills_extension_commands(self, tmp_path): + """Switching to an already-installed agent should register extensions.""" + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered + assert "codex" not in registered, "precondition: codex not yet installed" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + codex_git_feature = ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ) + assert not codex_git_feature.exists() + + result = _run_in_project(project, ["integration", "switch", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered + assert codex_git_feature.exists() + def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): """Copilot --skills should receive extension skills, not .agent.md files.""" project = _init_project(tmp_path, "opencode") @@ -2324,6 +2492,93 @@ def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): "shared .sh scripts must be executable after upgrade" ) + def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path): + """Upgrade re-registers enabled extensions for the upgraded agent. + + Regression for #2886: agents installed before extension back-fill + existed (or whose extension artifacts went missing) should regain the + enabled extensions' commands on ``upgrade``, reaching parity with + ``switch``. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Simulate a project created before the install/upgrade back-fill: drop + # codex's extension registration and its rendered artifacts. + registry_path = project / ".specify" / "extensions" / ".registry" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["extensions"]["git"]["registered_commands"].pop("codex", None) + registry_path.write_text(json.dumps(registry), encoding="utf-8") + agents_skills = project / ".agents" / "skills" + for skill_dir in agents_skills.glob("speckit-git-*"): + shutil.rmtree(skill_dir) + + # Precondition: codex is now missing the git extension. + assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists() + + result = _run_in_project(project, [ + "integration", "upgrade", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Upgrade back-filled the git extension for codex. + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, "upgrade should re-register extension commands (#2886)" + assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists() + + def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path): + """Upgrading a non-active agent must not touch the active agent's skills. + + Regression for the #2886 wiring: extension skill rendering is + active-agent-scoped, so routing upgrade of a *secondary* agent through + ``register_enabled_extensions_for_agent`` used to re-render the + *active* skills-mode agent's extension skills as a side effect — + resurrecting skill files the user had deliberately deleted. The skills + pass is now gated on the target being the active agent. (Skills parity + for non-active agents is tracked separately in #2948.) + """ + # Active agent: copilot in skills mode → git extension renders as skills. + project = _init_project(tmp_path, "copilot", integration_options="--skills") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + assert skill.exists(), "precondition: active copilot has the git extension skill" + + # Add a secondary (non-active) agent; copilot is not multi_install_safe. + result = _run_in_project(project, [ + "integration", "install", "codex", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + # The user deliberately removes the active agent's git skill. + shutil.rmtree(skill.parent) + assert not skill.exists() + + # Upgrading the *non-active* agent must not re-render copilot's skills. + result = _run_in_project(project, [ + "integration", "upgrade", "codex", "--script", "sh", + ]) + assert result.exit_code == 0, result.output + assert not skill.exists(), ( + "upgrading a non-active agent must not resurrect the active agent's " + "deleted extension skill (#2886)" + ) + # ── Full lifecycle ─────────────────────────────────────────────────── From 3c11f4d90be55e410c6b706ff1a0c8fec38815a2 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:52:26 -0500 Subject: [PATCH 29/42] chore: release 0.11.5, begin 0.11.6.dev0 development (#3105) * chore: bump version to 0.11.5 * chore: begin 0.11.6.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 cbac946304..cf0a18ae82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.11.5] - 2026-06-22 + +### Changed + +- fix: register enabled extensions for agent on integration use/upgrade (#2949) +- Add SicarioSpec Core preset to community catalog (#3102) +- Update Game Narrative Writing preset to v1.1.0 (#3099) +- feat: add PyPI publishing workflow and readme metadata (#2915) +- refactor: move extension command handlers to extensions/_commands.py (PR-7/8) (#3014) +- feat: add ZCode (Z.AI) integration (#3063) +- fix(agent-context): support multiple context files safely (#2969) +- Update DocGuard — CDD Enforcement extension to v0.27.0 (#3094) +- fix(presets): use _repo_root() for bundled-core source-checkout fallback (#3086) (#3091) +- chore: release 0.11.4, begin 0.11.5.dev0 development (#3092) + ## [0.11.4] - 2026-06-22 ### Changed diff --git a/pyproject.toml b/pyproject.toml index b336dd2726..6a5099a9d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.5.dev0" +version = "0.11.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From e5a03bffc87290ba1fb19c7d40c512611333b233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:36:54 -0600 Subject: [PATCH 30/42] fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) install_shared_infra never removed shared scripts a prior (pre-refactor) install recorded but the current core no longer ships — e.g. the legacy scripts//update-agent-context.sh, superseded by the bundled agent-context extension. On a legacy project the orphan lingers and crashes when it sources a refreshed common.sh (HAS_GIT unbound under set -u). Apply the stale-removal that integration_upgrade already performs to install_shared_infra: manifest-tracked scripts the current bundle no longer produces are removed, but only managed copies (hash matches the manifest); user-customized files, symlinks, and recovered entries are preserved. Guarded so a missing/empty source can't trigger mass deletion, and the safe-destination check prevents unlinking through a symlinked ancestor. Add IntegrationManifest.remove(); drop the stale update-agent-context.sh reference in CONTRIBUTING.md. AI assistance: implemented with Claude Code (Anthropic); reviewed and validated locally (ruff clean, full suite 4176 passed, manual CLI repro). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(shared-infra): harden stale-cleanup per review (empty source + orphan manifest) - Set scripts_scanned only after a real source file is seen, so an empty variant source can't trigger mass deletion of tracked scripts. - Prune a stale manifest entry even when its file is already gone from disk, keeping the manifest consistent (previously left tracked forever). - Add a test for each edge case. Addresses the Copilot review comments on #3098. AI assistance: Claude Code (Anthropic), reviewed/validated locally (ruff clean, full suite 4178 passed). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(shared-infra): guard unsafe manifest keys in stale-cleanup (review) - Skip absolute / '..' manifest keys before any filesystem access in stale-cleanup, so a corrupted/hand-edited manifest can't make it touch paths outside the project root (mirrors IntegrationManifest.check_modified / uninstall). - Clarify the scripts_scanned comment: the safety hinge is that flag, not seen_rels (which also holds template paths). - Add a containment test: a traversal manifest key is skipped, its target untouched. Addresses the second round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4179 passed). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(manifest): make remove() reject absolute/.. keys like its siblings (review) IntegrationManifest.remove() now applies the same lexical validation and normalization as record_existing() / is_recovered(): absolute paths and '..' segments are rejected (return False) instead of being used verbatim as a key. Keeps the manifest API consistent. Adds tests (valid drop + no-op, absolute rejected, traversal rejected). Addresses the third round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4182 passed). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(shared-infra): validate stale-cleanup keys for containment, not just lexically (review) The stale-script cleanup guarded manifest keys with a lexical check only (is_absolute() / ".." segments). On Windows a drive-relative key such as "C:tmp\\file" is not is_absolute(), yet joining it onto the project path discards the root — so cleanup could stat/unlink outside the project before _ensure_safe_shared_destination raised, and a corrupted manifest key turned into an install-time hard failure (ValueError) instead of being skipped. Reuse the canonical containment helper (_validate_rel_path, the same one IntegrationManifest.is_recovered / remove use): after the fast lexical reject, resolve the join and confirm it stays within the project root; a key that still escapes is skipped, never unlinked, never fatal. Adds a regression test that forces _validate_rel_path to reject a managed key (portably simulating the Windows drive-relative escape) and asserts the install skips it without failing and still installs the real scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 2 +- src/specify_cli/integrations/manifest.py | 24 +++ src/specify_cli/shared_infra.py | 75 ++++++++- tests/integrations/test_cli.py | 200 +++++++++++++++++++++++ tests/integrations/test_manifest.py | 28 ++++ 5 files changed, 326 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 899dae258c..5cf5514a0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,7 +167,7 @@ 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 +- 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), 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 diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index 23121be013..58719e1469 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -232,6 +232,30 @@ def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> N # transition. ``discard`` is a no-op when the key is absent. self._recovered_files.discard(normalized) + def remove(self, rel_path: str | Path) -> bool: + """Drop *rel_path* from the tracked file set and any recovered marker. + + Operates purely on the manifest's recorded key; it does NOT touch the + file on disk. Returns ``True`` if an entry was present and removed. + Used to keep the manifest consistent after a caller deletes a stale + managed file that the current install no longer ships. + + Input is normalized through the same lexical pipeline as + ``record_existing`` / ``is_recovered``: absolute paths and paths + containing ``..`` segments are rejected (return ``False``) — such paths + can never be canonical manifest keys, so there is nothing to remove. + """ + rel = Path(rel_path) + if rel.is_absolute() or ".." in rel.parts: + return False + try: + abs_path = _validate_rel_path(rel, self.project_root) + normalized = abs_path.relative_to(self.project_root).as_posix() + except ValueError: + return False + self._recovered_files.discard(normalized) + return self._files.pop(normalized, None) is not None + # -- Querying --------------------------------------------------------- @property diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 0db8687058..83fa9d4205 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -304,7 +304,7 @@ def install_shared_infra( customization warning to tell the user which flag would overwrite their customizations. """ - from .integrations.manifest import _sha256 + from .integrations.manifest import _sha256, _validate_rel_path manifest = load_speckit_manifest(project_path, version=version, console=console) prior_hashes = dict(manifest.files) @@ -325,6 +325,11 @@ def _is_managed(rel: str, dst: Path) -> bool: symlinked_files: list[str] = [] planned_copies: list[tuple[Path, str, bytes, int]] = [] planned_templates: list[tuple[Path, str, str]] = [] + # Track every shared path the current bundle produces so we can detect + # manifest entries the core no longer ships (stale-script cleanup, #3076). + seen_rels: set[str] = set() + scripts_scanned = False + variant_dir = "bash" if script_type == "sh" else "powershell" def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: """Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" @@ -379,7 +384,6 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if scripts_src.is_dir(): dest_scripts = project_path / ".specify" / "scripts" if _ensure_or_bucket_dir(dest_scripts): - 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 @@ -387,10 +391,18 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: for src_path in variant_src.rglob("*"): if not src_path.is_file(): continue + # Mark scanned only once a real source file is seen. An + # empty (or symlink-skipped) variant keeps this False, so + # stale-cleanup is skipped — otherwise it would treat every + # tracked script as obsolete and delete it. (The safety + # hinge is this flag, not ``seen_rels``, which also holds + # template paths populated later.) + scripts_scanned = True rel_path = src_path.relative_to(variant_src) dst_path = dest_variant / rel_path rel = dst_path.relative_to(project_path).as_posix() + seen_rels.add(rel) if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False): continue write, bucket = _decide_overwrite(rel, dst_path) @@ -442,6 +454,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: dst = dest_templates / src.name rel = dst.relative_to(project_path).as_posix() + seen_rels.add(rel) if not _safe_dest_or_bucket(dst, rel): continue write, bucket = _decide_overwrite(rel, dst) @@ -521,5 +534,63 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if refresh_hint: console.print(refresh_hint) + # Remove stale managed scripts: paths a previous install recorded that the + # current core no longer ships — e.g. the legacy + # ``scripts//update-agent-context.sh`` superseded by the bundled + # agent-context extension. Left behind, such an orphan can crash when it + # sources a refreshed ``common.sh`` (#3076). Only run when the script source + # was actually scanned (so a missing/empty source never triggers mass + # deletion), scoped to the active variant, and only for *managed* copies — + # a user-customized file (hash diverges), a symlink, or a recovered entry is + # preserved by ``_is_managed``. + if scripts_scanned: + stale_removed: list[str] = [] + script_prefix = f".specify/scripts/{variant_dir}/" + for rel in list(prior_hashes): + if rel in seen_rels or not rel.startswith(script_prefix): + continue + # Guard corrupted/hand-edited manifest keys BEFORE any filesystem + # access: absolute, ``..``, or (on Windows) drive-relative keys such + # as ``C:tmp`` are not ``is_absolute()`` yet discard the project root + # when joined. The lexical check is a fast reject; ``_validate_rel_path`` + # resolves the join and confirms containment, catching the rest. A key + # that still escapes is *skipped*, never turned into an install-time + # hard failure. Mirrors IntegrationManifest.is_recovered / remove. + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + continue + try: + _validate_rel_path(rel_path, project_path) + except ValueError: + continue + dst = project_path / rel_path + # Already gone from disk but still tracked: drop the orphaned manifest + # entry so the manifest stays consistent (nothing to unlink). + if not dst.exists() and not dst.is_symlink(): + manifest.remove(rel) + continue + if not _is_managed(rel, dst): + continue # user-modified / symlink / recovered → preserve + # Never unlink through a symlinked ancestor (writes/deletes could + # escape the project root). The safe-destination check buckets such + # paths under ``symlinked_files`` and we leave them in place. + if not _safe_dest_or_bucket(dst, rel): + continue + try: + dst.unlink() + except OSError as exc: + console.print(f"[yellow]⚠[/yellow] could not remove stale {rel}: {exc}") + continue + manifest.remove(rel) + stale_removed.append(rel) + + if stale_removed: + console.print( + f"[yellow]⚠[/yellow] Removed {len(stale_removed)} obsolete shared " + "script(s) left by a previous install:" + ) + for path in stale_removed: + console.print(f" {path}") + manifest.save() return True diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 565b8c84be..be8aad2326 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -263,6 +263,206 @@ def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): assert (scripts_dir / "setup-plan.sh").exists() assert (templates_dir / "plan-template.md").exists() + def test_shared_infra_removes_stale_managed_script(self, tmp_path): + """A managed script the core no longer ships (e.g. the legacy + update-agent-context.sh, superseded by the agent-context extension) is + removed, and the manifest stops tracking it (#3076).""" + from specify_cli import _install_shared_infra + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "stale-test" + project.mkdir() + (project / ".specify").mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + # Legacy orphan the current bundle no longer ships, recorded in the + # manifest as a managed file (hash matches on disk) — a pre-refactor install. + stale_rel = ".specify/scripts/bash/update-agent-context.sh" + (scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8") + manifest = IntegrationManifest("speckit", project, version="test") + manifest.record_existing(stale_rel) + manifest.save() + + _install_shared_infra(project, "sh", force=False) + + # The orphan is gone and the manifest no longer tracks it. + assert not (scripts_dir / "update-agent-context.sh").exists() + refreshed = IntegrationManifest.load("speckit", project) + assert stale_rel not in refreshed.files + # Scripts the core DOES ship are installed and tracked. + assert (scripts_dir / "common.sh").exists() + assert ".specify/scripts/bash/common.sh" in refreshed.files + + def test_shared_infra_preserves_modified_stale_script(self, tmp_path): + """A user-modified stale script is preserved (hash diverges from the + managed baseline), never silently deleted (#3076).""" + from specify_cli import _install_shared_infra + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "stale-modified" + project.mkdir() + (project / ".specify").mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + stale = scripts_dir / "update-agent-context.sh" + stale.write_text("# original managed\n", encoding="utf-8") + manifest = IntegrationManifest("speckit", project, version="test") + manifest.record_existing(".specify/scripts/bash/update-agent-context.sh") + manifest.save() + + # User customizes it after install → on-disk hash now diverges. + stale.write_text("# user customization\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + # Preserved: it is no longer a managed (hash-matching) copy. + assert stale.exists() + assert stale.read_text(encoding="utf-8") == "# user customization\n" + + def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path): + """A stale manifest entry whose file is already gone from disk is pruned + so the manifest stays consistent, not left tracked forever (#3076 review).""" + from specify_cli import _install_shared_infra + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "orphan-entry" + project.mkdir() + (project / ".specify").mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + stale_rel = ".specify/scripts/bash/update-agent-context.sh" + stale = scripts_dir / "update-agent-context.sh" + stale.write_text("# legacy orphan\n", encoding="utf-8") + manifest = IntegrationManifest("speckit", project, version="test") + manifest.record_existing(stale_rel) + manifest.save() + # File removed out of band, but the manifest still tracks it. + stale.unlink() + + _install_shared_infra(project, "sh", force=False) + + refreshed = IntegrationManifest.load("speckit", project) + assert stale_rel not in refreshed.files + + def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch): + """If the bundle's script source dir exists but is empty, stale-cleanup + must NOT run (no source files seen → can't tell what's obsolete): a + previously-tracked script is preserved, never mass-deleted (#3076 review).""" + from specify_cli import _install_shared_infra, shared_infra + from specify_cli.integrations.manifest import IntegrationManifest + + # Point the script source at an empty ``bash/`` directory. + empty_src = tmp_path / "empty-bundle" / "scripts" + (empty_src / "bash").mkdir(parents=True) + monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src) + + project = tmp_path / "empty-source" + project.mkdir() + (project / ".specify").mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + tracked_rel = ".specify/scripts/bash/common.sh" + (scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8") + manifest = IntegrationManifest("speckit", project, version="test") + manifest.record_existing(tracked_rel) + manifest.save() + + _install_shared_infra(project, "sh", force=False) + + # Empty source → scripts_scanned stays False → nothing deleted. + assert (scripts_dir / "common.sh").exists() + refreshed = IntegrationManifest.load("speckit", project) + assert tracked_rel in refreshed.files + + def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path): + """A corrupted/hand-edited manifest key with a ``..`` segment is skipped + before any filesystem access — its traversal target is never deleted + (#3076 review, containment guard).""" + import hashlib + import json + from specify_cli import _install_shared_infra + + project = tmp_path / "unsafe-key" + project.mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + manifest_dir = project / ".specify" / "integrations" + manifest_dir.mkdir(parents=True) + + # A file the traversal key would resolve to (outside scripts/bash/). + victim = project / ".specify" / "scripts" / "keep-me.sh" + victim_bytes = b"# do not touch\n" + victim.write_bytes(victim_bytes) + + # Hand-crafted manifest: a key under the script prefix but with a ``..`` + # segment, with the *matching* hash so that — absent the containment guard + # — stale-cleanup would consider it managed and unlink the target. + traversal_key = ".specify/scripts/bash/../keep-me.sh" + (manifest_dir / "speckit.manifest.json").write_text( + json.dumps({ + "integration": "speckit", + "version": "test", + "files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()}, + }), + encoding="utf-8", + ) + + _install_shared_infra(project, "sh", force=False) + + # The unsafe key was skipped; its target file is untouched. + assert victim.exists() + assert victim.read_bytes() == victim_bytes + + def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing( + self, tmp_path, monkeypatch + ): + """A key that passes the lexical guard but escapes containment — e.g. a + Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards + the project root when joined — is skipped via ``_validate_rel_path``, never + unlinked, and never turned into an install-time hard failure (#3076 review + round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the + managed key, since real drive-relative paths only escape on Windows.""" + from specify_cli import _install_shared_infra + from specify_cli.integrations import manifest as manifest_mod + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "escaping-key" + project.mkdir() + (project / ".specify").mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + # A managed stale orphan that would normally be removed. + stale_rel = ".specify/scripts/bash/update-agent-context.sh" + stale = scripts_dir / "update-agent-context.sh" + stale.write_text("# legacy orphan\n", encoding="utf-8") + manifest = IntegrationManifest("speckit", project, version="test") + manifest.record_existing(stale_rel) + manifest.save() + + # Force the containment check to reject this key, as it would for a + # drive-relative escape on Windows. The cleanup must skip it gracefully. + real_validate = manifest_mod._validate_rel_path + + def fake_validate(rel, root): + if str(rel).endswith("update-agent-context.sh"): + raise ValueError("simulated drive-relative escape") + return real_validate(rel, root) + + monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate) + + # Must not raise (no install-time hard failure from a corrupted key). + _install_shared_infra(project, "sh", force=False) + + # The escaping key was skipped, so its file is left untouched... + assert stale.exists() + assert stale.read_text(encoding="utf-8") == "# legacy orphan\n" + # ...yet the install otherwise completed: real scripts are installed. + assert (scripts_dir / "common.sh").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 diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index 503ee133e5..32ff6efbdf 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -116,6 +116,34 @@ def test_uninstall_skips_traversal_paths(self, tmp_path): assert len(removed) == 1 assert removed[0].name == "safe.txt" + def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path): + (tmp_path / "f.txt").write_text("x", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + m.record_existing("f.txt") + assert "f.txt" in m.files + assert m.remove("f.txt") is True + assert "f.txt" not in m.files + assert m.remove("f.txt") is False # already gone → no-op + + def test_remove_rejects_absolute_path(self, tmp_path): + # Matches record_existing/is_recovered: an absolute key can never be a + # canonical manifest key, so remove() rejects it lexically and leaves + # the tracked entry untouched. + (tmp_path / "f.txt").write_text("x", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + m.record_existing("f.txt") + import sys + abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt" + assert m.remove(abs_input) is False + assert "f.txt" in m.files + + def test_remove_rejects_parent_traversal(self, tmp_path): + (tmp_path / "f.txt").write_text("x", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + m.record_existing("f.txt") + assert m.remove("../f.txt") is False + assert "f.txt" in m.files + class TestManifestCheckModified: def test_unmodified_file(self, tmp_path): From ac4f646144d1ba3472fbf523f2f592020c39968e Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:41:58 +0800 Subject: [PATCH 31/42] chore: sync issue template agent lists (#3052) * chore: sync issue template agent lists * test: harden agent template consistency check * test: harden agent template drift checks --------- Co-authored-by: root --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 40 ++++-- .github/ISSUE_TEMPLATE/feature_request.yml | 40 ++++-- tests/test_agent_config_consistency.py | 144 ++++++++++++++++++++- 4 files changed, 200 insertions(+), 26 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 1a44adec2d..f72491dff7 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, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI, Devin for Terminal + **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index dd09f8e02a..c1e1dc572c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -62,24 +62,40 @@ body: label: AI Agent description: Which AI agent are you using? options: + - Amp + - Antigravity + - Auggie CLI - Claude Code + - Cline + - CodeBuddy + - Codex CLI + - Cursor + - Devin for Terminal + - Forge - Gemini CLI - GitHub Copilot - - Cursor - - Qwen Code - - opencode - - Codex CLI - - Windsurf + - Goose + - Hermes Agent + - IBM Bob + - iFlow CLI + - Junie - Kilo Code - - Auggie CLI - - Roo Code - - CodeBuddy - - Qoder CLI + - Kimi Code - Kiro CLI - - Amp + - Lingma + - Mistral Vibe + - opencode + - Pi Coding Agent + - Qoder CLI + - Qwen Code + - Roo Code + - RovoDev ACLI - SHAI - - IBM Bob - - Antigravity + - Tabnine CLI + - Trae + - Windsurf + - ZCode + - Zed - Not applicable validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3b5889288b..3d55c0442c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -56,24 +56,40 @@ body: description: Does this feature relate to a specific AI agent? options: - All agents + - Amp + - Antigravity + - Auggie CLI - Claude Code + - Cline + - CodeBuddy + - Codex CLI + - Cursor + - Devin for Terminal + - Forge - Gemini CLI - GitHub Copilot - - Cursor - - Qwen Code - - opencode - - Codex CLI - - Windsurf + - Goose + - Hermes Agent + - IBM Bob + - iFlow CLI + - Junie - Kilo Code - - Auggie CLI - - Roo Code - - CodeBuddy - - Qoder CLI + - Kimi Code - Kiro CLI - - Amp + - Lingma + - Mistral Vibe + - opencode + - Pi Coding Agent + - Qoder CLI + - Qwen Code + - Roo Code + - RovoDev ACLI - SHAI - - IBM Bob - - Antigravity + - Tabnine CLI + - Trae + - Windsurf + - ZCode + - Zed - Not applicable - type: textarea diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 1176009778..ed16625d33 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,15 +1,157 @@ """Consistency checks for agent configuration across runtime surfaces.""" +import re from pathlib import Path +import yaml + from specify_cli import AGENT_CONFIG from specify_cli.extensions import CommandRegistrar REPO_ROOT = Path(__file__).resolve().parent.parent +ISSUE_TEMPLATE_AGENT_KEYS = [ + "amp", + "agy", + "auggie", + "claude", + "cline", + "codebuddy", + "codex", + "cursor-agent", + "devin", + "forge", + "gemini", + "copilot", + "goose", + "hermes", + "bob", + "iflow", + "junie", + "kilocode", + "kimi", + "kiro-cli", + "lingma", + "vibe", + "opencode", + "pi", + "qodercli", + "qwen", + "roo", + "rovodev", + "shai", + "tabnine", + "trae", + "windsurf", + "zcode", + "zed", +] + + +def _issue_template(path: str) -> dict: + return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8")) + + +def _body_item_by_id(template: dict, item_id: str) -> dict: + for item in template["body"]: + if item.get("id") == item_id: + return item + raise AssertionError(f"Expected issue template body item {item_id!r}") + + +def _dropdown_options(path: str, item_id: str) -> list[str]: + item = _body_item_by_id(_issue_template(path), item_id) + return item["attributes"]["options"] + + +def _normalized_markdown(text: str) -> str: + return " ".join(text.split()) + + +def _markdown_value_containing(path: str, marker: str) -> str: + template = _issue_template(path) + normalized_marker = _normalized_markdown(marker) + for item in template["body"]: + if item.get("type") != "markdown": + continue + value = item["attributes"]["value"] + if normalized_marker in _normalized_markdown(value): + return value + raise AssertionError(f"Expected issue template markdown containing {marker!r}") + + +def _markdown_paragraph_containing(path: str, marker: str) -> str: + value = _markdown_value_containing(path, marker) + normalized_marker = _normalized_markdown(marker) + for paragraph in re.split(r"\n\s*\n", value): + if normalized_marker in _normalized_markdown(paragraph): + return paragraph + raise AssertionError(f"Expected issue template paragraph containing {marker!r}") + + +def _supported_agent_names_from_agent_request_template() -> list[str]: + marker = "**Currently supported agents**:" + paragraph = _markdown_paragraph_containing( + ".github/ISSUE_TEMPLATE/agent_request.yml", + marker, + ) + supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip() + return [agent.strip() for agent in supported_agents_text.split(",")] + class TestAgentConfigConsistency: - """Ensure kiro-cli migration stays synchronized across key surfaces.""" + """Ensure agent configuration stays synchronized across key surfaces.""" + + def test_issue_template_agent_lists_match_runtime_integrations(self): + """GitHub issue templates should list all concrete built-in agents.""" + concrete_agent_keys = set(AGENT_CONFIG) - {"generic"} + issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS) + + missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys) + unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys) + duplicate_agent_keys = sorted( + key + for key in issue_template_agent_keys + if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1 + ) + assert not missing_agent_keys, ( + "Issue template agent list is missing AGENT_CONFIG keys: " + f"{missing_agent_keys}" + ) + assert not unexpected_agent_keys, ( + "Issue template agent list includes unknown AGENT_CONFIG keys: " + f"{unexpected_agent_keys}" + ) + assert not duplicate_agent_keys, ( + "Issue template agent list contains duplicate keys: " + f"{duplicate_agent_keys}" + ) + + issue_template_agent_names = [ + AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS + ] + assert "Generic (bring your own agent)" not in issue_template_agent_names + + bug_options = _dropdown_options( + ".github/ISSUE_TEMPLATE/bug_report.yml", + "ai-agent", + ) + assert bug_options == issue_template_agent_names + ["Not applicable"] + + feature_options = _dropdown_options( + ".github/ISSUE_TEMPLATE/feature_request.yml", + "ai-agent", + ) + assert feature_options == [ + "All agents", + *issue_template_agent_names, + "Not applicable", + ] + + assert ( + _supported_agent_names_from_agent_request_template() + == issue_template_agent_names + ) def test_runtime_config_uses_kiro_cli_and_removes_q(self): """AGENT_CONFIG should include kiro-cli and exclude legacy q.""" From 2bd97543cc7ebc47930d1b99095bf58dc054f786 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:46:19 -0500 Subject: [PATCH 32/42] =?UTF-8?q?Update=20DocGuard=20=E2=80=94=20CDD=20Enf?= =?UTF-8?q?orcement=20extension=20to=20v0.28.0=20(#3115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update docguard extension submitted by @raccioly: - extensions/catalog.community.json (version, download_url, updated_at) - docs/community/extensions.md community extensions table (no changes needed) Closes #3106 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 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 6a3835b09c..c3746cefa7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-23T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1006,8 +1006,8 @@ "id": "docguard", "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.", "author": "raccioly", - "version": "0.27.0", - "download_url": "https://github.com/raccioly/docguard/releases/download/v0.27.0/spec-kit-docguard-v0.27.0.zip", + "version": "0.28.0", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.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", @@ -1043,7 +1043,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-13T00:00:00Z", - "updated_at": "2026-06-22T00:00:00Z" + "updated_at": "2026-06-23T00:00:00Z" }, "doctor": { "name": "Project Health Check", From 0a126256e0a35c9c0edcb2acf53065f1a7a2aa6e Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:01:00 +0500 Subject: [PATCH 33/42] feat: add Firebender integration (Android Studio / IntelliJ) (#3077) * feat: add Firebender integration (Android Studio / IntelliJ) Firebender (https://firebender.com/) is an AI coding agent for Android Studio and IntelliJ. It reads project-local custom slash commands from .firebender/commands/*.mdc and project rules from .firebender/rules/*.mdc. Add a FirebenderIntegration (MarkdownIntegration) that installs the speckit command templates as .mdc command files and writes the managed context section into .firebender/rules/specify-rules.mdc. command_filename is overridden so init-time commands also use the .mdc extension Firebender requires. Register it in the integration registry, add the catalog entry and docs row, and add an integration test covering the .mdc command output. Closes #1548 Co-Authored-By: Claude Opus 4.8 (1M context) * feat: address review - bump catalog updated_at and list firebender as multi-install safe Bump the catalog top-level updated_at to reflect the new entry, and add firebender (with its .firebender/commands + .firebender/rules/specify-rules.mdc isolation paths) to the 'currently declared multi-install safe integrations' table in the docs. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/reference/integrations.md | 2 + integrations/catalog.json | 11 ++++- src/specify_cli/integrations/__init__.py | 2 + .../integrations/firebender/__init__.py | 33 ++++++++++++++ .../test_integration_firebender.py | 45 +++++++++++++++++++ tests/integrations/test_registry.py | 2 +- 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/integrations/firebender/__init__.py create mode 100644 tests/integrations/test_integration_firebender.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 1fe4a53640..a04e9db1d9 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -15,6 +15,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [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` | | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | | [Forge](https://forgecode.dev/) | `forge` | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | | [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | @@ -185,6 +186,7 @@ The currently declared multi-install safe integrations are: | `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` | | `codex` | `.agents/skills`, `AGENTS.md` | | `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` | +| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` | | `gemini` | `.gemini/commands`, `GEMINI.md` | | `iflow` | `.iflow/commands`, `IFLOW.md` | | `junie` | `.junie/commands`, `.junie/AGENTS.md` | diff --git a/integrations/catalog.json b/integrations/catalog.json index f89af37d5c..5e6862ec1b 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-02T00:00:00Z", + "updated_at": "2026-06-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -102,6 +102,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli"] }, + "firebender": { + "id": "firebender", + "name": "Firebender", + "version": "1.0.0", + "description": "Firebender IDE integration for Android Studio / IntelliJ", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, "forge": { "id": "forge", "name": "Forge", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index a81d705543..fe09468a76 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -58,6 +58,7 @@ def _register_builtins() -> None: from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration + from .firebender import FirebenderIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration from .generic import GenericIntegration @@ -95,6 +96,7 @@ def _register_builtins() -> None: _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(DevinIntegration()) + _register(FirebenderIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) diff --git a/src/specify_cli/integrations/firebender/__init__.py b/src/specify_cli/integrations/firebender/__init__.py new file mode 100644 index 0000000000..b49140b1f8 --- /dev/null +++ b/src/specify_cli/integrations/firebender/__init__.py @@ -0,0 +1,33 @@ +"""Firebender IDE integration. + +Firebender (https://firebender.com/) is an AI coding agent for Android Studio +and IntelliJ. It reads project-local custom slash commands from +``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``, +so Spec Kit installs its command templates as ``.mdc`` command files and writes +the managed context section into a ``.firebender/rules/`` rule file. +""" + +from ..base import MarkdownIntegration + + +class FirebenderIntegration(MarkdownIntegration): + key = "firebender" + config = { + "name": "Firebender", + "folder": ".firebender/", + "commands_subdir": "commands", + "install_url": "https://firebender.com/", + "requires_cli": False, + } + registrar_config = { + "dir": ".firebender/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".mdc", + } + context_file = ".firebender/rules/specify-rules.mdc" + multi_install_safe = True + + def command_filename(self, template_name: str) -> str: + """Firebender reads custom slash commands from ``.firebender/commands/*.mdc``.""" + return f"speckit.{template_name}.mdc" diff --git a/tests/integrations/test_integration_firebender.py b/tests/integrations/test_integration_firebender.py new file mode 100644 index 0000000000..b42d2fbf9d --- /dev/null +++ b/tests/integrations/test_integration_firebender.py @@ -0,0 +1,45 @@ +"""Tests for FirebenderIntegration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestFirebenderIntegration(MarkdownIntegrationTests): + KEY = "firebender" + FOLDER = ".firebender/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".firebender/commands" + CONTEXT_FILE = ".firebender/rules/specify-rules.mdc" + + # Firebender reads custom slash commands from ``.firebender/commands/*.mdc``, + # so this integration uses the ``.mdc`` extension instead of the ``.md`` + # default the base mixin assumes. Override the two extension-specific tests. + 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"] == ".mdc" + + 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(".mdc") + + def _expected_files(self, script_variant: str) -> list[str]: + # Firebender emits ``.mdc`` command files, so remap the base mixin's + # ``.md`` expectations for files under this integration's command dir. + cmd_dir = get_integration(self.KEY).registrar_config["dir"] + prefix = cmd_dir + "/" + return sorted( + f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f + for f in super()._expected_files(script_variant) + ) diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 7582bd6717..0110e19ec7 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -23,7 +23,7 @@ # Stage 3 — standard markdown integrations "claude", "qwen", "opencode", "junie", "kilocode", "auggie", "roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae", - "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", + "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", "firebender", # Stage 4 — TOML integrations "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations From 2344eafdd9021b3f59dc085850a077523751fa65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:16:15 -0500 Subject: [PATCH 34/42] Add Intake extension to community catalog (#3117) Add intake extension submitted by @bigsmartben to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3110 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 889b8424d6..9e63151cc1 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -57,6 +57,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | +| Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c3746cefa7..c56efee5ac 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1370,6 +1370,46 @@ "created_at": "2026-06-16T00:00:00Z", "updated_at": "2026-06-16T00:00:00Z" }, + "intake": { + "name": "Intake", + "id": "intake", + "description": "Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts.", + "author": "bigsmartben", + "version": "0.1.2", + "download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.2.zip", + "repository": "https://github.com/bigsmartben/spec-kit-intake", + "homepage": "https://github.com/bigsmartben/spec-kit-intake", + "documentation": "https://github.com/bigsmartben/spec-kit-intake/blob/main/README.md", + "changelog": "https://github.com/bigsmartben/spec-kit-intake/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.8.10.dev0", + "tools": [ + { + "name": "figma-mcp", + "required": false + } + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "intake", + "sdd", + "requirements", + "validation", + "figma" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-23T00:00:00Z", + "updated_at": "2026-06-23T00:00:00Z" + }, "issue": { "name": "GitHub Issues Integration 2", "id": "issue", From 3cfc81ff316b3ccdca2aa80500b300787d20212e Mon Sep 17 00:00:00 2001 From: YNan_varamor <270133796@163.com> Date: Tue, 23 Jun 2026 22:57:26 +0930 Subject: [PATCH 35/42] docs: clarify project-defined constitution articles (#2994) Co-authored-by: yann lei --- spec-driven.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec-driven.md b/spec-driven.md index 090071a3c4..28259ae28f 100644 --- a/spec-driven.md +++ b/spec-driven.md @@ -318,6 +318,12 @@ No implementation code shall be written before: This completely inverts traditional AI code generation. Instead of generating code and hoping it works, the LLM must first generate comprehensive tests that define behavior, get them approved, and only then generate implementation. +#### Articles IV, V & VI: Project-Defined Governance + +Articles IV, V, and VI are intentionally defined by each project's constitution rather than prescribed by Spec Kit. The constitution template provides placeholder slots and example concerns such as integration testing, observability, versioning, and breaking changes, but teams replace those placeholders with the principles that match their system and organization. + +This keeps the nine-article structure stable while allowing each project to encode its own non-negotiable standards. For one project, Article IV might govern security and access boundaries; for another, it might define integration test requirements. The `/speckit.analyze` command evaluates the concrete constitution in the project, so these project-defined articles participate in compliance checks just like the built-in examples. + #### Articles VII & VIII: Simplicity and Anti-Abstraction These paired articles combat over-engineering: From 8c85919f0f14e5309b06d684cba3f78626ce449a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:30:01 -0500 Subject: [PATCH 36/42] Update Architecture Workflow extension to v1.2.1 (#3118) Update arch extension submitted by @bigsmartben to: - extensions/catalog.community.json (version, download_url, description, provides.commands) - docs/community/extensions.md community extensions table Closes #3111 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 2 +- extensions/catalog.community.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 9e63151cc1..9566e7fc74 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -31,7 +31,7 @@ The following community-contributed extensions are available in [`catalog.commun | API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | | 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) | | Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | -| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) | +| Architecture Workflow | Generate or reverse project-level 4+1 architecture views as separate commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c56efee5ac..3edfff48f4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -187,10 +187,10 @@ "arch": { "name": "Architecture Workflow", "id": "arch", - "description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis", + "description": "Generate or reverse project-level 4+1 architecture views as separate commands", "author": "bigsmartben", - "version": "1.1.0", - "download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip", + "version": "1.2.1", + "download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.1.zip", "repository": "https://github.com/bigsmartben/spec-kit-arch", "homepage": "https://github.com/bigsmartben/spec-kit-arch", "documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md", @@ -202,7 +202,7 @@ "speckit_version": ">=0.8.10.dev0" }, "provides": { - "commands": 2, + "commands": 10, "hooks": 0 }, "tags": [ @@ -215,7 +215,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-14T00:00:00Z", - "updated_at": "2026-05-15T00:00:00Z" + "updated_at": "2026-06-23T00:00:00Z" }, "architect-preview": { "name": "Architect Impact Previewer", From a86ee0e8b6a6ec47e3e31c80137d051f55c8a1cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:30:21 -0500 Subject: [PATCH 37/42] Add Spec Kit Discovery Extension to community catalog (#3119) Add discovery extension submitted by @bigsmartben to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3113 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 9566e7fc74..6e9a254d86 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -111,6 +111,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) | | 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 Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) | | Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | | Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) | | Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3edfff48f4..304d21babf 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1001,6 +1001,40 @@ "created_at": "2026-04-08T00:00:00Z", "updated_at": "2026-04-08T00:00:00Z" }, + "discovery": { + "name": "Spec Kit Discovery Extension", + "id": "discovery", + "description": "Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation.", + "author": "bigsmartben", + "version": "0.2.0", + "download_url": "https://github.com/bigsmartben/spec-kit-discovery/archive/refs/tags/v0.2.0.zip", + "repository": "https://github.com/bigsmartben/spec-kit-discovery", + "homepage": "https://github.com/bigsmartben/spec-kit-discovery", + "documentation": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/docs/usage.md", + "changelog": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 6, + "hooks": 0 + }, + "tags": [ + "discovery", + "workflow", + "validation", + "feasibility", + "decision" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-23T00:00:00Z", + "updated_at": "2026-06-23T00:00:00Z" + }, "docguard": { "name": "DocGuard — CDD Enforcement", "id": "docguard", From 45423d6bc60848241e98cfce6fe35f62e9131798 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:32:16 -0500 Subject: [PATCH 38/42] [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116) * Update Spec Kit Preview extension to v1.1.0 Update preview extension submitted by @bigsmartben to: - extensions/catalog.community.json (version, name, description, download_url, commands, tags, updated_at) - docs/community/extensions.md community extensions table (name, description, alphabetical order) Closes #3109 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sync issue templates with firebender integration Assisted-by: GitHub Copilot (model: GPT-5, autonomous) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + docs/community/extensions.md | 2 +- extensions/catalog.community.json | 13 +++++++------ tests/test_agent_config_consistency.py | 1 + 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index f72491dff7..d9ed95eb55 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**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed + **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c1e1dc572c..59f7e9eaf8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -71,6 +71,7 @@ body: - Codex CLI - Cursor - Devin for Terminal + - Firebender - Forge - Gemini CLI - GitHub Copilot diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3d55c0442c..dc0e9b83c1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -65,6 +65,7 @@ body: - Codex CLI - Cursor - Devin for Terminal + - Firebender - Forge - Gemini CLI - GitHub Copilot diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 6e9a254d86..b30d796252 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -58,7 +58,6 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | | Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | -| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | 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) | @@ -112,6 +111,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 Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) | +| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) | | Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) | | Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 304d21babf..e72b5dc517 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2421,12 +2421,12 @@ "updated_at": "2026-03-18T00:00:00Z" }, "preview": { - "name": "Interactive HTML Preview", + "name": "Spec Kit Preview", "id": "preview", - "description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts", + "description": "Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML", "author": "bigsmartben", - "version": "1.0.0", - "download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/bigsmartben/spec-kit-preview", "homepage": "https://github.com/bigsmartben/spec-kit-preview", "documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md", @@ -2438,20 +2438,21 @@ "speckit_version": ">=0.8.10.dev0" }, "provides": { - "commands": 1, + "commands": 6, "hooks": 0 }, "tags": [ "preview", "prototype", "html", + "markdown", "ux" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-05-15T00:00:00Z", - "updated_at": "2026-05-15T00:00:00Z" + "updated_at": "2026-06-23T00:00:00Z" }, "product": { "name": "Product Spec Extension", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index ed16625d33..f31f15fdea 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -20,6 +20,7 @@ "codex", "cursor-agent", "devin", + "firebender", "forge", "gemini", "copilot", From 59ffa918df3b621439fd58a6646dc9f73998b682 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:47:17 -0500 Subject: [PATCH 39/42] chore: release 0.11.6, begin 0.11.7.dev0 development (#3121) * chore: bump version to 0.11.6 * chore: begin 0.11.7.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 cf0a18ae82..72b31f5274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.11.6] - 2026-06-23 + +### Changed + +- [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116) +- Add Spec Kit Discovery Extension to community catalog (#3119) +- Update Architecture Workflow extension to v1.2.1 (#3118) +- docs: clarify project-defined constitution articles (#2994) +- Add Intake extension to community catalog (#3117) +- feat: add Firebender integration (Android Studio / IntelliJ) (#3077) +- Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115) +- chore: sync issue template agent lists (#3052) +- fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098) +- chore: release 0.11.5, begin 0.11.6.dev0 development (#3105) + ## [0.11.5] - 2026-06-22 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6a5099a9d1..7666c1d2cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.6.dev0" +version = "0.11.7.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 0c975bbef79e307b360e7946aa0551c2938443ea Mon Sep 17 00:00:00 2001 From: JinHyuk Sung <163989462+sjh9714@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:55:38 +0900 Subject: [PATCH 40/42] fix: write Codex dev skills as files (#2988) * fix: write Codex dev skills as files * fix: route codex dev symlink policy through metadata * fix: replace codex dev symlinks on refresh * fix: migrate codex dev skill symlinks * fix: avoid inactive shared skill dev symlinks * fix: preserve unrelated dev skill symlinks --- src/specify_cli/agents.py | 27 +++- src/specify_cli/extensions/__init__.py | 14 +- src/specify_cli/integrations/base.py | 3 + .../integrations/codex/__init__.py | 1 + tests/test_agent_config_consistency.py | 6 + tests/test_extension_skills.py | 78 +++++++++++ tests/test_extensions.py | 131 ++++++++++++++++++ 7 files changed, 254 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index b1a5a932c2..28dc8037e7 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -37,6 +37,8 @@ def _build_agent_configs() -> dict[str, Any]: # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. if "invoke_separator" not in config: config["invoke_separator"] = integration.invoke_separator + if integration.dev_no_symlink: + config["dev_no_symlink"] = True configs[key] = config return configs @@ -714,6 +716,7 @@ def register_commands( output_name, agent_config["extension"], link_outputs, + agent_config, ) if agent_name == "copilot": @@ -788,6 +791,7 @@ def register_commands( alias_output_name, agent_config["extension"], link_outputs, + agent_config, ) if agent_name == "copilot": self.write_copilot_prompt(project_root, alias) @@ -804,9 +808,12 @@ def _write_registered_output( output_name: str, extension: str, link_outputs: bool, + agent_config: dict[str, Any] | None = None, ) -> None: """Write a rendered agent artifact, optionally as a dev-mode symlink.""" - if not link_outputs: + if not link_outputs or (agent_config or {}).get("dev_no_symlink"): + if dest_file.is_symlink(): + dest_file.unlink() dest_file.write_text(content, encoding="utf-8") return @@ -927,6 +934,16 @@ def register_commands_for_all_agents( self._active_skills_agent(project_root) if create_missing_active_skills_dir else None ) + active_skills_dir: Optional[Path] = None + if active_skills_agent: + active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent) + if ( + active_skills_config + and active_skills_config.get("extension") == "/SKILL.md" + ): + active_skills_dir = self._resolve_agent_dir( + active_skills_agent, active_skills_config, project_root, + ) active_created_skills_dir: Optional[Path] = None for agent_name, agent_config in self.AGENT_CONFIGS.items(): active_skills_output = ( @@ -958,6 +975,14 @@ def register_commands_for_all_agents( agent_dir = self._resolve_agent_dir( agent_name, agent_config, project_root, ) + shares_active_skills_dir = ( + active_skills_dir is not None + and agent_name != active_skills_agent + and agent_config.get("extension") == "/SKILL.md" + and self._same_lexical_path(agent_dir, active_skills_dir) + ) + if shares_active_skills_dir: + continue agent_dir_existed = agent_dir.is_dir() register_missing_active_skills_agent = ( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 19cc0f0910..3df917af2e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -997,6 +997,7 @@ def _register_extension_skills( if not isinstance(selected_ai, str) or not selected_ai: return [] registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) integration = get_integration(selected_ai) for cmd_info in manifest.commands: @@ -1030,15 +1031,16 @@ def _register_extension_skills( skill_file = skill_subdir / "SKILL.md" cache_root = extension_dir / ".specify-dev" / "extension-skills" cache_file = cache_root / skill_name / "SKILL.md" + use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink") CommandRegistrar._ensure_inside(cache_file, cache_root) if skill_file.exists() or skill_file.is_symlink(): + is_expected_dev_symlink = self._is_expected_dev_symlink( + skill_file, cache_file + ) # Do not overwrite user-customized skills, but allow dev-mode # symlinks that point back to this extension's generated cache # to be refreshed on a subsequent dev install. - if not ( - link_outputs - and self._is_expected_dev_symlink(skill_file, cache_file) - ): + if not is_expected_dev_symlink: continue # Create skill directory; track whether we created it so we can clean @@ -1093,7 +1095,7 @@ def _register_extension_skills( ): skill_content = integration.post_process_skill_content(skill_content) - if link_outputs: + if use_dev_symlink: try: cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(skill_content, encoding="utf-8") @@ -1106,6 +1108,8 @@ def _register_extension_skills( skill_file.unlink() skill_file.write_text(skill_content, encoding="utf-8") else: + if skill_file.is_symlink(): + skill_file.unlink() 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 3798778cce..be3ab7133d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -119,6 +119,9 @@ class IntegrationBase(ABC): invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + dev_no_symlink: bool = False + """Whether dev-mode registration should write files instead of symlinks.""" + multi_install_safe: bool = False """Whether this integration is declared safe to install alongside others. diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 1f7dbc601f..4dd79da493 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + dev_no_symlink = True multi_install_safe = True def build_exec_args( diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index f31f15fdea..49e74ef5ef 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -360,6 +360,12 @@ def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self): "expected '-' (propagated from SkillsIntegration.invoke_separator)" ) + def test_codex_dev_no_symlink_policy_in_agent_config(self): + """Codex dev installs must expose the no-symlink policy as metadata.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert cfg["codex"].get("dev_no_symlink") is True + def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): """__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit- when registered for a skills-based agent (e.g. claude). diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8ef675e51e..1cdf126a06 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -573,6 +573,84 @@ def test_dev_skill_symlink_refreshes_existing_cache( assert "speckit-test-ext-hello" in written assert "Run this updated hello." in skill_file.read_text(encoding="utf-8") + def test_codex_dev_skill_registration_replaces_existing_dev_symlink( + self, project_dir, extension_dir, temp_dir + ): + """Codex dev skill registration should migrate prior dev symlinks to files.""" + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + _create_init_options(project_dir, ai="codex", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="codex") + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + skill_file.parent.mkdir(parents=True, exist_ok=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "extension-skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + written = manager._register_extension_skills( + manifest, + extension_dir, + link_outputs=True, + ) + + assert "speckit-test-ext-hello" in written + assert skill_file.exists() + assert not skill_file.is_symlink() + assert "Run this to say hello." in skill_file.read_text(encoding="utf-8") + assert cache_file.read_text(encoding="utf-8") == "old linked content" + + def test_codex_dev_skill_registration_preserves_unrelated_symlink( + self, project_dir, extension_dir, temp_dir + ): + """Codex dev registration should not overwrite user-owned symlinks.""" + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + _create_init_options(project_dir, ai="codex", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="codex") + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + skill_file.parent.mkdir(parents=True, exist_ok=True) + unrelated_cache_file = ( + temp_dir + / "other-extension" + / ".specify-dev" + / "extension-skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + unrelated_cache_file.parent.mkdir(parents=True) + unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8") + os.symlink( + os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file + ) + + written = manager._register_extension_skills( + manifest, + extension_dir, + link_outputs=True, + ) + + assert "speckit-test-ext-hello" not in written + assert skill_file.is_symlink() + assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve() + assert unrelated_cache_file.read_text(encoding="utf-8") == ( + "user-owned linked content" + ) + def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails( self, skills_project, extension_dir, monkeypatch ): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c60a7e430f..4cd052fd81 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2248,6 +2248,50 @@ def test_dev_register_commands_symlinks_rendered_copilot_agent( assert target.is_file() assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8") + def test_dev_register_commands_replaces_codex_dev_symlink( + self, extension_dir, project_dir, temp_dir + ): + """Codex dev registration should replace prior symlinks with real files.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "agent-commands" + / "codex" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "codex", + manifest, + extension_dir, + project_dir, + link_outputs=True, + ) + + assert skill_file.exists() + assert not skill_file.is_symlink() + assert "name: speckit-test-ext-hello" in skill_file.read_text( + encoding="utf-8" + ) + assert cache_file.read_text(encoding="utf-8") == "old linked content" + def test_dev_register_commands_falls_back_to_copy_when_symlink_fails( self, extension_dir, project_dir, monkeypatch ): @@ -4874,6 +4918,93 @@ def test_add_dev_links_copilot_agent_when_supported( else: assert not agent_file.is_symlink() + def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir): + """Codex dev skills should be written as files so Codex can load them.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + assert skill_file.exists() + assert not skill_file.is_symlink() + + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "metadata:" in content + assert "source: test-ext:commands/hello.md" in content + + def test_add_dev_replaces_existing_codex_skill_symlink( + self, extension_dir, project_dir, temp_dir + ): + """Codex dev installs should migrate expected dev symlinks to files.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "extension-skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert skill_file.exists() + assert not skill_file.is_symlink() + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "source: test-ext:commands/hello.md" in content + assert cache_file.read_text(encoding="utf-8") == "old linked content" + def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable( self, extension_dir, project_dir, monkeypatch ): From 0ef53eb91f83e96f1fa783904906e097d20c6953 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 24 Jun 2026 00:09:17 +0500 Subject: [PATCH 41/42] fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123) * fix(scripts): send check-prerequisites.ps1 errors to stderr The validation errors and run-hints in check-prerequisites.ps1 were written with Write-Output, so they went to stdout. This script is usually run with -Json and its stdout parsed by the agent, so an error (e.g. missing plan.md) leaves the parser with an error string instead of JSON. The bash counterpart already writes these to stderr (>&2), as do the sibling PowerShell scripts (setup-tasks.ps1, common.ps1's Get-FeaturePathsEnv). Switch the six error/hint lines to [Console]::Error.WriteLine so stdout stays clean and the two shells match. * test(scripts): assert check-prerequisites errors stay on stderr Per the #3122 bug assessment, tighten the failure-path tests so they verify stdout stays clean (empty / valid JSON) and the error text only appears on stderr, instead of checking the combined stdout+stderr string. Covers all three PowerShell validation paths (missing feature dir, missing plan.md, missing tasks.md with -RequireTasks) and the bash counterpart. The two new error-routing tests fail on the pre-fix script (errors on stdout) and pass after it. --- scripts/powershell/check-prerequisites.ps1 | 12 ++-- tests/test_check_prerequisites_paths_only.py | 64 ++++++++++++++++++-- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index bb60e52c85..52469aa19a 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -83,24 +83,24 @@ if ($PathsOnly) { # Validate required directories and files if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { - Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" + [Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)") $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT - Write-Output "Run $specifyCommand first to create the feature structure." + [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.") exit 1 } if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { - Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)" + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT - Write-Output "Run $planCommand first to create the implementation plan." + [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") exit 1 } # Check for tasks.md if required if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { - Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)" + [Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)") $tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT - Write-Output "Run $tasksCommand first to create the task list." + [Console]::Error.WriteLine("Run $tasksCommand first to create the task list.") exit 1 } diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py index 03e2fc6e8b..c8c2926abc 100644 --- a/tests/test_check_prerequisites_paths_only.py +++ b/tests/test_check_prerequisites_paths_only.py @@ -143,7 +143,11 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: @requires_bash def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: - """Without --paths-only, feature directory validation must still fail on main.""" + """Without --paths-only, feature directory validation must still fail on main. + + The error must go to stderr and stdout must stay clean, so a caller that + parses stdout as JSON is not handed the error string instead (#3122). + """ script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" result = subprocess.run( ["bash", str(script), "--json"], @@ -155,6 +159,8 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: ) assert result.returncode != 0 assert "Feature directory not found" in result.stderr + assert "Feature directory not found" not in result.stdout + assert result.stdout.strip() == "" # ── PowerShell tests ────────────────────────────────────────────────────── @@ -213,7 +219,33 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: @pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: - """Without -PathsOnly, feature directory validation must still fail on main.""" + """Without -PathsOnly, feature directory validation must still fail on main. + + The error must land on stderr only, leaving stdout clean for -Json + callers that parse it as JSON (#3122). + """ + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Feature directory not found" in result.stderr + assert "Feature directory not found" not in result.stdout + assert result.stdout.strip() == "" + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None: + """A missing plan.md must report on stderr, not stdout (#3122).""" + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + _write_feature_json(prereq_repo) script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL result = subprocess.run( @@ -225,5 +257,29 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: env=_clean_env(), ) assert result.returncode != 0 - combined = result.stdout + result.stderr - assert "Feature directory not found" in combined + assert "plan.md not found" in result.stderr + assert "plan.md not found" not in result.stdout + assert result.stdout.strip() == "" + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None: + """With -RequireTasks, a missing tasks.md must report on stderr only (#3122).""" + feat = prereq_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + _write_feature_json(prereq_repo) + script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"], + cwd=prereq_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "tasks.md not found" in result.stderr + assert "tasks.md not found" not in result.stdout + assert result.stdout.strip() == "" From b6b74d4ccf8ab95a5e2e397b22bd9a6506e613da Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Tue, 23 Jun 2026 21:20:28 +0200 Subject: [PATCH 42/42] docs: add monorepo guide (#3084) * docs: add monorepo guide Adds docs/guides/monorepo.md covering per-project .specify/, targeting a member project from the repo root with SPECIFY_INIT_DIR, agent env propagation, the git extension scoping limitation (#3081), and per-project constitutions. Wires it into docs/toc.yml under Development. * docs: correct monorepo Git guidance * docs: drop open-issue reference and polish monorepo guide prose * docs: fix SPECIFY_INIT_DIR error example (absolute path, non-project dir) * docs: address Copilot wording nits in monorepo guide * docs: clarify monorepo constitution sharing Assisted-by: Codex (model: GPT-5, autonomous) --- docs/guides/monorepo.md | 111 ++++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 + 2 files changed, 113 insertions(+) create mode 100644 docs/guides/monorepo.md diff --git a/docs/guides/monorepo.md b/docs/guides/monorepo.md new file mode 100644 index 0000000000..b143699256 --- /dev/null +++ b/docs/guides/monorepo.md @@ -0,0 +1,111 @@ +# Using Spec Kit in a Monorepo + +A Spec Kit project is **directory-scoped**: the project is whichever directory +contains `.specify/`. A monorepo can hold several independent Spec Kit projects +under one repository root, each with its own `.specify/`, `specs/`, constitution, +and feature numbering. + +Root resolution already prefers the **nearest** `.specify/` over the Git +toplevel, so commands run from inside a member project resolve to that project, +not the repo root. + +## Layout + +```text +my-monorepo/ +├── .git/ # one Git repository at the root +├── apps/ +│ ├── web/ +│ │ └── .specify/ # Spec Kit project "web" +│ │ └── memory/constitution.md +│ └── api/ +│ └── .specify/ # Spec Kit project "api" +│ └── memory/constitution.md +└── packages/ + └── ui/ + └── .specify/ # Spec Kit project "ui" +``` + +Initialize each member project independently: + +```bash +specify init apps/web --integration claude +specify init apps/api --integration claude +``` + +Each project keeps its own `specs/` directory and numbers features +independently (`apps/web/specs/001-…`, `apps/api/specs/001-…`). + +## Working inside a member project + +The default workflow is unchanged: change into the project directory and run the +slash commands. Root resolution finds the nearest `.specify/`. + +```bash +cd apps/web +# then run /speckit.specify, /speckit.plan, … in your agent +``` + +## Targeting a member project from the repo root + +For non-interactive or CI runs where you do not want to `cd`, set +**`SPECIFY_INIT_DIR`** to the member project root (the directory *containing* +`.specify/`). Relative paths resolve against the current directory. + +```bash +# operate on apps/web from the monorepo root (no cd required) +export SPECIFY_INIT_DIR=apps/web +``` + +The path must exist and contain `.specify/`. If it does not, the command +**errors and does not fall back** to the current directory or the Git toplevel. +This is deliberate: a typo never writes specs into the wrong project. A +nonexistent path is reported as you typed it; a path that exists but is not a +Spec Kit project is reported as its resolved absolute path: + +```text +# SPECIFY_INIT_DIR=apps/wbe (typo: no such directory) +ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe + +# SPECIFY_INIT_DIR=apps (exists, but has no .specify/ of its own) +ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps +``` + +`SPECIFY_INIT_DIR` selects the **project**; `SPECIFY_FEATURE_DIRECTORY` selects +the **feature** within it. They compose: set both to pick a project and a +feature non-interactively. See the +[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for +the full contract and the two-axes model. + +## How `SPECIFY_INIT_DIR` reaches your agent + +`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke +(`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell). It takes effect only +when it is present in the environment of the shell that runs those scripts. + +- **Scripted / CI runs:** export it in the same shell that drives the commands; + it is reliable there. +- **Interactive agents:** whether an exported variable reaches the shell tool an + agent uses is agent-specific. Export `SPECIFY_INIT_DIR` *before* launching the + agent, and verify once (e.g. run `/speckit.specify` and confirm the new feature + landed under the intended project's `specs/`). + +## Git in a monorepo + +> [!NOTE] +> Spec Kit project files are scoped to the **resolved project root**, but Git +> operations still run in the containing Git work tree. In a monorepo with a +> single Git repository at the root and projects in subdirectories, feature +> branch creation creates or switches branches in the shared root repository. +> Spec directories still live under the selected member project, while the Git +> branch namespace is shared by the whole monorepo. Manage branches and commits +> at the repository root, or initialize Git per member project if you want +> isolated per-project branch namespaces. + +## Constitutions + +Each member project has its own `.specify/memory/constitution.md` and +`/speckit.constitution` edits the local project's file. Spec Kit does not provide +a built-in base/inheritance mechanism; if you want one constitution to reference +shared rules elsewhere in the monorepo, you need to maintain that wiring yourself. +Otherwise, duplicate or sync shared engineering rules per project. diff --git a/docs/toc.yml b/docs/toc.yml index aba93ab432..711abb3375 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -53,6 +53,8 @@ href: local-development.md - name: Evolving Specs href: guides/evolving-specs.md + - name: Monorepos + href: guides/monorepo.md # Community - name: Community