diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 57ecb354ad..c5b9340bae 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -24,6 +24,15 @@ "taskstoissues": "Optional filter or label for GitHub issues", } +# Per-command frontmatter overrides for skills that should run in a forked +# subagent context. Read-only analysis commands are good candidates: the +# heavy reads (spec/plan/tasks artefacts) collapse to a short summary, +# so isolating them keeps the main conversation context clean. +# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = { + "analyze": {"context": "fork", "agent": "general-purpose"}, +} + class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -191,6 +200,11 @@ def setup( if hint: updated = self.inject_argument_hint(updated, hint) + fork_config = FORK_CONTEXT_COMMANDS.get(stem) + if fork_config: + for key, value in fork_config.items(): + updated = self._inject_frontmatter_flag(updated, key, value) + if updated != content: path.write_bytes(updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 47b4240d85..27e7d20316 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1111,6 +1111,20 @@ def _get_skills_dir(self) -> Optional[Path]: that callers can fall back gracefully. """ from . import resolve_active_skills_dir, _print_cli_warning + from .integrations._helpers import _read_integration_json + + # For marketplace integrations, presets must still create local skill overrides + # to shadow the marketplace plugin version, bypassing the ai_skills guard. + integration_data = _read_integration_json(self.project_root) + if integration_data.get("skills_source") == "marketplace": + from . import load_init_options, _get_skills_dir as _module_get_skills_dir + opts = load_init_options(self.project_root) or {} + agent = opts.get("ai") + if not isinstance(agent, str) or not agent: + return None + skills_dir = _module_get_skills_dir(self.project_root, agent) + return skills_dir if skills_dir.is_dir() else None + try: return resolve_active_skills_dir(self.project_root) except (ValueError, OSError) as exc: @@ -1120,6 +1134,7 @@ def _get_skills_dir(self) -> Optional[Path]: ) return None + @staticmethod def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: """Return the modern and legacy skill directory names for a command.""" @@ -1254,6 +1269,7 @@ def _register_skills( return [] from . import SKILL_DESCRIPTIONS, load_init_options + from .integrations._helpers import _read_integration_json from .agents import CommandRegistrar from .integrations import get_integration @@ -1264,6 +1280,12 @@ def _register_skills( if not isinstance(selected_ai, str): return [] ai_skills_enabled = is_ai_skills_enabled(init_opts) + integration_data = _read_integration_json(self.project_root) + skills_source = integration_data.get("skills_source") + # When skills_source is "marketplace" the user needs a local SKILL.md to + # shadow the marketplace plugin version, so treat ai_skills as effectively + # enabled regardless of the init-options flag. + ai_skills_effective = ai_skills_enabled or skills_source == "marketplace" registrar = CommandRegistrar() integration = get_integration(selected_ai) agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) @@ -1271,7 +1293,7 @@ def _register_skills( # preset skills in _register_commands() because their detected agent # directory is already the skills directory. This flag is only for # command-backed agents that also mirror commands into skills. - create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" + create_missing_skills = ai_skills_effective and agent_config.get("extension") != "/SKILL.md" written: List[str] = [] diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 0a5a2b18f6..e4b072cf23 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -10,7 +10,7 @@ from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase, SkillsIntegration -from specify_cli.integrations.claude import ARGUMENT_HINTS +from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from specify_cli.integrations.manifest import IntegrationManifest @@ -496,6 +496,72 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p assert agy.post_process_skill_content(content) == content +class TestClaudeForkContext: + """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" + + def test_analyze_skill_runs_in_forked_subagent(self, tmp_path): + """speckit-analyze must opt into context: fork + agent.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + assert analyze_skill.exists() + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + + def test_other_skills_do_not_fork(self, tmp_path): + """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in FORK_CONTEXT_COMMANDS: + continue + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert "context" not in parsed, ( + f"{f.parent.name}: must not have context frontmatter" + ) + assert "agent" not in parsed, ( + f"{f.parent.name}: must not have agent frontmatter" + ) + + def test_fork_flags_inside_frontmatter(self, tmp_path): + """context/agent must appear in the frontmatter, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3 + frontmatter = parts[1] + body = parts[2] + assert "context: fork" in frontmatter + assert "agent: general-purpose" in frontmatter + assert "context: fork" not in body + assert "agent: general-purpose" not in body + + def test_fork_injection_idempotent(self, tmp_path): + """Re-running setup must not duplicate the fork frontmatter keys.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + content = analyze_skill.read_text(encoding="utf-8") + assert content.count("context: fork") == 1 + assert content.count("agent: general-purpose") == 1 + + class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index 9c2d3a508f..6de485316b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2855,6 +2855,39 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): content = skill_file.read_text() assert "untouched" in content, "Skill should not be modified when ai_skills=False" + def test_skill_created_when_ai_skills_false_but_marketplace_source(self, project_dir, temp_dir): + """When ai_skills is False but skills_source is 'marketplace', skill override IS created. + + A marketplace-integrated project sets skills_source=marketplace in integration.json + and ai_skills=false in init-options.json. The preset install must still write a + local SKILL.md so the local file can shadow the marketplace plugin skill. + """ + import json as _json + + # ai_skills disabled, but integration.json declares marketplace as the source + self._write_init_options(project_dir, ai="claude", ai_skills=False) + specify_dir = project_dir / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + integration_json = specify_dir / "integration.json" + integration_json.write_text( + _json.dumps({"skills_source": "marketplace", "integration_state_schema": 1}) + ) + + # The parent skills dir must exist for _get_skills_dir() to return it, + # but the specific skill subdir must NOT exist yet (create_missing_skills path). + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists(), ( + "SKILL.md should be created for marketplace projects even when ai_skills=False" + ) + content = skill_file.read_text() + assert "preset:self-test" in content, "Skill should reference preset source" + def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir): """Corrupted init-options ai values should not crash preset skill resolution.""" init_options = project_dir / ".specify" / "init-options.json"