diff --git a/README.md b/README.md index afca9b15a5..3eb82395c1 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ specify init . --force --integration copilot specify init --here --force --integration copilot ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a790389774..1fe4a53640 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -38,6 +38,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | | [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | | [Windsurf](https://windsurf.com/) | `windsurf` | | +| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | | [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | | Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | diff --git a/integrations/catalog.json b/integrations/catalog.json index 33c6ddd931..f89af37d5c 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -299,6 +299,15 @@ "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] + }, + "zcode": { + "id": "zcode", + "name": "ZCode", + "version": "1.0.0", + "description": "Z.AI ZCode CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills", "z-ai"] } } } diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index a61f699a53..627967cfbd 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -8,6 +8,9 @@ from __future__ import annotations +# Agents that render $speckit- (chat invocation) when in skills mode. +DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"}) + # Agents that always render /speckit-, regardless of ai_skills. ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"}) @@ -26,6 +29,17 @@ ) +def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: + """Return ``True`` if *selected_ai* uses ``$speckit-`` invocations. + + Agents in `DOLLAR_SKILLS_AGENTS` (e.g. ``codex``, ``zcode``) render + ``$speckit-`` chat invocations when installed in skills mode. + """ + if not isinstance(selected_ai, str): + return False + return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled + + def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: """Return ``True`` if *selected_ai* uses ``/speckit-`` invocations. diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 997b9ee679..fc82334da2 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -693,6 +693,7 @@ def init( ) or getattr(resolved_integration, "_skills_mode", False) codex_skill_mode = selected_ai == "codex" and _is_skills_integration + zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration claude_skill_mode = selected_ai == "claude" and _is_skills_integration kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration @@ -706,6 +707,7 @@ def init( cline_skill_mode = selected_ai == "cline" native_skill_mode = ( codex_skill_mode + or zcode_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode @@ -721,6 +723,11 @@ def init( f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]" ) step_num += 1 + if zcode_skill_mode: + steps_lines.append( + f"{step_num}. Start ZCode in this project directory; spec-kit skills were installed to [cyan].zcode/skills[/cyan]" + ) + step_num += 1 if claude_skill_mode: steps_lines.append( f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]" @@ -743,7 +750,10 @@ def init( step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" - from .._invocation_style import is_slash_skills_agent as _is_slash_skills_agent + from .._invocation_style import ( + is_dollar_skills_agent as _is_dollar_skills_agent, + is_slash_skills_agent as _is_slash_skills_agent, + ) # `_is_skills_integration` means the integration is installed in # skills mode, which is the semantic equivalent of `ai_skills_enabled` @@ -751,7 +761,7 @@ def init( _ai_skills_enabled = _is_skills_integration def _display_cmd(name: str) -> str: - if codex_skill_mode: + if _is_dollar_skills_agent(selected_ai, _ai_skills_enabled): return f"$speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index ecc26fa877..56daf20721 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -27,7 +27,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from ._init_options import is_ai_skills_enabled -from ._invocation_style import is_slash_skills_agent +from ._invocation_style import is_dollar_skills_agent, is_slash_skills_agent from ._utils import dump_frontmatter from .catalogs import CatalogEntry as BaseCatalogEntry from .catalogs import CatalogStackBase @@ -2848,12 +2848,12 @@ def _render_hook_invocation(self, command: Any) -> str: selected_ai = init_options.get("ai") ai_skills_enabled = is_ai_skills_enabled(init_options) - codex_skill_mode = selected_ai == "codex" and ai_skills_enabled + dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled) kimi_skill_mode = selected_ai == "kimi" cline_mode = selected_ai == "cline" skill_name = self._skill_name_from_command(command_id) - if codex_skill_mode and skill_name: + if dollar_skill_mode and skill_name: return f"${skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 07d3cc1a6d..a81d705543 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -80,6 +80,7 @@ def _register_builtins() -> None: from .trae import TraeIntegration from .vibe import VibeIntegration from .windsurf import WindsurfIntegration + from .zcode import ZcodeIntegration from .zed import ZedIntegration # -- Registration (alphabetical) -------------------------------------- @@ -116,6 +117,7 @@ def _register_builtins() -> None: _register(TraeIntegration()) _register(VibeIntegration()) _register(WindsurfIntegration()) + _register(ZcodeIntegration()) _register(ZedIntegration()) diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py new file mode 100644 index 0000000000..ea47f31555 --- /dev/null +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -0,0 +1,43 @@ +"""ZCode integration — skills-based agent (Z.AI). + +ZCode uses the ``.zcode/skills/speckit-/SKILL.md`` layout, matching +the Claude Code skill format. Skills are invoked in chat with +``$speckit-``. Z.AI recommends skills (over simple ``/`` commands) +for template- and script-driven workflows such as spec-kit. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class ZcodeIntegration(SkillsIntegration): + """Integration for ZCode CLI (Z.AI).""" + + key = "zcode" + config = { + "name": "ZCode", + "folder": ".zcode/", + "commands_subdir": "skills", + "install_url": "https://zcode.z.ai/", + "requires_cli": True, + } + registrar_config = { + "dir": ".zcode/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "ZCODE.md" + multi_install_safe = True + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for ZCode)", + ), + ] diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py new file mode 100644 index 0000000000..3eb82ed4f2 --- /dev/null +++ b/tests/integrations/test_integration_zcode.py @@ -0,0 +1,38 @@ +"""Tests for ZcodeIntegration — skills-based integration (Z.AI).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestZcodeIntegration(SkillsIntegrationTests): + KEY = "zcode" + FOLDER = ".zcode/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".zcode/skills" + CONTEXT_FILE = "ZCODE.md" + + +class TestZcodeInvocation: + """ZCode renders $speckit-* chat invocations (like Codex).""" + + def test_next_steps_show_dollar_skill_invocation(self, tmp_path): + """ZCode next-steps guidance should display $speckit-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "zcode-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", "zcode", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e063571b14..daa2acd921 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6018,6 +6018,24 @@ def test_codex_hooks_render_dollar_skill_invocation(self, project_dir): assert execution["command"] == "speckit.tasks" assert execution["invocation"] == "$speckit-tasks" + def test_zcode_hooks_render_dollar_skill_invocation(self, project_dir): + """ZCode projects with skills mode should render $speckit-* invocations.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "zcode", "ai_skills": True})) + + hook_executor = HookExecutor(project_dir) + execution = hook_executor.execute_hook( + { + "extension": "test-ext", + "command": "speckit.tasks", + "optional": False, + } + ) + + assert execution["command"] == "speckit.tasks" + assert execution["invocation"] == "$speckit-tasks" + def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir): """Corrupted truthy ai_skills values should not enable skill invocation.""" init_options = project_dir / ".specify" / "init-options.json"