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 @@ -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."""
Expand Down Expand Up @@ -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)

Comment on lines +203 to +207
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
Expand Down
24 changes: 23 additions & 1 deletion src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1120 to +1126

try:
return resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
Expand All @@ -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."""
Expand Down Expand Up @@ -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

Expand All @@ -1264,14 +1280,20 @@ 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, {})
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
# 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] = []

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 @@ -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


Expand Down Expand Up @@ -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."""

Expand Down
33 changes: 33 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down