Skip to content
37 changes: 36 additions & 1 deletion src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,51 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

@staticmethod
def ensure_claude_md(project_root: Path) -> Path | None:
"""Create a minimal root ``CLAUDE.md`` if missing.

Claude Code expects ``CLAUDE.md`` at the project root; this file
acts as a bridge to ``.specify/memory/constitution.md``.
Returns the path if created, ``None`` otherwise.
"""
constitution = project_root / ".specify" / "memory" / "constitution.md"
claude_file = project_root / "CLAUDE.md"
if claude_file.exists() or not constitution.exists():
return None

content = (
"## Claude's Role\n"
"Read `.specify/memory/constitution.md` first. It is the authoritative source of truth for this project. "
"Everything in it is non-negotiable.\n\n"
"## SpecKit Commands\n"
"- `/speckit.specify` — generate spec\n"
"- `/speckit.plan` — generate plan\n"
"- `/speckit.tasks` — generate task list\n"
"- `/speckit.implement` — execute plan\n\n"
"## On Ambiguity\n"
"If a spec is missing, incomplete, or conflicts with the constitution — stop and ask. "
"Do not infer. Do not proceed.\n\n"
)
claude_file.write_text(content, encoding="utf-8")
return claude_file

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
"""Install Claude skills, create CLAUDE.md, then inject frontmatter flags and argument-hints."""
created = super().setup(project_root, manifest, parsed_options, **opts)

# Create root CLAUDE.md pointing to the constitution
claude_md = self.ensure_claude_md(project_root)
if claude_md is not None:
created.append(claude_md)
self.record_file_in_manifest(claude_md, project_root, manifest)

# Post-process generated skill files
skills_dir = self.skills_dest(project_root).resolve()

Expand Down
70 changes: 70 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,76 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
assert "speckit-research" in metadata.get("registered_skills", [])


class TestClaudeMdCreation:
"""Verify that CLAUDE.md is created during setup when constitution exists."""

def test_setup_creates_claude_md_when_constitution_exists(self, tmp_path):
integration = get_integration("claude")
constitution = tmp_path / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")

claude_md = tmp_path / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content
assert claude_md in created

def test_setup_skips_claude_md_when_constitution_missing(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

assert not (tmp_path / "CLAUDE.md").exists()

def test_setup_preserves_existing_claude_md(self, tmp_path):
integration = get_integration("claude")
constitution = tmp_path / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

claude_md = tmp_path / "CLAUDE.md"
claude_md.write_text("# Custom content\n", encoding="utf-8")

manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

assert claude_md.read_text(encoding="utf-8") == "# Custom content\n"

def test_init_cli_creates_claude_md(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / "claude-md-test"
project.mkdir()

# Pre-create constitution so ensure_claude_md has something to gate on
constitution = project / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
["init", "--here", "--force", "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)

assert result.exit_code == 0, result.output
claude_md = project / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content


class TestClaudeArgumentHints:
"""Verify that argument-hint frontmatter is injected for Claude skills."""

Expand Down