Skip to content
7 changes: 6 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ def _build_ai_assistant_help() -> str:
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"

# Relative path (from project root) to the authoritative constitution file.
# Shared by init-time scaffolding and integration-specific context-file
# generation so the two cannot drift.
CONSTITUTION_REL_PATH = Path(".specify") / "memory" / "constitution.md"

BANNER = """
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
Expand Down Expand Up @@ -753,7 +758,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =

def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None:
"""Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization)."""
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
memory_constitution = project_path / CONSTITUTION_REL_PATH
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"

# If constitution already exists in memory, preserve it
Expand Down
47 changes: 46 additions & 1 deletion src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,61 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

@classmethod
def ensure_claude_md(cls, project_root: Path) -> Path | None:
"""Create a minimal root context file (``CLAUDE.md``) if missing.

Claude Code expects ``context_file`` at the project root; this file
acts as a bridge to the constitution at ``CONSTITUTION_REL_PATH``.
Returns the path if created, ``None`` otherwise.
"""
from specify_cli import CONSTITUTION_REL_PATH

if cls.context_file is None:
return None

constitution = project_root / CONSTITUTION_REL_PATH
context_file = project_root / cls.context_file
if context_file.exists() or not constitution.exists():
return None

constitution_rel = CONSTITUTION_REL_PATH.as_posix()
content = (
"## Claude's Role\n"
f"Read `{constitution_rel}` first. It is the authoritative source of truth for this project. "
"Everything in it is non-negotiable.\n\n"
"## SpecKit Commands\n"
"- `/speckit.constitution` — establish or amend project principles\n"
"- `/speckit.specify` — generate spec\n"
"- `/speckit.clarify` — ask structured de-risking questions (before `/speckit.plan`)\n"
"- `/speckit.plan` — generate plan\n"
"- `/speckit.tasks` — generate task list\n"
"- `/speckit.analyze` — cross-artifact consistency report (after `/speckit.tasks`)\n"
"- `/speckit.checklist` — generate quality checklists\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"
)
context_file.write_text(content, encoding="utf-8")
return context_file

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
"""Install Claude skills, create CLAUDE.md, then inject frontmatter flags and argument-hints."""
created = super().setup(project_root, manifest, parsed_options, **opts)

# Create root CLAUDE.md pointing to the constitution
claude_md = self.ensure_claude_md(project_root)
if claude_md is not None:
created.append(claude_md)
self.record_file_in_manifest(claude_md, project_root, manifest)

# Post-process generated skill files
skills_dir = self.skills_dest(project_root).resolve()

Expand Down
95 changes: 95 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,101 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
assert "speckit-research" in metadata.get("registered_skills", [])


EXPECTED_CLAUDE_MD_COMMANDS = (
"/speckit.constitution",
"/speckit.specify",
"/speckit.clarify",
"/speckit.plan",
"/speckit.tasks",
"/speckit.analyze",
"/speckit.checklist",
"/speckit.implement",
)
EXPECTED_CLAUDE_MD_SECTIONS = (
"## Claude's Role",
"## SpecKit Commands",
"## On Ambiguity",
)


class TestClaudeMdCreation:
"""Verify that CLAUDE.md is created during setup when constitution exists."""

def test_setup_creates_claude_md_when_constitution_exists(self, tmp_path):
integration = get_integration("claude")
constitution = tmp_path / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")

claude_md = tmp_path / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content
assert claude_md in created
for section in EXPECTED_CLAUDE_MD_SECTIONS:
assert section in content, f"missing section header: {section}"
for command in EXPECTED_CLAUDE_MD_COMMANDS:
assert f"`{command}`" in content, f"missing command: {command}"

def test_setup_skips_claude_md_when_constitution_missing(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

assert not (tmp_path / "CLAUDE.md").exists()

def test_setup_preserves_existing_claude_md(self, tmp_path):
integration = get_integration("claude")
constitution = tmp_path / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

claude_md = tmp_path / "CLAUDE.md"
claude_md.write_text("# Custom content\n", encoding="utf-8")

manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")

assert claude_md.read_text(encoding="utf-8") == "# Custom content\n"

def test_init_cli_creates_claude_md(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / "claude-md-test"
project.mkdir()

# Pre-create constitution so ensure_claude_md has something to gate on
constitution = project / ".specify" / "memory" / "constitution.md"
constitution.parent.mkdir(parents=True, exist_ok=True)
constitution.write_text("# Constitution\n", encoding="utf-8")

old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
["init", "--here", "--force", "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)

assert result.exit_code == 0, result.output
claude_md = project / "CLAUDE.md"
assert claude_md.exists()
content = claude_md.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content
for section in EXPECTED_CLAUDE_MD_SECTIONS:
assert section in content, f"missing section header: {section}"
for command in EXPECTED_CLAUDE_MD_COMMANDS:
assert f"`{command}`" in content, f"missing command: {command}"


class TestClaudeArgumentHints:
"""Verify that argument-hint frontmatter is injected for Claude skills."""

Expand Down