From 537557437e44e238a2c995a50b6a84173aea38a3 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 11:14:17 +0100 Subject: [PATCH 1/6] feat(claude): add --no-model-invocation integration option Claude skills are always written with `disable-model-invocation: false`, so every spec-kit skill description sits in Claude Code's context and counts against the skill-listing budget. In projects with many skills that pushes other skills out of the listing (#2654). `specify init --integration claude --integration-options="--no-model-invocation"` now writes `disable-model-invocation: true` instead. The default is unchanged. The option persists in .specify/integration.json like other integration options, so `specify integration upgrade claude` reapplies it; `--integration-options=""` clears it. The change is confined to the Claude integration: setup() records the option on the integration and post_process_skill_content() reads it, so preset and extension skills re-registered in the same run follow it. Commands that write those skills without calling setup() (extension add, preset add, integration use) keep the default; the docs say so and point at `integration upgrade claude` to reapply. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 3 +- .../integrations/claude/__init__.py | 50 +++++- tests/integrations/test_integration_claude.py | 169 ++++++++++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index df113a74be..b0567cb8c5 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 them user-invocable only (see [Integration-Specific Options](#integration-specific-options)). | | [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 generated skills so Claude Code runs them only when invoked as `/speckit-` and keeps their descriptions out of its context (useful when many installed skills exhaust the skill-listing budget). Skills stay model-invocable by default. The option is persisted and reapplied by `specify integration upgrade claude`; pass `--integration-options=""` to that command to clear it. Commands that write extension or preset skills without reinstalling Claude (`specify extension add`, `specify preset add`, `specify integration use claude`) use the default for those skills; run `specify integration upgrade claude` afterwards to reapply the option. | Example: diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 9a14cf50b0..fd5d65b917 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,43 @@ class ClaudeIntegration(SkillsIntegration): events_config_file = ".claude/settings.json" events_format = "json-nested" + # Set by ``setup()`` from ``--no-model-invocation``. Integrations are + # process-wide singletons, so preset and extension skills re-registered + # later in the same run (init, upgrade, switch) see the same value. + # Commands that write those skills without calling setup() + # (``extension add``, ``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 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 = bool( + (parsed_options or {}).get("no_model_invocation") + ) + return super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + @staticmethod def inject_argument_hint(content: str, hint: str) -> str: """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. @@ -228,10 +267,17 @@ 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`` 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") + updated = self._inject_frontmatter_flag( + updated, + "disable-model-invocation", + "true" if self._disable_model_invocation else "false", + ) stem = self._skill_stem_from_content(updated) if stem: diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8671b7c445..ecf632b692 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,157 @@ 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 and extension skills re-registered in the same run as + ``setup()`` (init, upgrade, switch) 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-git-commit\n---\n\nBody\n") + assert "disable-model-invocation: true" in result + assert "disable-model-invocation: false" not 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_standalone_preset_add_uses_default_until_upgrade(self, tmp_path): + """Documented limitation: ``preset add`` runs without ``setup()``, so + the skills it writes keep the default until the next upgrade.""" + 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" + assert fm["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 + + class TestClaudeInjectFrontmatterFlagNoTrailingNewline: """`_inject_frontmatter_flag` must not corrupt content whose closing frontmatter delimiter is the file's last line with no trailing newline. From f5a08649fb05b0762958ca76772bb5aac57d1988 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 13:17:03 +0100 Subject: [PATCH 2/6] fix(claude): keep extension skills model-invocable; document gaps Mandatory extension hooks tell the model to invoke the hook skill, which Claude Code refuses for `disable-model-invocation: true`. Limit --no-model-invocation to the core command skills (and preset overrides of them) by recording the core template stems in setup(), and reset that state if setup() fails so it cannot leak into later post-processing. Document the behaviour in its own section: extension skills stay invocable, upgrade options replace the stored set, and commands that rewrite skills without reinstalling Claude (preset add/remove, integration use) need `integration upgrade claude --force` afterwards. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 30 +++++++- .../integrations/claude/__init__.py | 40 +++++++---- tests/integrations/test_integration_claude.py | 68 ++++++++++++++++--- 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index b0567cb8c5..78663c7b6c 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`. Pass `--integration-options="--no-model-invocation"` to make them user-invocable only (see [Integration-Specific Options](#integration-specific-options)). | +| [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,7 +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 generated skills so Claude Code runs them only when invoked as `/speckit-` and keeps their descriptions out of its context (useful when many installed skills exhaust the skill-listing budget). Skills stay model-invocable by default. The option is persisted and reapplied by `specify integration upgrade claude`; pass `--integration-options=""` to that command to clear it. Commands that write extension or preset skills without reinstalling Claude (`specify extension add`, `specify preset add`, `specify integration use claude`) use the default for those skills; run `specify integration upgrade claude` afterwards to reapply the option. | +| `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: @@ -462,6 +462,32 @@ 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 (including preset +overrides of them) 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. +- 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. +- Commands that rewrite these skills without reinstalling Claude, such as + `specify preset add` or `remove` and `specify integration use claude`, write + them with the default. Run `specify integration upgrade claude --force` + afterwards to reapply the option (`--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 fd5d65b917..2830afceb0 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -67,12 +67,15 @@ class ClaudeIntegration(SkillsIntegration): events_config_file = ".claude/settings.json" events_format = "json-nested" - # Set by ``setup()`` from ``--no-model-invocation``. Integrations are - # process-wide singletons, so preset and extension skills re-registered - # later in the same run (init, upgrade, switch) see the same value. - # Commands that write those skills without calling setup() - # (``extension add``, ``preset add``, ``integration use``) get the default. - _disable_model_invocation: bool = False + # Core command stems (``plan``, ``specify``, ...) whose skills get + # ``disable-model-invocation: true``; set by ``setup()`` from + # ``--no-model-invocation``. Extension skills are never included: + # mandatory hooks rely on the model invoking them. 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. + _no_model_invocation_stems: frozenset[str] = frozenset() @classmethod def options(cls) -> list[IntegrationOption]: @@ -97,12 +100,18 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - self._disable_model_invocation = bool( - (parsed_options or {}).get("no_model_invocation") - ) - return super().setup( - project_root, manifest, parsed_options=parsed_options, **opts + self._no_model_invocation_stems = ( + frozenset(f.stem for f in self.list_command_templates()) + if (parsed_options or {}).get("no_model_invocation") + else frozenset() ) + try: + return super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + except BaseException: + self._no_model_invocation_stems = frozenset() + raise @staticmethod def inject_argument_hint(content: str, hint: str) -> str: @@ -268,18 +277,19 @@ def post_process_skill_content(self, content: str) -> str: so command-specific frontmatter (argument-hint, fork context) stays consistent however the SKILL.md was produced. - ``disable-model-invocation`` is ``true`` when the last ``setup()`` in - this process was given ``--no-model-invocation``, else ``false``. + ``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") + stem = self._skill_stem_from_content(updated) updated = self._inject_frontmatter_flag( updated, "disable-model-invocation", - "true" if self._disable_model_invocation else "false", + "true" if stem in self._no_model_invocation_stems else "false", ) - stem = self._skill_stem_from_content(updated) 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 ecf632b692..58c6055cda 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -21,9 +21,9 @@ 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 + claude._no_model_invocation_stems = frozenset() yield - claude._disable_model_invocation = False + claude._no_model_invocation_stems = frozenset() def _skill_frontmatter(skill_file: Path) -> dict: @@ -636,8 +636,8 @@ def test_setup_with_option_sets_disable_model_invocation_true(self, tmp_path): assert f.read_text(encoding="utf-8").count("disable-model-invocation:") == 1 def test_post_process_follows_option_from_setup(self, tmp_path): - """Preset and extension skills re-registered in the same run as - ``setup()`` (init, upgrade, switch) get the same value.""" + """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, @@ -645,10 +645,39 @@ def test_post_process_follows_option_from_setup(self, tmp_path): parsed_options={"no_model_invocation": True}, script_type="sh", ) - result = i.post_process_skill_content("---\nname: speckit-git-commit\n---\n\nBody\n") + 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_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" @@ -717,7 +746,7 @@ def test_option_persists_across_upgrade_and_can_be_cleared(self, tmp_path): # 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 + get_integration("claude")._no_model_invocation_stems = frozenset() result = _run_in_project(project, ["integration", "upgrade", "claude", "--force"]) assert result.exit_code == 0, result.output for f in skill_files: @@ -743,7 +772,7 @@ def test_standalone_preset_add_uses_default_until_upgrade(self, tmp_path): 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 + get_integration("claude")._no_model_invocation_stems = frozenset() result = _run_in_project( project, ["preset", "add", "--dev", str(REPO_ROOT / "presets" / "lean")] ) @@ -752,13 +781,36 @@ def test_standalone_preset_add_uses_default_until_upgrade(self, tmp_path): assert fm["metadata"]["source"] == "preset:lean" assert fm["disable-model-invocation"] is False - get_integration("claude")._disable_model_invocation = False + get_integration("claude")._no_model_invocation_stems = frozenset() 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 + 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 From 8a4bc122a11819c4d357b689120f56398f88fe9e Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 13:22:05 +0100 Subject: [PATCH 3/6] refactor(claude): derive core skills from CORE_COMMAND_NAMES Store a plain flag from setup() and decide per skill in post_process_skill_content() using the shared CORE_COMMAND_NAMES, so there is one definition of "core command" and no extra template listing. Require the `speckit-` prefix so an unrelated skill named like a core stem (e.g. `tasks`) is not affected. Say "core command skills" in the option help. Docs: list every command that rewrites the skills without reinstalling Claude, and note that re-running `specify init` stores only the options passed to it. Tests: reset the in-memory flag before upgrade in the extension-hook test so it proves persistence, and stop asserting the documented preset gap as required behaviour. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 16 +++-- .../integrations/claude/__init__.py | 65 +++++++++++-------- tests/integrations/test_integration_claude.py | 34 ++++++---- 3 files changed, 70 insertions(+), 45 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 78663c7b6c..cb0ea7c6a3 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -480,13 +480,15 @@ specify init my-project --integration claude --integration-options="--no-model-i `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. -- Commands that rewrite these skills without reinstalling Claude, such as - `specify preset add` or `remove` and `specify integration use claude`, write - them with the default. Run `specify integration upgrade claude --force` - afterwards to reapply the option (`--force` is needed because those commands - modify installed skill files, and it overwrites local edits to Spec - Kit-managed files). + `--integration-options=""` clears them. Re-running `specify init` likewise + stores only the options passed to it. +- Commands that rewrite these skills without reinstalling Claude write them with + the default: the `specify preset` commands that add, remove, enable, disable, + reprioritize or update presets, `specify integration use claude`, and `specify + integration switch claude` when Claude is already installed. Run `specify + integration upgrade claude --force` afterwards to reapply the option + (`--force` is needed because those commands modify installed skill files, and + it overwrites local edits to Spec Kit-managed files). ## Scaffold a New Integration diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 2830afceb0..22307a9462 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -67,15 +67,14 @@ class ClaudeIntegration(SkillsIntegration): events_config_file = ".claude/settings.json" events_format = "json-nested" - # Core command stems (``plan``, ``specify``, ...) whose skills get - # ``disable-model-invocation: true``; set by ``setup()`` from - # ``--no-model-invocation``. Extension skills are never included: - # mandatory hooks rely on the model invoking them. 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. - _no_model_invocation_stems: frozenset[str] = frozenset() + # 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]: @@ -86,7 +85,7 @@ def options(cls) -> list[IntegrationOption]: is_flag=True, default=False, help=( - "Install skills as user-invocable only " + "Install core command skills as user-invocable only " "(disable-model-invocation: true)" ), ) @@ -100,17 +99,15 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - self._no_model_invocation_stems = ( - frozenset(f.stem for f in self.list_command_templates()) - if (parsed_options or {}).get("no_model_invocation") - else frozenset() + self._disable_model_invocation = bool( + (parsed_options or {}).get("no_model_invocation") ) try: return super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) except BaseException: - self._no_model_invocation_stems = frozenset() + self._disable_model_invocation = False raise @staticmethod @@ -248,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") @@ -264,11 +257,30 @@ 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 + @classmethod + def _skill_stem_from_content(cls, 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. + """ + name = cls._skill_name_from_content(content) + if name and name.startswith("speckit-"): + return name[len("speckit-"):] + return name + + def _is_core_command_skill(self, content: str) -> bool: + from specify_cli.extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + + name = self._skill_name_from_content(content) or "" + return ( + name.startswith("speckit-") + and name[len("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. @@ -283,13 +295,12 @@ def post_process_skill_content(self, content: str) -> str: """ updated = super().post_process_skill_content(content) updated = self._inject_frontmatter_flag(updated, "user-invocable") - stem = self._skill_stem_from_content(updated) + disable = self._disable_model_invocation and self._is_core_command_skill(updated) updated = self._inject_frontmatter_flag( - updated, - "disable-model-invocation", - "true" if stem in self._no_model_invocation_stems else "false", + updated, "disable-model-invocation", "true" if disable else "false" ) + stem = self._skill_stem_from_content(updated) 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 58c6055cda..680602e5a4 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -21,9 +21,9 @@ 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._no_model_invocation_stems = frozenset() + claude._disable_model_invocation = False yield - claude._no_model_invocation_stems = frozenset() + claude._disable_model_invocation = False def _skill_frontmatter(skill_file: Path) -> dict: @@ -665,6 +665,18 @@ def test_extension_skills_stay_model_invocable(self, tmp_path): 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")): @@ -746,7 +758,7 @@ def test_option_persists_across_upgrade_and_can_be_cleared(self, tmp_path): # 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")._no_model_invocation_stems = frozenset() + 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: @@ -763,25 +775,24 @@ def test_option_persists_across_upgrade_and_can_be_cleared(self, tmp_path): state = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert "parsed_options" not in state["integration_settings"]["claude"] - def test_standalone_preset_add_uses_default_until_upgrade(self, tmp_path): - """Documented limitation: ``preset add`` runs without ``setup()``, so - the skills it writes keep the default until the next upgrade.""" + 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")._no_model_invocation_stems = frozenset() + 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" - assert fm["disable-model-invocation"] is False + assert _skill_frontmatter(skill_file)["metadata"]["source"] == "preset:lean" - get_integration("claude")._no_model_invocation_stems = frozenset() + 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) @@ -799,6 +810,7 @@ def test_extension_hook_skills_stay_model_invocable_after_upgrade(self, tmp_path 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 From 0e7f11c9b45f26a8b2f2c3ec0ac1edeb6701d6ea Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 13:27:11 +0100 Subject: [PATCH 4/6] fix(claude): tighten --no-model-invocation edge cases and docs Parse the skill name once per post-processing call, and enable the option only for a stored value of exactly `true`. Docs: say the option is applied whenever Claude's skills are installed or upgraded rather than promising it for every preset override, add the `specify bundle` preset paths, limit the `switch` caveat to a Claude install that is not the default, and warn that a mandatory extension hook on a core command cannot run with the option on. Test the documented preset gap explicitly so a behaviour change forces a docs update. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 25 +++++++++------- .../integrations/claude/__init__.py | 29 +++++-------------- tests/integrations/test_integration_claude.py | 6 +++- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index cb0ea7c6a3..478911c9de 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -467,28 +467,33 @@ workflow and does not install or enable the extension automatically. 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 (including preset -overrides of them) user-invocable only: +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. + on the model invoking them. If an extension registers a mandatory hook on a + core command, 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. -- Commands that rewrite these skills without reinstalling Claude write them with - the default: the `specify preset` commands that add, remove, enable, disable, - reprioritize or update presets, `specify integration use claude`, and `specify - integration switch claude` when Claude is already installed. Run `specify - integration upgrade claude --force` afterwards to reapply the option - (`--force` is needed because those commands modify installed skill files, and - it overwrites local edits to Spec Kit-managed files). +- It is applied whenever Claude's skills are installed or upgraded, including to + preset overrides of core commands present at that point. Commands that later + rewrite those skills without reinstalling Claude write them with the default: + commands that install, remove, enable, disable, reprioritize 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. Run `specify integration upgrade claude --force` + afterwards to reapply the option (`--force` is needed because those commands + modify installed skill files, and it overwrites local edits to Spec + Kit-managed files). ## Scaffold a New Integration diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 22307a9462..4cc5b22dec 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -99,8 +99,8 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - self._disable_model_invocation = bool( - (parsed_options or {}).get("no_model_invocation") + self._disable_model_invocation = ( + (parsed_options or {}).get("no_model_invocation") is True ) try: return super().setup( @@ -260,25 +260,11 @@ def _skill_name_from_content(content: str) -> str | None: return name or None return None - @classmethod - def _skill_stem_from_content(cls, 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. - """ - name = cls._skill_name_from_content(content) - if name and name.startswith("speckit-"): - return name[len("speckit-"):] - return name - - def _is_core_command_skill(self, content: str) -> bool: + def _is_core_command_skill(self, name: str | None) -> bool: from specify_cli.extensions import CORE_COMMAND_NAMES # lazy: avoids circular import - name = self._skill_name_from_content(content) or "" - return ( - name.startswith("speckit-") - and name[len("speckit-"):] in CORE_COMMAND_NAMES + return bool(name) and name.startswith("speckit-") and ( + name.removeprefix("speckit-") in CORE_COMMAND_NAMES ) def post_process_skill_content(self, content: str) -> str: @@ -295,12 +281,13 @@ def post_process_skill_content(self, content: str) -> str: """ updated = super().post_process_skill_content(content) updated = self._inject_frontmatter_flag(updated, "user-invocable") - disable = self._disable_model_invocation and self._is_core_command_skill(updated) + 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 680602e5a4..96e495e1ef 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -790,7 +790,11 @@ def test_upgrade_reapplies_option_to_preset_overrides(self, tmp_path): project, ["preset", "add", "--dev", str(REPO_ROOT / "presets" / "lean")] ) assert result.exit_code == 0, result.output - assert _skill_frontmatter(skill_file)["metadata"]["source"] == "preset:lean" + 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 get_integration("claude")._disable_model_invocation = False result = _run_in_project(project, ["integration", "upgrade", "claude", "--force"]) From f25516f4dcf5889f8f15094b24a478369aa3c1b2 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 13:32:21 +0100 Subject: [PATCH 5/6] docs(claude): narrow --no-model-invocation caveats to what reverts Only preset-related skills are rewritten by preset, use and switch commands, and `upgrade --force` re-registers presets only while Claude is the default integration; say so, and clarify that the hook caveat is about hooks whose command is a core command. Pin the documented need for `--force` with a test, and make `_is_core_command_skill` a staticmethod. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 25 +++++++++---------- .../integrations/claude/__init__.py | 3 ++- tests/integrations/test_integration_claude.py | 7 ++++++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 478911c9de..ae17cc26ce 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -475,25 +475,24 @@ specify init my-project --integration claude --integration-options="--no-model-i ``` - Extension skills stay model-invocable, because mandatory extension hooks rely - on the model invoking them. If an extension registers a mandatory hook on a - core command, leave this option off: the model cannot run that hook while the - core skill is user-invocable only. + 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. -- It is applied whenever Claude's skills are installed or upgraded, including to - preset overrides of core commands present at that point. Commands that later - rewrite those skills without reinstalling Claude write them with the default: - commands that install, remove, enable, disable, reprioritize 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. Run `specify integration upgrade claude --force` - afterwards to reapply the option (`--force` is needed because those commands - modify installed skill files, and it overwrites local edits to Spec - Kit-managed files). +- 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, enable, disable, reprioritize 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 diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 4cc5b22dec..03d428bc51 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -260,7 +260,8 @@ def _skill_name_from_content(content: str) -> str | None: return name or None return None - def _is_core_command_skill(self, name: str | None) -> bool: + @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 ( diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 96e495e1ef..f275737075 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -796,6 +796,13 @@ def test_upgrade_reapplies_option_to_preset_overrides(self, tmp_path): # 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 From 591a760883117777974dd0fdd17a5ac6a43de13b Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Sun, 11 Oct 2026 13:38:48 +0100 Subject: [PATCH 6/6] docs(claude): only preset add/remove/update rewrite skills preset enable, disable and set-priority update the registry without rewriting skills, so they do not revert --no-model-invocation. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NRjt1UC5M2USX77TzRMemU --- docs/reference/integrations.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ae17cc26ce..cf43dac0e7 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -486,13 +486,13 @@ specify init my-project --integration claude --integration-options="--no-model-i 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, enable, disable, reprioritize 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). + 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