From b1223034c3acbf2893593dfe8ae5fc5bcb352e09 Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Sun, 31 May 2026 11:38:27 +0300 Subject: [PATCH 01/13] feat: add Zed integration --- docs/index.md | 2 +- docs/reference/integrations.md | 1 + src/specify_cli/commands/init.py | 8 ++- src/specify_cli/extensions.py | 3 ++ src/specify_cli/integrations/__init__.py | 2 + src/specify_cli/integrations/zed/__init__.py | 41 +++++++++++++++ .../test_integration_subcommand.py | 1 + tests/integrations/test_integration_zed.py | 51 +++++++++++++++++++ tests/integrations/test_registry.py | 2 +- 9 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/specify_cli/integrations/zed/__init__.py create mode 100644 tests/integrations/test_integration_zed.py diff --git a/docs/index.md b/docs/index.md index c8b02d98cb..486193e823 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and ### Use any coding agent -30 integrations — Copilot, Gemini, Codex, Windsurf, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. +31 integrations — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..ab34bf5846 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -35,6 +35,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` | | +| [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 | ## List Available Integrations diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index e5dc47e98c..e034affddf 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -726,7 +726,8 @@ def init( cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration devin_skill_mode = selected_ai == "devin" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode + zed_skill_mode = selected_ai == "zed" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or zed_skill_mode if codex_skill_mode and not ai_skills: steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") @@ -740,6 +741,9 @@ def init( if devin_skill_mode: steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") step_num += 1 + if zed_skill_mode: + steps_lines.append(f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -749,7 +753,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or zed_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 5a595fbffa..a11eedcbdb 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2413,6 +2413,7 @@ def _render_hook_invocation(self, command: Any) -> str: claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) + zed_skill_mode = selected_ai == "zed" and bool(init_options.get("ai_skills")) skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: @@ -2423,6 +2424,8 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/skill:{skill_name}" if cursor_skill_mode and skill_name: return f"/{skill_name}" + if zed_skill_mode and skill_name: + return f"/{skill_name}" return f"/{command_id}" diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ad1440d074..c3d7499ff3 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -78,6 +78,7 @@ def _register_builtins() -> None: from .trae import TraeIntegration from .vibe import VibeIntegration from .windsurf import WindsurfIntegration + from .zed import ZedIntegration # -- Registration (alphabetical) -------------------------------------- _register(AgyIntegration()) @@ -111,6 +112,7 @@ def _register_builtins() -> None: _register(TraeIntegration()) _register(VibeIntegration()) _register(WindsurfIntegration()) + _register(ZedIntegration()) _register_builtins() diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py new file mode 100644 index 0000000000..004cfc57f6 --- /dev/null +++ b/src/specify_cli/integrations/zed/__init__.py @@ -0,0 +1,41 @@ +"""Zed editor integration. — skills-based agent. + +Zed uses the ``.agents/skills/speckit-/SKILL.md`` layout so Spec Kit +commands are exposed as project-local skills that can be invoked from Zed's +slash-command menu. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class ZedIntegration(SkillsIntegration): + """Integration for Zed editor skills.""" + + key = "zed" + config = { + "name": "Zed", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Zed)", + ), + ] diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index f40adb7ae9..42a088f541 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -93,6 +93,7 @@ def test_list_shows_available_integrations(self, tmp_path): # Should show multiple integrations assert "claude" in result.output assert "gemini" in result.output + assert "zed" in result.output def test_list_shows_multi_install_safe_status(self, tmp_path): project = _init_project(tmp_path, "claude") diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py new file mode 100644 index 0000000000..f3256c7fc7 --- /dev/null +++ b/tests/integrations/test_integration_zed.py @@ -0,0 +1,51 @@ +"""Tests for ZedIntegration.""" + +import json + +from specify_cli.integrations import get_integration + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestZedIntegration(SkillsIntegrationTests): + KEY = "zed" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + CONTEXT_FILE = "AGENTS.md" + + def test_requires_cli_is_false(self): + """Zed is IDE-based; requires_cli must remain False.""" + i = get_integration(self.KEY) + assert i is not None + assert i.config is not None + assert i.config["requires_cli"] is False + + +class TestZedHookInvocations: + """Zed hook messages should reference slash-invokable skills.""" + + def test_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "zed-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "zed", "ai_skills": True})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + + assert "Executing: `/speckit-plan`" in message + assert "EXECUTE_COMMAND: speckit.plan" in message + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 1b36501056..b6f7439254 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -27,7 +27,7 @@ # Stage 4 — TOML integrations "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations - "codex", "kimi", "agy", "generic", + "codex", "kimi", "agy", "zed", "generic", ] From d6df1e8320a310b8d1b59c70dced0607c10249a6 Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Wed, 3 Jun 2026 02:07:25 +0300 Subject: [PATCH 02/13] fix: update integrations stats grid to 31 for consistency --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 486193e823..7f838902c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ **Define what to build before building it — with any AI coding agent.** -Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe *what* to build, refine it through structured phases, and let your AI coding agent implement it. +Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe _what_ to build, refine it through structured phases, and let your AI coding agent implement it. Install Spec Kit  Quick Start @@ -90,7 +90,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a Contributors
- 30 + 31 Integrations
From 2e99b1958efaa42043f3d292aed6ac309c2619ba Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Wed, 3 Jun 2026 02:20:03 +0300 Subject: [PATCH 03/13] fix: address Copilot review feedback - Remove non-actionable --skills flag from ZedIntegration (Zed is always skills-based, like Agy) - Align zed_skill_mode predicate with ai_skills for consistency across init output and hook rendering - Consolidate claude/cursor/zed slash-skill return blocks in _render_hook_invocation to reduce duplication - Override test_options_include_skills_flag for Zed (no --skills flag) --- src/specify_cli/commands/init.py | 2 +- src/specify_cli/extensions.py | 10 ++++------ src/specify_cli/integrations/zed/__init__.py | 11 ++--------- tests/integrations/test_integration_zed.py | 6 ++++++ 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 29dfefeaef..4cb745be5f 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -728,7 +728,7 @@ def init( cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration devin_skill_mode = selected_ai == "devin" - zed_skill_mode = selected_ai == "zed" and _is_skills_integration + zed_skill_mode = selected_ai == "zed" and (ai_skills or _is_skills_integration) cline_skill_mode = selected_ai == "cline" native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or zed_skill_mode diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 997cd41d3f..8c1fa9158c 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2492,19 +2492,17 @@ def _render_hook_invocation(self, command: Any) -> str: skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: return f"${skill_name}" - if claude_skill_mode and skill_name: - return f"/{skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" - if cursor_skill_mode and skill_name: - return f"/{skill_name}" - if zed_skill_mode and skill_name: - return f"/{skill_name}" if cline_mode: from .integrations.cline import format_cline_command_name return f"/{format_cline_command_name(command_id)}" + # Slash-skill integrations (Claude, Cursor, Zed…) + if skill_name and (claude_skill_mode or cursor_skill_mode or zed_skill_mode): + return f"/{skill_name}" + return f"/{command_id}" def get_project_config(self) -> Dict[str, Any]: diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 575c97c43a..8ee2f4609a 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -7,7 +7,7 @@ from __future__ import annotations -from ..base import IntegrationOption, SkillsIntegration +from ..base import SkillsIntegration class ZedIntegration(SkillsIntegration): @@ -31,11 +31,4 @@ class ZedIntegration(SkillsIntegration): @classmethod def options(cls) -> list[IntegrationOption]: - return [ - IntegrationOption( - "--skills", - is_flag=True, - default=True, - help="Install as agent skills (default for Zed)", - ), - ] + return [] diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index f3256c7fc7..18d59ea125 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -14,6 +14,12 @@ class TestZedIntegration(SkillsIntegrationTests): REGISTRAR_DIR = ".agents/skills" CONTEXT_FILE = "AGENTS.md" + def test_options_include_skills_flag(self): + """Zed is always skills-based; no --skills option needed.""" + i = get_integration(self.KEY) + skills_opts = [o for o in i.options() if o.name == "--skills"] + assert len(skills_opts) == 0 + def test_requires_cli_is_false(self): """Zed is IDE-based; requires_cli must remain False.""" i = get_integration(self.KEY) From e4f3b4c5732e498ca4ccbd308714cd64e2078fa8 Mon Sep 17 00:00:00 2001 From: Ahmet TOK <48218623+arrrrny@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:28:26 +0300 Subject: [PATCH 04/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/zed/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 8ee2f4609a..882d83cc59 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -7,7 +7,7 @@ from __future__ import annotations -from ..base import SkillsIntegration +from ..base import IntegrationOption, SkillsIntegration class ZedIntegration(SkillsIntegration): From 2b9b482a2c32b41cfe7994961569c5d0326a212b Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Wed, 3 Jun 2026 18:32:22 +0300 Subject: [PATCH 05/13] fix: address Copilot review round 2 - Make zed_skill_mode unconditional in hook rendering (Zed is always skills-based, no --skills option) - Add test_init_persists_ai_skills_for_zed that exercises the actual CLI init path and verifies HookExecutor renders /speckit-plan without manual init-options manipulation --- src/specify_cli/extensions.py | 2 +- tests/integrations/test_integration_zed.py | 63 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 8c1fa9158c..82ac06f568 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2486,7 +2486,7 @@ def _render_hook_invocation(self, command: Any) -> str: claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) - zed_skill_mode = selected_ai == "zed" and bool(init_options.get("ai_skills")) + zed_skill_mode = selected_ai == "zed" cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 18d59ea125..ebe9a50d5a 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -55,3 +55,66 @@ def test_hooks_render_skill_invocation(self, tmp_path): assert "Executing: `/speckit-plan`" in message assert "EXECUTE_COMMAND: speckit.plan" in message assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_init_persists_ai_skills_for_zed(self, tmp_path): + """specify init --integration zed must persist ai_skills: true, + so HookExecutor renders slash-skill invocations without manual + init-options manipulation.""" + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.extensions import HookExecutor + + project = tmp_path / "zed-init-test" + project.mkdir() + old_cwd = None + try: + import os + + old_cwd = os.getcwd() + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "zed", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + if old_cwd is not None: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + opts_path = project / ".specify" / "init-options.json" + assert opts_path.exists() + opts = json.loads(opts_path.read_text(encoding="utf-8")) + assert opts.get("ai") == "zed" + assert opts.get("ai_skills") is True, ( + f"init must persist ai_skills=true for Zed, got: {opts.get('ai_skills')}" + ) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + assert "Executing: `/speckit-plan`" in message, ( + "Hook rendering must produce /speckit-plan for Zed without hint injection\n" + f"Got message: {message}" + ) + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message From 52b6d0b810f73eb6a0f8917feea4e6ec159d008d Mon Sep 17 00:00:00 2001 From: Ahmet TOK <48218623+arrrrny@users.noreply.github.com> Date: Thu, 4 Jun 2026 06:52:08 +0300 Subject: [PATCH 06/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 7f838902c3..b94eb2fc1a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ Define what to build before building it. Rich templates, quality checklists, and ### Use any coding agent -31 integrations — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. +33 integrations — Copilot, Gemini, Codex, Windsurf, Zed, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in. Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool. From 2dbbe757ae162e9dbe1f954b4b37dc74d91c8c5d Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 07:45:49 +0300 Subject: [PATCH 07/13] fix: address copilot review feedback for zed integration - Update integration count from 31 to 33 in docs/index.md (32 integrations + Generic) - Make zed_skill_mode unconditional to match extensions.py behavior - Consolidate slash-skill integrations into a set for consistency - Move os import to module level in test_integration_zed.py --- docs/index.md | 2 +- src/specify_cli/commands/init.py | 453 ++++++++++++++++----- src/specify_cli/extensions.py | 419 +++++++++++-------- tests/integrations/test_integration_zed.py | 3 +- 4 files changed, 609 insertions(+), 268 deletions(-) diff --git a/docs/index.md b/docs/index.md index b94eb2fc1a..8f61d65f70 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,7 +90,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a Contributors
- 31 + 33 Integrations
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4cb745be5f..fac37fae09 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1,4 +1,5 @@ """specify init command.""" + from __future__ import annotations import os @@ -28,6 +29,7 @@ from .._console import StepTracker, console, select_with_arrows, show_banner from .._utils import check_tool, init_git_repo, is_git_repo + def _build_integration_equivalent( integration_key: str, ai_commands_dir: str | None = None, @@ -63,7 +65,9 @@ def ensure_constitution_from_template( ) -> None: """Copy constitution template to memory if it doesn't exist.""" memory_constitution = project_path / ".specify" / "memory" / "constitution.md" - template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" + template_constitution = ( + project_path / ".specify" / "templates" / "constitution-template.md" + ) if memory_constitution.exists(): if tracker: @@ -90,29 +94,94 @@ def ensure_constitution_from_template( tracker.add("constitution", "Constitution setup") tracker.error("constitution", str(e)) else: - console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + console.print( + f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]" + ) def register(app: typer.Typer) -> None: @app.command() def init( - project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), + project_name: str = typer.Argument( + None, + help="Name for your new project directory (optional if using --here, or use '.' for current directory)", + ), ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), - ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), - script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), - ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), - no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), - here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), - force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), - skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), - debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True), - github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), - offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), - preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), - branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), - integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), - integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), + ai_commands_dir: str = typer.Option( + None, + "--ai-commands-dir", + help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)", + ), + script_type: str = typer.Option( + None, "--script", help="Script type to use: sh or ps" + ), + ignore_agent_tools: bool = typer.Option( + False, + "--ignore-agent-tools", + help="Skip checks for coding agent tools like Claude Code", + ), + no_git: bool = typer.Option( + False, "--no-git", help="Skip git repository initialization" + ), + here: bool = typer.Option( + False, + "--here", + help="Initialize project in the current directory instead of creating a new one", + ), + force: bool = typer.Option( + False, + "--force", + help="Force merge/overwrite when using --here (skip confirmation)", + ), + skip_tls: bool = typer.Option( + False, + "--skip-tls", + help="Deprecated (no-op). Previously: skip SSL/TLS verification.", + hidden=True, + ), + debug: bool = typer.Option( + False, + "--debug", + help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", + hidden=True, + ), + github_token: str = typer.Option( + None, + "--github-token", + help="Deprecated (no-op). Previously: GitHub token for API requests.", + hidden=True, + ), + ai_skills: bool = typer.Option( + False, + "--ai-skills", + help="Install Prompt.MD templates as agent skills (requires --ai)", + ), + offline: bool = typer.Option( + False, + "--offline", + help="Deprecated (no-op). All scaffolding now uses bundled assets.", + hidden=True, + ), + preset: str = typer.Option( + None, + "--preset", + help="Install a preset during initialization (by preset ID)", + ), + branch_numbering: str = typer.Option( + None, + "--branch-numbering", + help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)", + ), + integration: str = typer.Option( + None, + "--integration", + help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai.", + ), + integration_options: str = typer.Option( + None, + "--integration-options", + help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")', + ), ): """ Initialize a new Specify project. @@ -156,36 +225,53 @@ def init( ensure_executable_scripts, save_init_options, ) + from ..integration_runtime import ( + with_integration_setting as _with_integration_setting, + ) from ..integrations._commands import ( _parse_integration_options, _write_integration_json, ) - from ..integration_runtime import with_integration_setting as _with_integration_setting show_banner() ai_deprecation_warning: str | None = None if ai_assistant and ai_assistant.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") - console.print("[yellow]Example:[/yellow] specify init --integration claude --here") - console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") + console.print( + "[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?" + ) + console.print( + "[yellow]Example:[/yellow] specify init --integration claude --here" + ) + console.print( + f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}" + ) raise typer.Exit(1) if ai_commands_dir and ai_commands_dir.startswith("--"): - console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") - console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"") + console.print( + f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'" + ) + console.print( + "[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?" + ) + console.print( + '[yellow]Example:[/yellow] specify init --integration generic --integration-options="--commands-dir .myagent/commands/"' + ) raise typer.Exit(1) if ai_assistant: ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) if integration and ai_assistant: - console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") + console.print( + "[red]Error:[/red] --integration and --ai are mutually exclusive" + ) raise typer.Exit(1) from ..integrations import INTEGRATION_REGISTRY, get_integration + if integration: resolved_integration = get_integration(integration) if not resolved_integration: @@ -197,7 +283,9 @@ def init( elif ai_assistant: resolved_integration = get_integration(ai_assistant) if not resolved_integration: - console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") + console.print( + f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}" + ) raise typer.Exit(1) ai_deprecation_warning = _build_ai_deprecation_warning( resolved_integration.key, @@ -207,6 +295,7 @@ def init( if ai_assistant or integration: if ai_skills: from ..integrations.base import SkillsIntegration as _SkillsCheck + if isinstance(resolved_integration, _SkillsCheck): console.print( "[dim]Note: --ai-skills is not needed; " @@ -235,21 +324,29 @@ def init( project_name = None if here and project_name: - console.print("[red]Error:[/red] Cannot specify both project name and --here flag") + console.print( + "[red]Error:[/red] Cannot specify both project name and --here flag" + ) raise typer.Exit(1) if not here and not project_name: - console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") + console.print( + "[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag" + ) raise typer.Exit(1) if ai_skills and not ai_assistant: console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") - console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") + console.print( + "[yellow]Usage:[/yellow] specify init --ai --ai-skills" + ) raise typer.Exit(1) BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: - console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") + console.print( + f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}" + ) raise typer.Exit(1) dir_existed_before = False @@ -260,10 +357,16 @@ def init( existing_items = list(project_path.iterdir()) if existing_items: - console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)") - console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print( + f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" + ) + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) if force: - console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") + console.print( + "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" + ) else: response = typer.confirm("Do you want to continue?") if not response: @@ -274,14 +377,22 @@ def init( dir_existed_before = project_path.exists() if project_path.exists(): if not project_path.is_dir(): - console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + console.print( + f"[red]Error:[/red] '{project_name}' exists but is not a directory." + ) raise typer.Exit(1) existing_items = list(project_path.iterdir()) if force: if existing_items: - console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") - console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") - console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + console.print( + f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)" + ) + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) + console.print( + f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]" + ) else: error_panel = Panel( f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" @@ -289,7 +400,7 @@ def init( "Use [bold]--force[/bold] to merge into the existing directory.", title="[red]Directory Conflict[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -297,7 +408,9 @@ def init( if ai_assistant: if ai_assistant not in AGENT_CONFIG: - console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") + console.print( + f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}" + ) raise typer.Exit(1) selected_ai = ai_assistant elif not _stdin_is_interactive(): @@ -322,8 +435,12 @@ def init( if selected_ai == "generic" and not integration_options: if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") - console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') + console.print( + "[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic" + ) + console.print( + '[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]' + ) raise typer.Exit(1) current_dir = Path.cwd() @@ -338,13 +455,17 @@ def init( if not here: setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") - console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) + console.print( + Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)) + ) should_init_git = False if not no_git: should_init_git = check_tool("git") if not should_init_git: - console.print("[yellow]Git not found - will skip repository initialization[/yellow]") + console.print( + "[yellow]Git not found - will skip repository initialization[/yellow]" + ) if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) @@ -358,7 +479,7 @@ def init( "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", title="[red]Agent Detection Error[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -366,14 +487,20 @@ def init( if script_type: if script_type not in SCRIPT_TYPE_CHOICES: - console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") + console.print( + f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" + ) raise typer.Exit(1) selected_script = script_type else: default_script = "ps" if os.name == "nt" else "sh" if _stdin_is_interactive(): - selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) + selected_script = select_with_arrows( + SCRIPT_TYPE_CHOICES, + "Choose script type (or press Enter)", + default_script, + ) else: selected_script = default_script @@ -404,13 +531,18 @@ def init( git_default_notice = False - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: + with Live( + tracker.render(), console=console, refresh_per_second=8, transient=True + ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: from ..integrations.manifest import IntegrationManifest + tracker.start("integration") manifest = IntegrationManifest( - resolved_integration.key, project_path, version=get_speckit_version() + resolved_integration.key, + project_path, + version=get_speckit_version(), ) integration_parsed_options: dict[str, Any] = {} @@ -419,12 +551,15 @@ def init( if ai_skills: integration_parsed_options["skills"] = True if integration_options: - extra = _parse_integration_options(resolved_integration, integration_options) + extra = _parse_integration_options( + resolved_integration, integration_options + ) if extra: integration_parsed_options.update(extra) resolved_integration.setup( - project_path, manifest, + project_path, + manifest, parsed_options=integration_parsed_options or None, script_type=selected_script, raw_options=integration_options, @@ -446,7 +581,10 @@ def init( integration_settings, ) - tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) + tracker.complete( + "integration", + resolved_integration.config.get("name", resolved_integration.key), + ) tracker.start("shared-infra") _install_shared_infra_or_exit( @@ -454,9 +592,13 @@ def init( selected_script, tracker=tracker, force=force, - invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options), + invoke_separator=resolved_integration.effective_invoke_separator( + integration_parsed_options + ), + ) + tracker.complete( + "shared-infra", f"scripts ({selected_script}) + templates" ) - tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -473,7 +615,7 @@ def init( else: git_has_error = True if error_msg: - sanitized = error_msg.replace('\n', ' ').strip() + sanitized = error_msg.replace("\n", " ").strip() git_messages.append(f"init failed: {sanitized[:120]}") else: git_messages.append("init failed") @@ -481,6 +623,7 @@ def init( git_messages.append("git not available") try: from ..extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) @@ -497,7 +640,7 @@ def init( git_messages.append("bundled extension not found") except Exception as ext_err: git_has_error = True - sanitized_ext = str(ext_err).replace('\n', ' ').strip() + sanitized_ext = str(ext_err).replace("\n", " ").strip() git_messages.append( f"extension install failed: {sanitized_ext[:120]}" ) @@ -514,29 +657,38 @@ def init( if bundled_wf: from ..workflows.catalog import WorkflowRegistry from ..workflows.engine import WorkflowDefinition + wf_registry = WorkflowRegistry(project_path) if wf_registry.is_installed("speckit"): tracker.complete("workflow", "already installed") else: import shutil as _shutil - dest_wf = project_path / ".specify" / "workflows" / "speckit" + + dest_wf = ( + project_path / ".specify" / "workflows" / "speckit" + ) dest_wf.mkdir(parents=True, exist_ok=True) _shutil.copy2( bundled_wf / "workflow.yml", dest_wf / "workflow.yml", ) - definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") - wf_registry.add("speckit", { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": "bundled", - }) + definition = WorkflowDefinition.from_yaml( + dest_wf / "workflow.yml" + ) + wf_registry.add( + "speckit", + { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }, + ) tracker.complete("workflow", "speckit installed") else: tracker.skip("workflow", "bundled workflow not found") except Exception as wf_err: - sanitized_wf = str(wf_err).replace('\n', ' ').strip() + sanitized_wf = str(wf_err).replace("\n", " ").strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") init_opts = { @@ -548,7 +700,10 @@ def init( "speckit_version": get_speckit_version(), } from ..integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): + + if isinstance(resolved_integration, _SkillsPersist) or getattr( + resolved_integration, "_skills_mode", False + ): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -557,6 +712,7 @@ def init( # registration can read ai_skills + integration key. try: from ..extensions import ExtensionManager as _ExtMgr + bundled_ac = _locate_bundled_extension("agent-context") if bundled_ac: ac_mgr = _ExtMgr(project_path) @@ -569,13 +725,14 @@ def init( tracker.complete("agent-context", "extension installed") else: from ..extensions import REINSTALL_COMMAND as _ac_reinstall + tracker.error( "agent-context", f"bundled extension not found — installation may be " f"incomplete. Run: {_ac_reinstall}", ) except Exception as ac_err: - sanitized_ac = str(ac_err).replace('\n', ' ').strip() + sanitized_ac = str(ac_err).replace("\n", " ").strip() tracker.error( "agent-context", f"extension install failed: {sanitized_ac[:120]}", @@ -595,24 +752,34 @@ def init( if preset: try: - from ..presets import PresetManager, PresetCatalog, PresetError + from ..presets import PresetCatalog, PresetError, PresetManager + preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() local_path = Path(preset).resolve() if local_path.is_dir() and (local_path / "preset.yml").exists(): - preset_manager.install_from_directory(local_path, speckit_ver) + preset_manager.install_from_directory( + local_path, speckit_ver + ) else: bundled_path = _locate_bundled_preset(preset) if bundled_path: - preset_manager.install_from_directory(bundled_path, speckit_ver) + preset_manager.install_from_directory( + bundled_path, speckit_ver + ) else: preset_catalog = PresetCatalog(project_path) pack_info = preset_catalog.get_pack_info(preset) if not pack_info: - console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") - elif pack_info.get("bundled") and not pack_info.get("download_url"): + console.print( + f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping." + ) + elif pack_info.get("bundled") and not pack_info.get( + "download_url" + ): from ..extensions import REINSTALL_COMMAND + console.print( f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " f"but could not be found in the installed package." @@ -620,12 +787,16 @@ def init( console.print( "This usually means the spec-kit installation is incomplete or corrupted." ) - console.print(f"Try reinstalling: {REINSTALL_COMMAND}") + console.print( + f"Try reinstalling: {REINSTALL_COMMAND}" + ) else: zip_path = None try: zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) + preset_manager.install_from_zip( + zip_path, speckit_ver + ) except PresetError as preset_err: _print_cli_warning( "install", @@ -654,7 +825,13 @@ def init( raise except Exception as e: tracker.error("final", str(e)) - console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) + console.print( + Panel( + f"Initialization failed: {e}", + title="Failure", + border_style="red", + ) + ) if debug: _env_pairs = [ ("Python", sys.version.split()[0]), @@ -662,8 +839,17 @@ def init( ("CWD", str(Path.cwd())), ] _label_width = max(len(k) for k, _ in _env_pairs) - env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] - console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) + env_lines = [ + f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" + for k, v in _env_pairs + ] + console.print( + Panel( + "\n".join(env_lines), + title="Debug Environment", + border_style="magenta", + ) + ) if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) @@ -675,14 +861,16 @@ def init( agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: - agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] + agent_folder = ( + ai_commands_dir if selected_ai == "generic" else agent_config["folder"] + ) if agent_folder: security_notice = Panel( f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", title="[yellow]Agent Folder Security[/yellow]", border_style="yellow", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(security_notice) @@ -711,41 +899,72 @@ def init( steps_lines = [] if not here: - steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") + steps_lines.append( + f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]" + ) step_num = 2 else: steps_lines.append("1. You're already in the project directory!") step_num = 2 from ..integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) - codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) - claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) + _is_skills_integration = isinstance( + resolved_integration, _SkillsInt + ) or getattr(resolved_integration, "_skills_mode", False) + + codex_skill_mode = selected_ai == "codex" and ( + ai_skills or _is_skills_integration + ) + claude_skill_mode = selected_ai == "claude" and ( + ai_skills or _is_skills_integration + ) kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" - cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + cursor_agent_skill_mode = selected_ai == "cursor-agent" and ( + ai_skills or _is_skills_integration + ) copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration devin_skill_mode = selected_ai == "devin" - zed_skill_mode = selected_ai == "zed" and (ai_skills or _is_skills_integration) + zed_skill_mode = selected_ai == "zed" cline_skill_mode = selected_ai == "cline" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or zed_skill_mode + native_skill_mode = ( + codex_skill_mode + or claude_skill_mode + or kimi_skill_mode + or agy_skill_mode + or trae_skill_mode + or cursor_agent_skill_mode + or copilot_skill_mode + or devin_skill_mode + or zed_skill_mode + ) if codex_skill_mode and not ai_skills: - steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") + steps_lines.append( + f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" + ) step_num += 1 if claude_skill_mode and not ai_skills: - steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") + steps_lines.append( + f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]" + ) step_num += 1 if cursor_agent_skill_mode and not ai_skills: - steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") + steps_lines.append( + f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]" + ) step_num += 1 if devin_skill_mode: - steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") + steps_lines.append( + f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]" + ) step_num += 1 if zed_skill_mode: - steps_lines.append(f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") + steps_lines.append( + f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" + ) step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" @@ -756,19 +975,42 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or zed_skill_mode or cline_skill_mode: + if ( + cursor_agent_skill_mode + or copilot_skill_mode + or devin_skill_mode + or zed_skill_mode + or cline_skill_mode + ): return f"/speckit-{name}" return f"/speckit.{name}" - steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") + steps_lines.append( + f"{step_num}. Start using {usage_label} with your coding agent:" + ) - steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") - steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") - steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") - steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") - steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") + steps_lines.append( + f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles" + ) + steps_lines.append( + f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification" + ) + steps_lines.append( + f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan" + ) + steps_lines.append( + f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks" + ) + steps_lines.append( + f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation" + ) - steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2)) + steps_panel = Panel( + "\n".join(steps_lines), + title="Next Steps", + border_style="cyan", + padding=(1, 2), + ) console.print() console.print(steps_panel) @@ -782,9 +1024,16 @@ def _display_cmd(name: str) -> str: "", f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)", f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])", - f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])" + f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])", ] - enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands" - enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1, 2)) + enhancements_title = ( + "Enhancement Skills" if native_skill_mode else "Enhancement Commands" + ) + enhancements_panel = Panel( + "\n".join(enhancement_lines), + title=enhancements_title, + border_style="cyan", + padding=(1, 2), + ) console.print() console.print(enhancements_panel) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 82ac06f568..a8d0e5d2df 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -6,38 +6,40 @@ without bloating the core framework. """ -import json +import copy import hashlib +import json import os +import re +import shutil import tempfile import zipfile -import shutil -import copy from dataclasses import dataclass -from pathlib import Path -from typing import Optional, Dict, List, Any, Callable, Set from datetime import datetime, timezone -import re +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Set import pathspec - import yaml from packaging import version as pkg_version -from packaging.specifiers import SpecifierSet, InvalidSpecifier - -from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase - -_FALLBACK_CORE_COMMAND_NAMES = frozenset({ - "analyze", - "checklist", - "clarify", - "constitution", - "implement", - "plan", - "specify", - "tasks", - "taskstoissues", -}) +from packaging.specifiers import InvalidSpecifier, SpecifierSet + +from .catalogs import CatalogEntry as BaseCatalogEntry +from .catalogs import CatalogStackBase + +_FALLBACK_CORE_COMMAND_NAMES = frozenset( + { + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + } +) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" @@ -75,16 +77,19 @@ def _load_core_command_names() -> frozenset[str]: class ExtensionError(Exception): """Base exception for extension-related errors.""" + pass class ValidationError(ExtensionError): """Raised when extension manifest validation fails.""" + pass class CompatibilityError(ExtensionError): """Raised when extension is incompatible with current environment.""" + pass @@ -136,7 +141,7 @@ def __init__(self, manifest_path: Path): def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) except yaml.YAMLError as e: raise ValidationError(f"Invalid YAML in {path}: {e}") @@ -175,7 +180,7 @@ def _validate(self): raise ValidationError(f"Missing extension.{field}") # Validate extension ID format - if not re.match(r'^[a-z0-9-]+$', ext["id"]): + if not re.match(r"^[a-z0-9-]+$", ext["id"]): raise ValidationError( f"Invalid extension ID '{ext['id']}': " "must be lowercase alphanumeric with hyphens only" @@ -198,21 +203,15 @@ def _validate(self): hooks = self.data.get("hooks") if "commands" in provides and not isinstance(commands, list): - raise ValidationError( - "Invalid provides.commands: expected a list" - ) + raise ValidationError("Invalid provides.commands: expected a list") if "hooks" in self.data and not isinstance(hooks, dict): - raise ValidationError( - "Invalid hooks: expected a mapping" - ) + raise ValidationError("Invalid hooks: expected a mapping") has_commands = bool(commands) has_hooks = bool(hooks) if not has_commands and not has_hooks: - raise ValidationError( - "Extension must provide at least one command or hook" - ) + raise ValidationError("Extension must provide at least one command or hook") # Validate hook values (if present) if hooks: @@ -311,9 +310,9 @@ def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]: Returns the corrected name, or None if no safe correction is possible. """ - parts = name.split('.') + parts = name.split(".") if len(parts) == 2: - if parts[0] == 'speckit' or parts[0] == ext_id: + if parts[0] == "speckit" or parts[0] == ext_id: candidate = f"speckit.{ext_id}.{parts[1]}" if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): return candidate @@ -356,7 +355,7 @@ def hooks(self) -> Dict[str, Any]: def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" - with open(self.path, 'rb') as f: + with open(self.path, "rb") as f: return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" @@ -379,35 +378,26 @@ def __init__(self, extensions_dir: Path): def _load(self) -> dict: """Load registry from disk.""" if not self.registry_path.exists(): - return { - "schema_version": self.SCHEMA_VERSION, - "extensions": {} - } + return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} try: - with open(self.registry_path, 'r') as f: + with open(self.registry_path, "r") as f: data = json.load(f) # Validate loaded data is a dict (handles corrupted registry files) if not isinstance(data, dict): - return { - "schema_version": self.SCHEMA_VERSION, - "extensions": {} - } + return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} # Normalize extensions field (handles corrupted extensions value) if not isinstance(data.get("extensions"), dict): data["extensions"] = {} return data except (json.JSONDecodeError, FileNotFoundError): # Corrupted or missing registry, start fresh - return { - "schema_version": self.SCHEMA_VERSION, - "extensions": {} - } + return {"schema_version": self.SCHEMA_VERSION, "extensions": {}} def _save(self): """Save registry to disk.""" self.extensions_dir.mkdir(parents=True, exist_ok=True) - with open(self.registry_path, 'w') as f: + with open(self.registry_path, "w") as f: json.dump(self.data, f, indent=2) def add(self, extension_id: str, metadata: dict): @@ -419,7 +409,7 @@ def add(self, extension_id: str, metadata: dict): """ self.data["extensions"][extension_id] = { **copy.deepcopy(metadata), - "installed_at": datetime.now(timezone.utc).isoformat() + "installed_at": datetime.now(timezone.utc).isoformat(), } self._save() @@ -476,7 +466,9 @@ def restore(self, extension_id: str, metadata: dict): ValueError: If metadata is None or not a dict """ if metadata is None or not isinstance(metadata, dict): - raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict") + raise ValueError( + f"Cannot restore '{extension_id}': metadata must be a dict" + ) # Ensure extensions dict exists (handle corrupted registry) if not isinstance(self.data.get("extensions"), dict): self.data["extensions"] = {} @@ -589,7 +581,9 @@ def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: if not include_disabled and not meta.get("enabled", True): continue metadata_copy = copy.deepcopy(meta) - metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + metadata_copy["priority"] = normalize_priority( + metadata_copy.get("priority", 10) + ) sortable_extensions.append((ext_id, metadata_copy)) return sorted( sortable_extensions, @@ -735,7 +729,9 @@ def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: ) @staticmethod - def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: + def _load_extensionignore( + source_dir: Path, + ) -> Optional[Callable[[str, List[str]], Set[str]]]: """Load .extensionignore and return an ignore function for shutil.copytree. The .extensionignore file uses .gitignore-compatible patterns (one per line). @@ -830,12 +826,16 @@ 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 resolve_active_skills_dir, _print_cli_warning + from . import _print_cli_warning, resolve_active_skills_dir + try: return resolve_active_skills_dir(self.project_root) except (ValueError, OSError) as exc: _print_cli_warning( - "resolve", "skills directory", None, exc, + "resolve", + "skills directory", + None, + exc, continuing="Continuing without skill registration.", ) return None @@ -866,10 +866,11 @@ def _register_extension_skills( if not skills_dir: return [] + import yaml + from . import load_init_options from .agents import CommandRegistrar from .integrations import get_integration - import yaml written: List[str] = [] opts = load_init_options(self.project_root) @@ -904,7 +905,7 @@ def _register_extension_skills( # convention as hook rendering and preset skill registration. short_name_raw = cmd_name if short_name_raw.startswith("speckit."): - short_name_raw = short_name_raw[len("speckit."):] + short_name_raw = short_name_raw[len("speckit.") :] skill_name = f"speckit-{short_name_raw.replace('.', '-')}" # Check if skill already exists before creating the directory @@ -958,20 +959,16 @@ def _register_extension_skills( # Derive a human-friendly title from the command name short_name = cmd_name if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] + short_name = short_name[len("speckit.") :] title_name = short_name.replace(".", " ").replace("-", " ").title() skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# {title_name} Skill\n\n" - f"{body}\n" + f"---\n{frontmatter_text}\n---\n\n# {title_name} Skill\n\n{body}\n" ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content( - skill_content - ) + if integration is not None and hasattr( + integration, "post_process_skill_content" + ): + skill_content = integration.post_process_skill_content(skill_content) if link_outputs: try: @@ -1062,6 +1059,7 @@ def _unregister_extension_skills( continue try: import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") source = "" if raw.startswith("---"): @@ -1086,7 +1084,9 @@ def _unregister_extension_skills( for cfg in AGENT_CONFIG.values(): folder = cfg.get("folder", "") if folder: - candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills") + candidate_dirs.add( + self.project_root / folder.rstrip("/") / "skills" + ) candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR) for skills_candidate in candidate_dirs: @@ -1099,7 +1099,9 @@ def _unregister_extension_skills( continue try: skill_subdir = (skills_candidate / skill_name).resolve() - skill_subdir.relative_to(skills_candidate.resolve()) # raises if outside + skill_subdir.relative_to( + skills_candidate.resolve() + ) # raises if outside except (OSError, ValueError): continue if not skill_subdir.is_dir(): @@ -1113,6 +1115,7 @@ def _unregister_extension_skills( continue try: import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") source = "" if raw.startswith("---"): @@ -1133,9 +1136,7 @@ def _unregister_extension_skills( shutil.rmtree(skill_subdir) def check_compatibility( - self, - manifest: ExtensionManifest, - speckit_version: str + self, manifest: ExtensionManifest, speckit_version: str ) -> bool: """Check if extension is compatible with current spec-kit version. @@ -1240,15 +1241,18 @@ def install_from_directory( hook_executor.register_hooks(manifest) # Update registry - self.registry.add(manifest.id, { - "version": manifest.version, - "source": "local", - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - }) + self.registry.add( + manifest.id, + { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + }, + ) return manifest @@ -1280,7 +1284,7 @@ def install_from_zip( temp_path = Path(tmpdir) # Extract ZIP safely (prevent Zip Slip attack) - with zipfile.ZipFile(zip_path, 'r') as zf: + with zipfile.ZipFile(zip_path, "r") as zf: # Validate all paths first before extracting anything temp_path_resolved = temp_path.resolve() for member in zf.namelist(): @@ -1310,7 +1314,9 @@ def install_from_zip( raise ValidationError("No extension.yml found in ZIP file") # Install from extracted directory - return self.install_from_directory(extension_dir, speckit_version, priority=priority) + return self.install_from_directory( + extension_dir, speckit_version, priority=priority + ) def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. @@ -1327,7 +1333,9 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: # Get registered commands and skills before removal metadata = self.registry.get(extension_id) - registered_commands = metadata.get("registered_commands", {}) if metadata else {} + registered_commands = ( + metadata.get("registered_commands", {}) if metadata else {} + ) raw_skills = metadata.get("registered_skills", []) if metadata else [] # Normalize: must be a list of plain strings to avoid corrupted-registry errors if isinstance(raw_skills, list): @@ -1351,8 +1359,8 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: for child in extension_dir.iterdir(): # Keep top-level *-config.yml and *-config.local.yml files if child.is_file() and ( - child.name.endswith("-config.yml") or - child.name.endswith("-config.local.yml") + child.name.endswith("-config.yml") + or child.name.endswith("-config.local.yml") ): continue if child.is_dir(): @@ -1424,16 +1432,25 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: updates: Dict[str, Any] = {} registered_commands = metadata.get("registered_commands", {}) - if isinstance(registered_commands, dict) and agent_name in registered_commands: - command_names = self._valid_name_list(registered_commands.get(agent_name)) + if ( + isinstance(registered_commands, dict) + and agent_name in registered_commands + ): + command_names = self._valid_name_list( + registered_commands.get(agent_name) + ) if command_names: - registrar.unregister_commands({agent_name: command_names}, self.project_root) + registrar.unregister_commands( + {agent_name: command_names}, self.project_root + ) new_registered = copy.deepcopy(registered_commands) new_registered.pop(agent_name, None) updates["registered_commands"] = new_registered - registered_skills = self._valid_name_list(metadata.get("registered_skills", [])) + registered_skills = self._valid_name_list( + metadata.get("registered_skills", []) + ) if registered_skills: # Only pass the resolved skills_dir when it actually exists. # Otherwise let _unregister_extension_skills fall back to @@ -1531,7 +1548,9 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: registered_skills = self._register_extension_skills(manifest, ext_dir) if registered_skills: - existing_skills = self._valid_name_list(metadata.get("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 @@ -1555,30 +1574,34 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - result.append({ - "id": ext_id, - "name": manifest.name, - "version": metadata.get("version", "unknown"), - "description": manifest.description, - "enabled": metadata.get("enabled", True), - "priority": normalize_priority(metadata.get("priority")), - "installed_at": metadata.get("installed_at"), - "command_count": len(manifest.commands), - "hook_count": len(manifest.hooks) - }) + result.append( + { + "id": ext_id, + "name": manifest.name, + "version": metadata.get("version", "unknown"), + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "priority": normalize_priority(metadata.get("priority")), + "installed_at": metadata.get("installed_at"), + "command_count": len(manifest.commands), + "hook_count": len(manifest.hooks), + } + ) except ValidationError: # Corrupted extension - result.append({ - "id": ext_id, - "name": ext_id, - "version": metadata.get("version", "unknown"), - "description": "⚠️ Corrupted extension", - "enabled": False, - "priority": normalize_priority(metadata.get("priority")), - "installed_at": metadata.get("installed_at"), - "command_count": 0, - "hook_count": 0 - }) + result.append( + { + "id": ext_id, + "name": ext_id, + "version": metadata.get("version", "unknown"), + "description": "⚠️ Corrupted extension", + "enabled": False, + "priority": normalize_priority(metadata.get("priority")), + "installed_at": metadata.get("installed_at"), + "command_count": 0, + "hook_count": 0, + } + ) return result @@ -1631,37 +1654,46 @@ class CommandRegistrar: # Re-export AGENT_CONFIGS at class level for direct attribute access from .agents import CommandRegistrar as _AgentRegistrar + AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS def __init__(self): from .agents import CommandRegistrar as _Registrar + self._registrar = _Registrar() # Delegate static/utility methods @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: from .agents import CommandRegistrar as _Registrar + return _Registrar.parse_frontmatter(content) @staticmethod def render_frontmatter(fm: dict) -> str: 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 + _Registrar.write_copilot_prompt(project_root, cmd_name) def _render_markdown_command(self, frontmatter, body, ext_id): # Preserve extension-specific comment format for backward compatibility context_note = f"\n\n\n" - return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body + return ( + self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body + ) def _render_toml_command(self, frontmatter, body, ext_id): # Preserve extension-specific context comments for backward compatibility base = self._registrar.render_toml_command(frontmatter, body, ext_id) - context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n" + context_lines = ( + f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n" + ) return base.rstrip("\n") + "\n" + context_lines def register_commands_for_agent( @@ -1677,7 +1709,11 @@ def register_commands_for_agent( raise ExtensionError(f"Unsupported agent: {agent_name}") context_note = f"\n\n\n" return self._registrar.register_commands( - agent_name, manifest.commands, manifest.id, extension_dir, project_root, + agent_name, + manifest.commands, + manifest.id, + extension_dir, + project_root, context_note=context_note, link_outputs=link_outputs, ) @@ -1692,15 +1728,16 @@ def register_commands_for_all_agents( """Register extension commands for all detected agents.""" context_note = f"\n\n\n" return self._registrar.register_commands_for_all_agents( - manifest.commands, manifest.id, extension_dir, project_root, + manifest.commands, + manifest.id, + extension_dir, + project_root, context_note=context_note, link_outputs=link_outputs, ) def unregister_commands( - self, - registered_commands: Dict[str, List[str]], - project_root: Path + self, registered_commands: Dict[str, List[str]], project_root: Path ) -> None: """Remove previously registered command files from agent directories.""" self._registrar.unregister_commands(registered_commands, project_root) @@ -1721,7 +1758,9 @@ def register_commands_for_claude( class ExtensionCatalog(CatalogStackBase): """Manages extension catalog fetching, caching, and searching.""" - DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + ) COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" CACHE_DURATION = 3600 # 1 hour in seconds CONFIG_FILENAME = "extension-catalogs.yml" @@ -1747,6 +1786,7 @@ def _make_request(self, url: str): Delegates to :func:`specify_cli.authentication.http.build_request`. """ from specify_cli.authentication.http import build_request + return build_request(url) def _open_url( @@ -1760,6 +1800,7 @@ def _open_url( Delegates to :func:`specify_cli.authentication.http.open_url`. """ from specify_cli.authentication.http import open_url + return open_url(url, timeout, extra_headers=extra_headers) def _resolve_github_release_asset_api_url( @@ -1887,7 +1928,9 @@ def get_catalog_url(self) -> str: active = self.get_active_catalogs() return active[0].url if active else self.DEFAULT_CATALOG_URL - def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + def _fetch_single_catalog( + self, entry: CatalogEntry, force_refresh: bool = False + ) -> Dict[str, Any]: """Fetch a single catalog with per-URL caching. For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file / @@ -1946,10 +1989,15 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False # Save to cache self.cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps(catalog_data, indent=2)) - cache_meta_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": entry.url, - }, indent=2)) + cache_meta_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, + indent=2, + ) + ) return catalog_data @@ -1958,7 +2006,9 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False except json.JSONDecodeError as e: raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}") - def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]: + def _get_merged_extensions( + self, force_refresh: bool = False + ) -> List[Dict[str, Any]]: """Fetch and merge extensions from all active catalogs. Higher-priority (lower priority number) catalogs win on conflicts @@ -2151,7 +2201,9 @@ def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: return ext_data return None - def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path: + def download_extension( + self, extension_id: str, target_dir: Optional[Path] = None + ) -> Path: """Download extension ZIP from catalog. Args: @@ -2185,6 +2237,7 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non # Validate download URL requires HTTPS (prevent man-in-the-middle attacks) from urllib.parse import urlparse + parsed = urlparse(download_url) is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): @@ -2209,14 +2262,18 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non # Download the ZIP file try: - with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response: + with self._open_url( + download_url, timeout=60, extra_headers=extra_headers + ) as response: zip_data = response.read() zip_path.write_bytes(zip_data) return zip_path except urllib.error.URLError as e: - raise ExtensionError(f"Failed to download extension from {download_url}: {e}") + raise ExtensionError( + f"Failed to download extension from {download_url}: {e}" + ) except IOError as e: raise ExtensionError(f"Failed to save extension ZIP: {e}") @@ -2328,7 +2385,7 @@ def _get_env_config(self) -> Dict[str, Any]: continue # Remove prefix and split into parts - config_path = key[len(prefix):].lower().split("_") + config_path = key[len(prefix) :].lower().split("_") # Build nested dict current = env_config @@ -2342,7 +2399,9 @@ def _get_env_config(self) -> Dict[str, Any]: return env_config - def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + def _merge_configs( + self, base: Dict[str, Any], override: Dict[str, Any] + ) -> Dict[str, Any]: """Recursively merge two configuration dictionaries. Args: @@ -2355,7 +2414,11 @@ def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict result = base.copy() for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): # Recursive merge for nested dicts result[key] = self._merge_configs(result[key], value) else: @@ -2469,7 +2532,7 @@ def _skill_name_from_command(command: Any) -> str: command_id = command.strip() if not command_id.startswith("speckit."): return "" - return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}" + return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}" def _render_hook_invocation(self, command: Any) -> str: """Render an agent-specific invocation string for a hook command.""" @@ -2482,10 +2545,16 @@ def _render_hook_invocation(self, command: Any) -> str: init_options = self._load_init_options() selected_ai = init_options.get("ai") - codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) - claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) + codex_skill_mode = selected_ai == "codex" and bool( + init_options.get("ai_skills") + ) + claude_skill_mode = selected_ai == "claude" and bool( + init_options.get("ai_skills") + ) kimi_skill_mode = selected_ai == "kimi" - cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) + cursor_skill_mode = selected_ai == "cursor-agent" and bool( + init_options.get("ai_skills") + ) zed_skill_mode = selected_ai == "zed" cline_mode = selected_ai == "cline" @@ -2499,8 +2568,15 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{format_cline_command_name(command_id)}" - # Slash-skill integrations (Claude, Cursor, Zed…) - if skill_name and (claude_skill_mode or cursor_skill_mode or zed_skill_mode): + # Slash-skill integrations (Claude, Cursor, Zed, Agy, Devin…) + slash_skill_integrations = { + "claude", + "cursor-agent", + "zed", + "agy", + "devin", + } + if skill_name and selected_ai in slash_skill_integrations: return f"/{skill_name}" return f"/{command_id}" @@ -2543,7 +2619,9 @@ def get_project_config(self) -> Dict[str, Any]: if not isinstance(event_val, list): result["hooks"][event_key] = [] else: - result["hooks"][event_key] = [h for h in event_val if isinstance(h, dict)] + result["hooks"][event_key] = [ + h for h in event_val if isinstance(h, dict) + ] return result except (yaml.YAMLError, OSError, UnicodeError): return { @@ -2560,7 +2638,9 @@ def save_project_config(self, config: Dict[str, Any]): """ self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config_file.write_text( - yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + yaml.dump( + config, default_flow_style=False, sort_keys=False, allow_unicode=True + ), encoding="utf-8", ) @@ -2622,7 +2702,7 @@ def _sanitize_installed_list( Returns: A sanitized, deduplicated, alphabetically-sorted list. """ - _VALID_ID = re.compile(r'^[a-z0-9-]+$') + _VALID_ID = re.compile(r"^[a-z0-9-]+$") installed = raw if isinstance(raw, list) else [] @@ -2696,7 +2776,9 @@ def register_hooks(self, manifest: ExtensionManifest): # Register each hook for hook_name, hook_config in manifest.hooks.items(): - if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): + if hook_name not in config["hooks"] or not isinstance( + config["hooks"][hook_name], list + ): config["hooks"][hook_name] = [] changed = True @@ -2719,7 +2801,8 @@ def register_hooks(self, manifest: ExtensionManifest): # duplicate entries behind. (Feedback from review) original_list = config["hooks"][hook_name] deduped = [ - h for h in original_list + h + for h in original_list if not (isinstance(h, dict) and h.get("extension") == manifest.id) ] deduped.append(hook_entry) @@ -2743,7 +2826,7 @@ def unregister_hooks(self, extension_id: str): if not isinstance(config, dict): config = {} - # We don't save yet, as there are no hooks to unregister, + # We don't save yet, as there are no hooks to unregister, # but unregister_extension above might have already saved a normalized config. return @@ -2827,7 +2910,9 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo condition = condition.strip() # Pattern: "config.key.path is set" - if match := re.match(r'config\.([a-z0-9_.]+)\s+is\s+set', condition, re.IGNORECASE): + if match := re.match( + r"config\.([a-z0-9_.]+)\s+is\s+set", condition, re.IGNORECASE + ): key_path = match.group(1) if not extension_id: return False @@ -2836,7 +2921,11 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo return config_manager.has_value(key_path) # Pattern: "config.key.path == 'value'" or "config.key.path != 'value'" - if match := re.match(r'config\.([a-z0-9_.]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE): + if match := re.match( + r'config\.([a-z0-9_.]+)\s*(==|!=)\s*["\']([^"\']+)["\']', + condition, + re.IGNORECASE, + ): key_path = match.group(1) operator = match.group(2) expected_value = match.group(3) @@ -2860,12 +2949,16 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo return normalized_value != expected_value # Pattern: "env.VAR_NAME is set" - if match := re.match(r'env\.([A-Z0-9_]+)\s+is\s+set', condition, re.IGNORECASE): + if match := re.match(r"env\.([A-Z0-9_]+)\s+is\s+set", condition, re.IGNORECASE): var_name = match.group(1).upper() return var_name in os.environ # Pattern: "env.VAR_NAME == 'value'" or "env.VAR_NAME != 'value'" - if match := re.match(r'env\.([A-Z0-9_]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE): + if match := re.match( + r'env\.([A-Z0-9_]+)\s*(==|!=)\s*["\']([^"\']+)["\']', + condition, + re.IGNORECASE, + ): var_name = match.group(1).upper() operator = match.group(2) expected_value = match.group(3) @@ -2880,9 +2973,7 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo # Unknown condition format, default to False for safety return False - def format_hook_message( - self, event_name: str, hooks: List[Dict[str, Any]] - ) -> str: + def format_hook_message(self, event_name: str, hooks: List[Dict[str, Any]]) -> str: """Format hook execution message for display in command output. Args: @@ -2902,9 +2993,15 @@ def format_hook_message( extension = hook.get("extension") command = hook.get("command") invocation = self._render_hook_invocation(command) - command_text = command if isinstance(command, str) and command.strip() else "" + command_text = ( + command + if isinstance(command, str) and command.strip() + else "" + ) display_invocation = invocation or ( - f"/{command_text}" if command_text != "" else "/" + f"/{command_text}" + if command_text != "" + else "/" ) optional = hook.get("optional", True) prompt = hook.get("prompt", "") @@ -2942,11 +3039,7 @@ def check_hooks_for_event(self, event_name: str) -> Dict[str, Any]: hooks = self.get_hooks_for_event(event_name) if not hooks: - return { - "has_hooks": False, - "hooks": [], - "message": "" - } + return {"has_hooks": False, "hooks": [], "message": ""} # Filter hooks by condition executable_hooks = [] @@ -2958,13 +3051,13 @@ def check_hooks_for_event(self, event_name: str) -> Dict[str, Any]: return { "has_hooks": False, "hooks": [], - "message": f"# No executable hooks for event '{event_name}' (conditions not met)" + "message": f"# No executable hooks for event '{event_name}' (conditions not met)", } return { "has_hooks": True, "hooks": executable_hooks, - "message": self.format_hook_message(event_name, executable_hooks) + "message": self.format_hook_message(event_name, executable_hooks), } def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]: @@ -2989,7 +3082,7 @@ def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]: "extension": hook.get("extension"), "optional": hook.get("optional", True), "description": hook.get("description", ""), - "prompt": hook.get("prompt", "") + "prompt": hook.get("prompt", ""), } def enable_hooks(self, extension_id: str): diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index ebe9a50d5a..d53b066284 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -1,6 +1,7 @@ """Tests for ZedIntegration.""" import json +import os from specify_cli.integrations import get_integration @@ -69,8 +70,6 @@ def test_init_persists_ai_skills_for_zed(self, tmp_path): project.mkdir() old_cwd = None try: - import os - old_cwd = os.getcwd() os.chdir(project) runner = CliRunner() From 429ce894a20adc6c3bca53fcd54d82e23ca9778a Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 08:44:34 +0300 Subject: [PATCH 08/13] fix: refine slash-skill logic and ai-skills validation - Fix slash-skill integrations: Claude/Cursor require ai_skills=true; Zed/Agy/Devin are always skills - Allow --ai-skills with --integration (not just --ai) to fix validation error --- src/specify_cli/commands/init.py | 7 +++++-- src/specify_cli/extensions.py | 20 +++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fac37fae09..65c9653ce8 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -335,10 +335,13 @@ def init( ) raise typer.Exit(1) - if ai_skills and not ai_assistant: - console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + if ai_skills and not (ai_assistant or integration): + console.print( + "[red]Error:[/red] --ai-skills requires --ai or --integration to be specified" + ) console.print( "[yellow]Usage:[/yellow] specify init --ai --ai-skills" + " or: specify init --integration --ai-skills" ) raise typer.Exit(1) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 9c973770a5..f271201d98 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2614,15 +2614,17 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{format_cline_command_name(command_id)}" - # Slash-skill integrations (Claude, Cursor, Zed, Agy, Devin…) - slash_skill_integrations = { - "claude", - "cursor-agent", - "zed", - "agy", - "devin", - } - if skill_name and selected_ai in slash_skill_integrations: + # Slash-skill integrations (Claude, Cursor when ai_skills enabled, Zed, Agy, Devin) + # Zed/Agy/Devin are always skills-based; Claude/Cursor are conditional + always_slash_skill = {"zed", "agy", "devin"} + conditional_slash_skill = {"claude", "cursor-agent"} + if skill_name and ( + selected_ai in always_slash_skill + or ( + selected_ai in conditional_slash_skill + and bool(init_options.get("ai_skills")) + ) + ): return f"/{skill_name}" return f"/{command_id}" From 04d3eb3c311c6e64786bc40631ee73eed152d46b Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 18:29:26 +0300 Subject: [PATCH 09/13] fix: remove unused variables and update ai-skills help text - Add agy_skill_mode and devin_skill_mode variables to fix F841 lint error - Use all skill mode variables in the slash-skill conditional check - Update --ai-skills help text to reflect it works with --integration too --- src/specify_cli/commands/init.py | 2 +- src/specify_cli/extensions.py | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 65c9653ce8..540eaa8fa1 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -154,7 +154,7 @@ def init( ai_skills: bool = typer.Option( False, "--ai-skills", - help="Install Prompt.MD templates as agent skills (requires --ai)", + help="Install Prompt.MD templates as agent skills (works with --ai or --integration; skills integrations install automatically)", ), offline: bool = typer.Option( False, diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index f271201d98..fa31aed614 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1274,9 +1274,13 @@ def install_from_directory( backup_config_dir.unlink() elif backup_config_dir.is_dir(): for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file() and not cfg_file.is_symlink() and ( - cfg_file.name.endswith("-config.yml") or - cfg_file.name.endswith("-config.local.yml") + if ( + cfg_file.is_file() + and not cfg_file.is_symlink() + and ( + cfg_file.name.endswith("-config.yml") + or cfg_file.name.endswith("-config.local.yml") + ) ): shutil.copy2(cfg_file, dest_dir / cfg_file.name) shutil.rmtree(backup_config_dir) @@ -2602,6 +2606,8 @@ def _render_hook_invocation(self, command: Any) -> str: init_options.get("ai_skills") ) zed_skill_mode = selected_ai == "zed" + agy_skill_mode = selected_ai == "agy" + devin_skill_mode = selected_ai == "devin" cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) @@ -2614,16 +2620,15 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{format_cline_command_name(command_id)}" - # Slash-skill integrations (Claude, Cursor when ai_skills enabled, Zed, Agy, Devin) - # Zed/Agy/Devin are always skills-based; Claude/Cursor are conditional - always_slash_skill = {"zed", "agy", "devin"} - conditional_slash_skill = {"claude", "cursor-agent"} + # Slash-skill integrations + # - Claude/Cursor: conditional on ai_skills flag + # - Zed/Agy/Devin: always skills-based if skill_name and ( - selected_ai in always_slash_skill - or ( - selected_ai in conditional_slash_skill - and bool(init_options.get("ai_skills")) - ) + claude_skill_mode + or cursor_skill_mode + or zed_skill_mode + or agy_skill_mode + or devin_skill_mode ): return f"/{skill_name}" From 3d2c70b4162898dd4e58daa8525ec97dad8f778c Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 22:15:20 +0300 Subject: [PATCH 10/13] fix: add trae_skill_mode to hook invocation for consistency Trae is a SkillsIntegration like Zed/Agy/Devin, so it should also be treated as always-skills-based in hook invocation rendering. --- src/specify_cli/extensions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index fa31aed614..1bfcd2bcf4 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2606,6 +2606,7 @@ def _render_hook_invocation(self, command: Any) -> str: init_options.get("ai_skills") ) zed_skill_mode = selected_ai == "zed" + trae_skill_mode = selected_ai == "trae" agy_skill_mode = selected_ai == "agy" devin_skill_mode = selected_ai == "devin" cline_mode = selected_ai == "cline" @@ -2622,13 +2623,14 @@ def _render_hook_invocation(self, command: Any) -> str: # Slash-skill integrations # - Claude/Cursor: conditional on ai_skills flag - # - Zed/Agy/Devin: always skills-based + # - Zed/Agy/Devin/Trae: always skills-based if skill_name and ( claude_skill_mode or cursor_skill_mode or zed_skill_mode or agy_skill_mode or devin_skill_mode + or trae_skill_mode ): return f"/{skill_name}" From cb89f7d9b70d93166acf73f58c2369fb1008213f Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 22:33:20 +0300 Subject: [PATCH 11/13] fix: make Agy always skills-based for consistency AgyIntegration is a SkillsIntegration subclass with no --skills option, so it should be treated as always skills-based (like Zed, Devin, Trae). This aligns init.py skill mode detection with extensions.py hook rendering. --- src/specify_cli/commands/init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 540eaa8fa1..005674fd2d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -923,7 +923,7 @@ def init( ai_skills or _is_skills_integration ) kimi_skill_mode = selected_ai == "kimi" - agy_skill_mode = selected_ai == "agy" and _is_skills_integration + agy_skill_mode = selected_ai == "agy" trae_skill_mode = selected_ai == "trae" cursor_agent_skill_mode = selected_ai == "cursor-agent" and ( ai_skills or _is_skills_integration From 8a2c27801cbfe5c2328316348a7da7b6e5b62e0b Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 22:55:44 +0300 Subject: [PATCH 12/13] fix: gate agy_skill_mode and refactor _render_hook_invocation to use sets Addressed Copilot review comments: - Restored _is_skills_integration guard on agy_skill_mode in init.py to be defensive about runtime integration type. - Refactored _render_hook_invocation() in extensions.py to use always_slash/conditional_slash frozensets instead of individual per-agent booleans, eliminating unused variables (F841) and making it harder for conditions to drift between integrations. - Centralized slash-skill determination so adding a new unconditional slash-skill integration is a one-key addition. --- src/specify_cli/commands/init.py | 2 +- src/specify_cli/extensions.py | 40 +++++++++++++------------------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 005674fd2d..540eaa8fa1 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -923,7 +923,7 @@ def init( ai_skills or _is_skills_integration ) kimi_skill_mode = selected_ai == "kimi" - agy_skill_mode = selected_ai == "agy" + agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" cursor_agent_skill_mode = selected_ai == "cursor-agent" and ( ai_skills or _is_skills_integration diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 1bfcd2bcf4..1e6b85b952 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2595,20 +2595,12 @@ def _render_hook_invocation(self, command: Any) -> str: init_options = self._load_init_options() selected_ai = init_options.get("ai") - codex_skill_mode = selected_ai == "codex" and bool( - init_options.get("ai_skills") - ) - claude_skill_mode = selected_ai == "claude" and bool( - init_options.get("ai_skills") - ) + ai_skills = bool(init_options.get("ai_skills")) + + codex_skill_mode = selected_ai == "codex" and ai_skills + claude_skill_mode = selected_ai == "claude" and ai_skills kimi_skill_mode = selected_ai == "kimi" - cursor_skill_mode = selected_ai == "cursor-agent" and bool( - init_options.get("ai_skills") - ) - zed_skill_mode = selected_ai == "zed" - trae_skill_mode = selected_ai == "trae" - agy_skill_mode = selected_ai == "agy" - devin_skill_mode = selected_ai == "devin" + cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) @@ -2621,17 +2613,17 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{format_cline_command_name(command_id)}" - # Slash-skill integrations - # - Claude/Cursor: conditional on ai_skills flag - # - Zed/Agy/Devin/Trae: always skills-based - if skill_name and ( - claude_skill_mode - or cursor_skill_mode - or zed_skill_mode - or agy_skill_mode - or devin_skill_mode - or trae_skill_mode - ): + # Agents that use /speckit- (slash-skills invocation): + # - Unconditional: agy, devin, trae, zed + # - Conditional on ai_skills: claude, cursor-agent + always_slash: frozenset[str] = frozenset({"agy", "devin", "trae", "zed"}) + conditional_slash: frozenset[str] = frozenset({"claude", "cursor-agent"}) + + use_slash = selected_ai in always_slash or ( + selected_ai in conditional_slash and ai_skills + ) + + if skill_name and use_slash: return f"/{skill_name}" return f"/{command_id}" From 5466d620af8de48b6aa3179364c5e805a7fc1fcc Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 4 Jun 2026 23:15:34 +0300 Subject: [PATCH 13/13] fix: address latest Copilot review comments - Added copilot to CONDITIONAL_SLASH_AGENTS for consistent hook invocation rendering with init.py - Moved always_slash/conditional_slash frozensets to module scope to avoid per-call reallocation - Replaced manual os.chdir() with monkeypatch.chdir() in test - Overrode test_options_include_skills_flag for Zed (no --skills) --- src/specify_cli/extensions.py | 17 +++++--- tests/integrations/test_integration_zed.py | 45 ++++++++++------------ 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 2b8ae3f0ba..5acbd85a85 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -41,6 +41,14 @@ "taskstoissues", } ) + +# Agents that use /speckit- (slash-skills invocation) in hook messages. +# - ALWAYS_SLASH_AGENTS: always render as /speckit- regardless of ai_skills. +# - CONDITIONAL_SLASH_AGENTS: render as /speckit- only when ai_skills is enabled. +ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"}) +CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( + {"agy", "claude", "copilot", "cursor-agent"} +) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" @@ -2663,12 +2671,9 @@ def _render_hook_invocation(self, command: Any) -> str: # Agents that use /speckit- (slash-skills invocation): # - Always skills-based: devin, trae, zed - # - Conditional on ai_skills: agy, claude, cursor-agent - always_slash: frozenset[str] = frozenset({"devin", "trae", "zed"}) - conditional_slash: frozenset[str] = frozenset({"agy", "claude", "cursor-agent"}) - - use_slash = selected_ai in always_slash or ( - selected_ai in conditional_slash and ai_skills_enabled + # - Conditional on ai_skills: agy, claude, copilot, cursor-agent + use_slash = selected_ai in ALWAYS_SLASH_AGENTS or ( + selected_ai in CONDITIONAL_SLASH_AGENTS and ai_skills_enabled ) if skill_name and use_slash: diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index d53b066284..e66183723a 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -1,7 +1,6 @@ """Tests for ZedIntegration.""" import json -import os from specify_cli.integrations import get_integration @@ -19,7 +18,9 @@ def test_options_include_skills_flag(self): """Zed is always skills-based; no --skills option needed.""" i = get_integration(self.KEY) skills_opts = [o for o in i.options() if o.name == "--skills"] - assert len(skills_opts) == 0 + assert len(skills_opts) == 0, ( + "Zed is always skills-based and should not expose a --skills option" + ) def test_requires_cli_is_false(self): """Zed is IDE-based; requires_cli must remain False.""" @@ -57,7 +58,7 @@ def test_hooks_render_skill_invocation(self, tmp_path): assert "EXECUTE_COMMAND: speckit.plan" in message assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message - def test_init_persists_ai_skills_for_zed(self, tmp_path): + def test_init_persists_ai_skills_for_zed(self, tmp_path, monkeypatch): """specify init --integration zed must persist ai_skills: true, so HookExecutor renders slash-skill invocations without manual init-options manipulation.""" @@ -68,28 +69,22 @@ def test_init_persists_ai_skills_for_zed(self, tmp_path): project = tmp_path / "zed-init-test" project.mkdir() - old_cwd = None - try: - old_cwd = os.getcwd() - os.chdir(project) - runner = CliRunner() - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - "zed", - "--script", - "sh", - "--no-git", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - finally: - if old_cwd is not None: - os.chdir(old_cwd) + monkeypatch.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "zed", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) assert result.exit_code == 0, f"init failed: {result.output}"