diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e37c4b45f..570c8e154 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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 = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ @@ -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 @@ -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 = [] diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 1b09347dc..3fa7f59b4 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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: diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0..a1166d67a 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -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, diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df17..a52c5ccd5 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -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 @@ -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."""