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
13 changes: 12 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ def _build_ai_deprecation_warning(
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 @@ -842,7 +847,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 @@ -1281,6 +1286,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 @@ -208,6 +208,23 @@ def dispatch_command(
"stderr": result.stderr,
}

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
50 changes: 50 additions & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,56 @@ def post_process_skill_content(self, content: str) -> str:
updated = self._inject_hook_command_note(updated)
return updated

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():
Comment thread
arun-gupta marked this conversation as resolved.
return None

constitution_rel = CONSTITUTION_REL_PATH.as_posix()
inv = self.build_command_invocation
command_lines = [
(inv("constitution"), "establish or amend project principles"),
(inv("specify"), "generate spec"),
(inv("clarify"), f"ask structured de-risking questions (before `{inv('plan')}`)"),
(inv("plan"), "generate plan"),
(inv("tasks"), "generate task list"),
(inv("analyze"), f"cross-artifact consistency report (after `{inv('tasks')}`)"),
(inv("checklist"), "generate quality checklists"),
(inv("implement"), "execute plan"),
]
commands_section = "".join(
f"- `{command}` — {description}\n" for command, description in command_lines
)
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"
f"{commands_section}\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
108 changes: 108 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,113 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
assert "speckit-research" in metadata.get("registered_skills", [])


_CLAUDE = get_integration("claude")
EXPECTED_CLAUDE_MD_COMMANDS = tuple(
_CLAUDE.build_command_invocation(stem)
for stem in (
"constitution", "specify", "clarify", "plan",
"tasks", "analyze", "checklist", "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")
Comment thread
arun-gupta marked this conversation as resolved.

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}"

Comment thread
arun-gupta marked this conversation as resolved.
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