From 6a3ee9b64e06d259a218aa8eb5f84de80a053928 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:14:18 +0800 Subject: [PATCH] feat: add ZCode (Z.AI) integration (#3063) * feat: add ZCode (Z.AI) integration Add a skills-based integration for ZCode, Z.AI's Claude-Code-style agent. ZCode uses the same SKILL.md layout as Claude Code, so spec-kit installs workflows into .zcode/skills/speckit-/SKILL.md, invoked in chat as $speckit-. - ZcodeIntegration(SkillsIntegration) with .zcode/ folder and --skills option - Register in INTEGRATION_REGISTRY - Catalog entry (tags: cli, skills, z-ai) - Tests via SkillsIntegrationTests mixin - Document in integrations reference and README Co-Authored-By: Claude Opus 4.8 * fix: render $speckit-* invocations for ZCode skills ZCode is documented as a skills agent invoked with $speckit-, but the central invocation rendering only special-cased codex, so specify init Next Steps and extension hooks rendered the dotted /speckit. form instead. Centralize the $speckit-* decision in a DOLLAR_SKILLS_AGENTS set with an is_dollar_skills_agent() helper, and route both init Next Steps and HookExecutor._render_hook_invocation through it. Add ZCode invocation regression tests mirroring the existing Codex/Kimi coverage. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- docs/reference/integrations.md | 1 + integrations/catalog.json | 9 ++++ src/specify_cli/_invocation_style.py | 14 ++++++ src/specify_cli/commands/init.py | 14 +++++- src/specify_cli/extensions.py | 6 +-- src/specify_cli/integrations/__init__.py | 2 + .../integrations/zcode/__init__.py | 43 +++++++++++++++++++ tests/integrations/test_integration_zcode.py | 38 ++++++++++++++++ tests/test_extensions.py | 18 ++++++++ 10 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/specify_cli/integrations/zcode/__init__.py create mode 100644 tests/integrations/test_integration_zcode.py diff --git a/README.md b/README.md index 34e140332..15d016ef9 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ specify init . --force --integration copilot specify init --here --force --integration copilot ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a79038977..1fe4a5364 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 33c6ddd93..f89af37d5 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 a61f699a5..627967cfb 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -8,6 +8,9 @@ and ``specify init``'s next-steps output stay consistent. 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 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( ) +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 997b9ee67..fc82334da 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -693,6 +693,7 @@ def register(app: typer.Typer) -> None: ) 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 register(app: typer.Typer) -> None: 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 register(app: typer.Typer) -> None: 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 register(app: typer.Typer) -> None: 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 register(app: typer.Typer) -> None: _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 72d1e66e9..a66511b3c 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -27,7 +27,7 @@ from packaging import version as pkg_version from packaging.specifiers import InvalidSpecifier, SpecifierSet from ._init_options import is_ai_skills_enabled -from ._invocation_style import is_slash_skills_agent +from ._invocation_style import is_dollar_skills_agent, is_slash_skills_agent from ._utils import dump_frontmatter, relative_extension_path_violation from .catalogs import CatalogEntry as BaseCatalogEntry from .catalogs import CatalogStackBase @@ -2886,12 +2886,12 @@ class HookExecutor: 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 07d3cc1a6..a81d70554 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 000000000..ea47f3155 --- /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 000000000..3eb82ed4f --- /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 9cf0167ce..36f0818e2 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6052,6 +6052,24 @@ class TestHookInvocationRendering: 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"