diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index df113a74be..cf43dac0e7 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -10,7 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Amp](https://ampcode.com/) | `amp` | | | [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; installs skills into `.agents/skills/` and invokes them as `/speckit-`. In headless non-interactive runs, automatic tool approval via `--dangerously-skip-permissions` can be enabled by setting `SPECKIT_AGY_ALLOW_ALL_TOOLS=1` or `SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS=1`. | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | -| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills`. Pass `--integration-options="--no-model-invocation"` to make the core command skills user-invocable only (see [Claude: user-invocable-only skills](#claude-user-invocable-only-skills)). | | [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | | [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | | | [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | @@ -440,6 +440,7 @@ Some integrations accept additional options via `--integration-options`: | `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) | | `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. | | `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. | +| `claude` | `--no-model-invocation` | Write `disable-model-invocation: true` into the core Spec Kit command skills so Claude Code runs them only when invoked as `/speckit-` and keeps their descriptions out of its context. See [Claude: user-invocable-only skills](#claude-user-invocable-only-skills). | Example: @@ -461,6 +462,38 @@ skills use `$speckit-github-taskstoissues`; Kimi uses `/skill:speckit-github-taskstoissues`. The core command continues its existing workflow and does not install or enable the extension automatically. +### Claude: user-invocable-only skills + +By default every Spec Kit skill installed for Claude Code is model-invocable, so +its description is always in Claude's context and counts toward the +skill-listing budget. In projects with many skills this can push other skills +out of the listing. To make the core Spec Kit command skills user-invocable +only: + +```bash +specify init my-project --integration claude --integration-options="--no-model-invocation" +``` + +- Extension skills stay model-invocable, because mandatory extension hooks rely + on the model invoking them. If an extension's mandatory hook runs a core + command (its `command:` is a core `speckit.`), leave this option off: + the model cannot run that hook while the core skill is user-invocable only. +- The option is stored with the integration's other options and reapplied by + `specify integration upgrade claude`. Passing `--integration-options` to + `upgrade` replaces all stored options, so repeat any you want to keep (for + example `--integration-options="--events false --no-model-invocation"`); + `--integration-options=""` clears them. Re-running `specify init` likewise + stores only the options passed to it. +- Core command skills that a preset overrides, or stops overriding, are reverted + to the default by commands that re-register preset skills without reinstalling + Claude: commands that install, remove or update presets (including `specify + bundle` commands that carry presets), `specify integration use claude`, and + `specify integration switch claude` when Claude is installed but not the + default. To reapply the option, run `specify integration upgrade claude + --force` while Claude is the default integration (`--force` is needed because + those commands modify installed skill files, and it overwrites local edits to + Spec Kit-managed files). + ## Scaffold a New Integration ```bash diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 9a14cf50b0..03d428bc51 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations +from pathlib import Path from typing import Any -from ..base import SkillsIntegration +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest from ..._utils import dump_frontmatter # Mapping of command template stem → argument-hint text shown inline @@ -65,6 +67,49 @@ class ClaudeIntegration(SkillsIntegration): events_config_file = ".claude/settings.json" events_format = "json-nested" + # Set by ``setup()`` from ``--no-model-invocation``; applies to core + # command skills only, since mandatory extension hooks rely on the model + # invoking the hook skill. Integrations are process-wide singletons, so + # preset overrides re-registered later in the same run (init, upgrade, + # or a switch that installs Claude) see the same value. Commands that + # rewrite skills without calling setup() (e.g. ``preset add``, + # ``integration use``) get the default. + _disable_model_invocation: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + opts = super().options() + opts.append( + IntegrationOption( + "--no-model-invocation", + is_flag=True, + default=False, + help=( + "Install core command skills as user-invocable only " + "(disable-model-invocation: true)" + ), + ) + ) + return opts + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + self._disable_model_invocation = ( + (parsed_options or {}).get("no_model_invocation") is True + ) + try: + return super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + except BaseException: + self._disable_model_invocation = False + raise + @staticmethod def inject_argument_hint(content: str, hint: str) -> str: """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. @@ -200,12 +245,8 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str return "".join(out) @staticmethod - def _skill_stem_from_content(content: str) -> str | None: - """Derive the command stem (e.g. ``analyze``) from a skill's frontmatter. - - Reads the ``name:`` field of the first frontmatter block and strips - the ``speckit-`` prefix. Returns ``None`` when no name is present. - """ + def _skill_name_from_content(content: str) -> str | None: + """Return the ``name:`` field of a skill's first frontmatter block.""" dash_count = 0 for line in content.splitlines(): stripped = line.rstrip("\r\n") @@ -216,11 +257,17 @@ def _skill_stem_from_content(content: str) -> str | None: continue if dash_count == 1 and stripped.startswith("name:"): name = stripped[len("name:"):].strip().strip('"').strip("'") - if name.startswith("speckit-"): - return name[len("speckit-"):] return name or None return None + @staticmethod + def _is_core_command_skill(name: str | None) -> bool: + from specify_cli.extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + + return bool(name) and name.startswith("speckit-") and ( + name.removeprefix("speckit-") in CORE_COMMAND_NAMES + ) + def post_process_skill_content(self, content: str) -> str: """Inject Claude-specific frontmatter flags, hook notes, and any per-command frontmatter. @@ -228,12 +275,20 @@ def post_process_skill_content(self, content: str) -> str: Applied by every skill-generation path (setup, presets, extensions), so command-specific frontmatter (argument-hint, fork context) stays consistent however the SKILL.md was produced. + + ``disable-model-invocation`` is ``true`` for core command skills when + the last ``setup()`` in this process was given + ``--no-model-invocation``, else ``false``. """ updated = super().post_process_skill_content(content) updated = self._inject_frontmatter_flag(updated, "user-invocable") - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + name = self._skill_name_from_content(updated) + disable = self._disable_model_invocation and self._is_core_command_skill(name) + updated = self._inject_frontmatter_flag( + updated, "disable-model-invocation", "true" if disable else "false" + ) - stem = self._skill_stem_from_content(updated) + stem = name.removeprefix("speckit-") if name else None if stem: hint = ARGUMENT_HINTS.get(stem, "") if hint: diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8671b7c445..f275737075 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import yaml from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration @@ -12,6 +13,23 @@ from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from specify_cli.integrations.manifest import IntegrationManifest +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(autouse=True) +def _reset_claude_model_invocation(): + """``setup()`` stores ``--no-model-invocation`` on the process-wide Claude + singleton; keep it from leaking between tests.""" + claude = get_integration("claude") + claude._disable_model_invocation = False + yield + claude._disable_model_invocation = False + + +def _skill_frontmatter(skill_file: Path) -> dict: + content = skill_file.read_text(encoding="utf-8") + return yaml.safe_load(content.split("---", 2)[1]) + class TestClaudeIntegration: def test_registered(self): @@ -591,6 +609,232 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p assert agy.post_process_skill_content(content) == content +class TestClaudeNoModelInvocationOption: + """``--integration-options="--no-model-invocation"`` opts out of model invocation (#2654).""" + + def test_options_include_no_model_invocation_flag(self): + opts = get_integration("claude").options() + matching = [o for o in opts if o.name == "--no-model-invocation"] + assert len(matching) == 1 + assert matching[0].is_flag is True + # Opt-in: skills stay model-invocable unless the flag is passed. + assert matching[0].default is False + assert "--events" in [o.name for o in opts] + + def test_setup_with_option_sets_disable_model_invocation_true(self, tmp_path): + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup( + tmp_path, m, parsed_options={"no_model_invocation": True}, script_type="sh" + ) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + fm = _skill_frontmatter(f) + assert fm["disable-model-invocation"] is True, f.parent.name + assert fm["user-invocable"] is True, f.parent.name + assert f.read_text(encoding="utf-8").count("disable-model-invocation:") == 1 + + def test_post_process_follows_option_from_setup(self, tmp_path): + """Preset overrides of core commands re-registered in the same run as + ``setup()`` (init, upgrade) get the same value.""" + i = get_integration("claude") + i.setup( + tmp_path, + IntegrationManifest("claude", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: true" in result + assert "disable-model-invocation: false" not in result + + def test_extension_skills_stay_model_invocable(self, tmp_path): + """Mandatory extension hooks rely on the model invoking the hook skill, + so the option never applies to extension commands.""" + i = get_integration("claude") + i.setup( + tmp_path, + IntegrationManifest("claude", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content( + "---\nname: speckit-git-feature\n---\n\nBody\n" + ) + assert "disable-model-invocation: false" in result + assert "disable-model-invocation: true" not in result + + def test_option_requires_speckit_prefixed_core_name(self, tmp_path): + """A skill merely named like a core stem is not a core command skill.""" + i = get_integration("claude") + i.setup( + tmp_path, + IntegrationManifest("claude", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: tasks\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + + def test_failed_setup_does_not_leave_option_set(self, tmp_path): + i = get_integration("claude") + with patch.object(SkillsIntegration, "setup", side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError): + i.setup( + tmp_path, + IntegrationManifest("claude", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + + def test_setup_without_option_resets_to_false(self, tmp_path): + i = get_integration("claude") + first = tmp_path / "first" + first.mkdir() + i.setup( + first, + IntegrationManifest("claude", first), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + second = tmp_path / "second" + second.mkdir() + created = i.setup(second, IntegrationManifest("claude", second), script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + + def test_cli_rejects_value_for_flag(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-bad-option" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--integration-options", + "--no-model-invocation=yes", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "does not accept a value" in result.output + assert not (project / ".claude" / "skills").exists() + + def test_option_persists_across_upgrade_and_can_be_cleared(self, tmp_path): + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "claude", "--no-model-invocation") + skills_dir = project / ".claude" / "skills" + skill_files = sorted(skills_dir.glob("*/SKILL.md")) + assert len(skill_files) > 0 + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is True, f.parent.name + + state = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + settings = state["integration_settings"]["claude"] + assert settings["raw_options"] == "--no-model-invocation" + assert settings["parsed_options"] == {"no_model_invocation": True} + + # A later ``specify integration upgrade`` is a new process: drop the + # in-memory value so only the persisted options can supply it. + get_integration("claude")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "claude", "--force"]) + assert result.exit_code == 0, result.output + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is True, f.parent.name + + # Passing replacement options (here: none) clears it. + result = _run_in_project( + project, + ["integration", "upgrade", "claude", "--force", "--integration-options", ""], + ) + assert result.exit_code == 0, result.output + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + state = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert "parsed_options" not in state["integration_settings"]["claude"] + + def test_upgrade_reapplies_option_to_preset_overrides(self, tmp_path): + """``preset add`` runs without ``setup()`` (documented limitation); + ``integration upgrade claude --force`` reapplies the option to the + core-command skills a preset overrides.""" + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "claude", "--no-model-invocation") + skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert _skill_frontmatter(skill_file)["disable-model-invocation"] is True + + get_integration("claude")._disable_model_invocation = False + result = _run_in_project( + project, ["preset", "add", "--dev", str(REPO_ROOT / "presets" / "lean")] + ) + assert result.exit_code == 0, result.output + fm = _skill_frontmatter(skill_file) + assert fm["metadata"]["source"] == "preset:lean" + # Documented in docs/reference/integrations.md; update the docs if + # preset commands start honouring the option. + assert fm["disable-model-invocation"] is False + + # The docs say ``--force`` is needed here: preset add modified + # installed skill files, so a plain upgrade refuses. + result = _run_in_project(project, ["integration", "upgrade", "claude"]) + assert result.exit_code != 0 + assert "modified since installation" in result.output + assert _skill_frontmatter(skill_file)["disable-model-invocation"] is False + + get_integration("claude")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "claude", "--force"]) + assert result.exit_code == 0, result.output + fm = _skill_frontmatter(skill_file) + assert fm["metadata"]["source"] == "preset:lean" + assert fm["disable-model-invocation"] is True + + def test_extension_hook_skills_stay_model_invocable_after_upgrade(self, tmp_path): + """``integration upgrade`` re-registers extension skills in the same + process as ``setup()``; they must keep ``false`` so mandatory hooks + (e.g. ``speckit.git.feature``) can still be invoked by the model.""" + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "claude", "--no-model-invocation") + result = _run_in_project( + project, ["extension", "add", "--dev", str(REPO_ROOT / "extensions" / "git")] + ) + assert result.exit_code == 0, result.output + get_integration("claude")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "claude", "--force"]) + assert result.exit_code == 0, result.output + + skills_dir = project / ".claude" / "skills" + git_skills = sorted(skills_dir.glob("speckit-git-*/SKILL.md")) + assert git_skills, "git extension skills were not generated" + for f in git_skills: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + assert _skill_frontmatter(skills_dir / "speckit-specify" / "SKILL.md")[ + "disable-model-invocation" + ] is True + + class TestClaudeInjectFrontmatterFlagNoTrailingNewline: """`_inject_frontmatter_flag` must not corrupt content whose closing frontmatter delimiter is the file's last line with no trailing newline.