Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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."""
Expand Down Expand Up @@ -232,6 +241,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)
Expand Down
68 changes: 67 additions & 1 deletion tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase
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


Expand Down Expand Up @@ -496,6 +496,72 @@ def test_non_claude_post_process_is_identity(self, tmp_path):
assert codex.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."""

Expand Down