diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0..5a619a6a5 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -148,6 +148,35 @@ 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, @@ -155,9 +184,15 @@ def setup( 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() diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df17..9b1e940f8 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -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."""