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"
Comment on lines +1487 to +1498
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure_claude_md() will create a root CLAUDE.md that instructs Claude to read .specify/memory/constitution.md even when that file does not exist (e.g., when ensure_constitution_from_template() failed because the template was missing). Consider gating CLAUDE.md creation on the constitution file existing (or on the template being present), and mark the tracker step as error/skipped otherwise to avoid generating misleading guidance.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

)

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