Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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