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

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 +2113,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 +2181,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
35 changes: 35 additions & 0 deletions tests/test_ai_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,41 @@ class TestNewProjectCommandSkip:
download_and_extract_template patched to create local fixtures.
"""

@pytest.mark.skipif(
shutil.which("bash") is None or shutil.which("zip") is None,
reason="offline scaffolding requires bash + zip",
)
def test_init_claude_creates_root_CLAUDE_md(self, tmp_path):
from typer.testing import CliRunner

runner = CliRunner()
target = tmp_path / "claude-proj"

result = runner.invoke(
app,
[
"init",
str(target),
"--ai",
"claude",
"--offline",
"--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 _fake_extract(self, agent, project_path, **_kwargs):
"""Simulate template extraction: create agent commands dir."""
agent_cfg = AGENT_CONFIG.get(agent, {})
Expand Down
Loading