Skip to content
13 changes: 12 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 Expand Up @@ -1186,6 +1191,12 @@ def init(

ensure_constitution_from_template(project_path, tracker=tracker)

# Post-constitution hook: let the integration create its root
# context file (e.g. CLAUDE.md) now that the constitution exists.
context_file = resolved_integration.ensure_context_file(project_path, manifest)
if context_file is not None:
manifest.save()

if not no_git:
tracker.start("git")
git_messages = []
Expand Down
17 changes: 17 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none."""
return []

def ensure_context_file(
self,
project_root: Path,
manifest: "IntegrationManifest",
) -> Path | None:
"""Post-constitution-setup hook: create the agent's root context file.

Called from ``init()`` after ``ensure_constitution_from_template``
has run. Integrations that depend on the constitution should still
verify that it exists before using it, since the setup step may
complete without creating the file. Default: no-op. Integrations
that need a root file (e.g. ``CLAUDE.md``) should override this.
Returns the created path (to be recorded in the manifest) or
``None``.
"""
return None

# -- Primitives — building blocks for setup() -------------------------

def shared_commands_dir(self) -> Path | None:
Expand Down
43 changes: 43 additions & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,49 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

def ensure_context_file(
self,
project_root: Path,
manifest: IntegrationManifest,
) -> Path | None:
"""Create a minimal root ``CLAUDE.md`` if missing.

Typically called from ``init()`` after
``ensure_constitution_from_template``. This file acts as a bridge
to the constitution at ``CONSTITUTION_REL_PATH`` and is only
created if that constitution file exists. Returns the created
path or ``None`` (existing file, or prerequisites not met).
"""
from specify_cli import CONSTITUTION_REL_PATH

if self.context_file is None:
return None

constitution = project_root / CONSTITUTION_REL_PATH
context_file = project_root / self.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"
)
return self.write_file_and_record(content, context_file, project_root, manifest)

def setup(
self,
project_root: Path,
Expand Down
110 changes: 110 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import yaml

from specify_cli import CONSTITUTION_REL_PATH
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.claude import ARGUMENT_HINTS
Expand Down Expand Up @@ -286,6 +287,115 @@ 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 after the constitution is in place."""

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

manifest = IntegrationManifest("claude", tmp_path)
created = integration.ensure_context_file(tmp_path, manifest)

claude_md = tmp_path / "CLAUDE.md"
assert claude_md.exists()
assert created == claude_md
content = claude_md.read_text(encoding="utf-8")
assert CONSTITUTION_REL_PATH.as_posix() 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}"

def test_ensure_context_file_skips_when_constitution_missing(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
result = integration.ensure_context_file(tmp_path, manifest)

assert result is None
assert not (tmp_path / "CLAUDE.md").exists()

def test_ensure_context_file_preserves_existing_claude_md(self, tmp_path):
integration = get_integration("claude")
constitution = tmp_path / CONSTITUTION_REL_PATH
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)
result = integration.ensure_context_file(tmp_path, manifest)

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

def test_setup_does_not_create_claude_md_without_constitution(self, tmp_path):
"""``setup()`` alone must not create CLAUDE.md — that's the context-file hook's job,
and it only runs after the constitution exists."""
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_init_cli_creates_claude_md_on_fresh_project(self, tmp_path):
"""End-to-end: a fresh ``specify init --ai claude`` must produce
BOTH the constitution AND CLAUDE.md, proving the init-flow ordering
is correct (context file created after constitution)."""
from typer.testing import CliRunner
from specify_cli import app

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

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

assert result.exit_code == 0, result.output

# Constitution must have been created by the init flow (not pre-seeded)
constitution = project / CONSTITUTION_REL_PATH
assert constitution.exists(), "init did not create the constitution"

# CLAUDE.md must exist and point at the constitution
claude_md = project / "CLAUDE.md"
assert claude_md.exists(), "init did not create CLAUDE.md"
content = claude_md.read_text(encoding="utf-8")
assert CONSTITUTION_REL_PATH.as_posix() 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