Skip to content
57 changes: 57 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/test_ai_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
DEFAULT_SKILLS_DIR,
SKILL_DESCRIPTIONS,
AGENT_CONFIG,
StepTracker,
app,
ensure_claude_md,
)


Expand Down Expand Up @@ -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, {})
Expand Down
Loading