diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 6a7d483db3..0df388172d 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from pathlib import Path from typing import Any from ..base import SkillsIntegration -from ..manifest import IntegrationManifest from ..._utils import dump_frontmatter # Mapping of command template stem → argument-hint text shown inline @@ -23,6 +21,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.""" @@ -148,50 +155,47 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) - def post_process_skill_content(self, content: str) -> str: - """Inject Claude-specific frontmatter flags and hook notes.""" - 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") - return updated + @staticmethod + def _skill_stem_from_content(content: str) -> str | None: + """Derive the command stem (e.g. ``analyze``) from a skill's frontmatter. - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Install Claude skills, then inject argument-hints.""" - created = super().setup(project_root, manifest, parsed_options, **opts) - - skills_dir = self.skills_dest(project_root).resolve() - - for path in created: - # Only touch SKILL.md files under the skills directory - try: - path.resolve().relative_to(skills_dir) - except ValueError: - continue - if path.name != "SKILL.md": + Reads the ``name:`` field of the first frontmatter block and strips + the ``speckit-`` prefix. Returns ``None`` when no name is present. + """ + dash_count = 0 + for line in content.splitlines(): + stripped = line.rstrip("\r\n") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break 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 - content_bytes = path.read_bytes() - content = content_bytes.decode("utf-8") + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags, hook notes, and any + per-command frontmatter. - updated = content + 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. + """ + 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") - # Inject argument-hint if available for this skill - skill_dir_name = path.parent.name # e.g. "speckit-plan" - stem = skill_dir_name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] + stem = self._skill_stem_from_content(updated) + if stem: hint = ARGUMENT_HINTS.get(stem, "") if hint: updated = self.inject_argument_hint(updated, hint) - - if updated != content: - path.write_bytes(updated.encode("utf-8")) - self.record_file_in_manifest(path, project_root, manifest) - - return created + 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) + return updated diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index e8350114a7..c7ecef95d0 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 @@ -536,6 +536,102 @@ 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 + + def test_fork_context_injected_via_post_process(self): + """Preset/extension generators call post_process_skill_content directly, + bypassing setup(); fork context must be injected there too.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"] + + def test_post_process_no_fork_for_other_skills(self): + """Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent.""" + i = get_integration("claude") + content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_post_process_fork_idempotent(self): + """Re-running post_process must not duplicate fork frontmatter keys.""" + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + once = i.post_process_skill_content(content) + twice = i.post_process_skill_content(once) + assert once == twice + assert twice.count("context: fork") == 1 + assert twice.count("agent: general-purpose") == 1 + + class TestClaudeHookCommandNote: """Verify dot-to-hyphen normalization note is injected in hook sections."""