diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1f0eaf475..0a8e877c7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1461,6 +1461,58 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") +def ensure_claude_md(project_path: Path, tracker: StepTracker | None = None) -> None: + """Create a minimal root `CLAUDE.md` for Claude Code if missing. + + Claude Code expects `CLAUDE.md` at the project root; this file acts as a + bridge to `.specify/memory/constitution.md` (the source of truth). + """ + memory_constitution = project_path / ".specify" / "memory" / "constitution.md" + claude_file = project_path / "CLAUDE.md" + if claude_file.exists(): + if tracker: + tracker.add("claude-md", "Claude Code role file") + tracker.skip("claude-md", "existing file preserved") + return + + if not memory_constitution.exists(): + detail = "constitution missing" + if tracker: + tracker.add("claude-md", "Claude Code role file") + tracker.skip("claude-md", detail) + else: + console.print(f"[yellow]Warning:[/yellow] Not creating CLAUDE.md because {memory_constitution} is missing") + return + + 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" + ) + + try: + claude_file.write_text(content, encoding="utf-8") + if tracker: + tracker.add("claude-md", "Claude Code role file") + tracker.complete("claude-md", "created") + else: + console.print("[cyan]Initialized CLAUDE.md for Claude Code[/cyan]") + except Exception as e: + if tracker: + tracker.add("claude-md", "Claude Code role file") + tracker.error("claude-md", str(e)) + else: + console.print(f"[yellow]Warning: Could not create CLAUDE.md: {e}[/yellow]") + + INIT_OPTIONS_FILE = ".specify/init-options.json" @@ -2071,6 +2123,8 @@ def init( ("constitution", "Constitution setup"), ]: tracker.add(key, label) + if selected_ai == "claude": + tracker.add("claude-md", "Claude Code role file") if ai_skills: tracker.add("ai-skills", "Install agent skills") for key, label in [ @@ -2137,6 +2191,9 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + if selected_ai == "claude": + ensure_claude_md(project_path, tracker=tracker) + # Determine skills directory and migrate any legacy Kimi dotted skills. migrated_legacy_kimi_skills = 0 removed_legacy_kimi_skills = 0 diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index f0e220e26..3113f20b9 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -29,7 +29,9 @@ DEFAULT_SKILLS_DIR, SKILL_DESCRIPTIONS, AGENT_CONFIG, + StepTracker, app, + ensure_claude_md, ) @@ -693,6 +695,62 @@ class TestNewProjectCommandSkip: download_and_extract_template patched to create local fixtures. """ + def test_init_claude_creates_root_CLAUDE_md(self, tmp_path): + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "claude-proj" + + def fake_download(project_path, *args, **kwargs): + # Minimal scaffold required for ensure_constitution_from_template() + # and ensure_claude_md() to succeed deterministically. + templates_dir = project_path / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "constitution-template.md").write_text( + "# Constitution\n\nNon-negotiable rules.\n", + encoding="utf-8", + ) + + with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ + patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke( + app, + [ + "init", + str(target), + "--ai", + "claude", + "--ignore-agent-tools", + "--no-git", + "--script", + "sh", + ], + ) + + assert result.exit_code == 0, result.output + + claude_file = target / "CLAUDE.md" + assert claude_file.exists() + + content = claude_file.read_text(encoding="utf-8") + assert "## Claude's Role" in content + assert "`.specify/memory/constitution.md`" in content + assert "/speckit.plan" in content + + def test_ensure_claude_md_skips_when_constitution_missing(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + + tracker = StepTracker("t") + ensure_claude_md(project, tracker=tracker) + + assert not (project / "CLAUDE.md").exists() + step = next(s for s in tracker.steps if s["key"] == "claude-md") + assert step["status"] == "skipped" + assert "constitution missing" in step["detail"] + def _fake_extract(self, agent, project_path, **_kwargs): """Simulate template extraction: create agent commands dir.""" agent_cfg = AGENT_CONFIG.get(agent, {})