From 425ec32d9bbe2aac2b31720e7245be71c122bdbb Mon Sep 17 00:00:00 2001 From: Arun Gupta Date: Thu, 26 Mar 2026 14:33:45 -0700 Subject: [PATCH 1/7] fix(init): generate root CLAUDE.md for --ai claude Ensure `specify init --ai claude` creates a minimal root CLAUDE.md pointing to `.specify/memory/constitution.md`, and add a regression test for issue #1983. Made-with: Cursor --- src/specify_cli/__init__.py | 47 +++++++++++++++++++++++++++++++++++++ tests/test_ai_skills.py | 35 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1f0eaf475d..bf7ad24c76 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1461,6 +1461,48 @@ 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). + """ + 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 + + 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" + ) + + 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" @@ -2071,6 +2113,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 [ @@ -2137,6 +2181,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 diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index f0e220e26a..4b30a08659 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -693,6 +693,41 @@ class TestNewProjectCommandSkip: download_and_extract_template patched to create local fixtures. """ + @pytest.mark.skipif( + shutil.which("bash") is None or shutil.which("zip") is None, + reason="offline scaffolding requires bash + zip", + ) + def test_init_claude_creates_root_CLAUDE_md(self, tmp_path): + from typer.testing import CliRunner + + runner = CliRunner() + target = tmp_path / "claude-proj" + + result = runner.invoke( + app, + [ + "init", + str(target), + "--ai", + "claude", + "--offline", + "--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 _fake_extract(self, agent, project_path, **_kwargs): """Simulate template extraction: create agent commands dir.""" agent_cfg = AGENT_CONFIG.get(agent, {}) From 89d2ab2f477660a53958a1428ead474ad6b14ebb Mon Sep 17 00:00:00 2001 From: Arun Gupta Date: Thu, 26 Mar 2026 15:04:21 -0700 Subject: [PATCH 2/7] fix(init): gate CLAUDE.md on constitution presence generate CLAUDE.md only when `.specify/memory/constitution.md` exists to avoid misleading guidance, and make the regression test deterministic by patching init scaffolding. Made-with: Cursor --- src/specify_cli/__init__.py | 10 +++++++ tests/test_ai_skills.py | 59 ++++++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index bf7ad24c76..0a8e877c74 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1467,6 +1467,7 @@ def ensure_claude_md(project_path: Path, tracker: StepTracker | None = None) -> 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: @@ -1474,6 +1475,15 @@ def ensure_claude_md(project_path: Path, tracker: StepTracker | None = None) -> 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. " diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 4b30a08659..3113f20b95 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -29,7 +29,9 @@ DEFAULT_SKILLS_DIR, SKILL_DESCRIPTIONS, AGENT_CONFIG, + StepTracker, app, + ensure_claude_md, ) @@ -693,30 +695,39 @@ class TestNewProjectCommandSkip: download_and_extract_template patched to create local fixtures. """ - @pytest.mark.skipif( - shutil.which("bash") is None or shutil.which("zip") is None, - reason="offline scaffolding requires bash + zip", - ) def test_init_claude_creates_root_CLAUDE_md(self, tmp_path): from typer.testing import CliRunner runner = CliRunner() target = tmp_path / "claude-proj" - result = runner.invoke( - app, - [ - "init", - str(target), - "--ai", - "claude", - "--offline", - "--ignore-agent-tools", - "--no-git", - "--script", - "sh", - ], - ) + 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 @@ -728,6 +739,18 @@ def test_init_claude_creates_root_CLAUDE_md(self, tmp_path): 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, {}) From 0d0e848ac75cfd5e52d7975081fe182f9941112e Mon Sep 17 00:00:00 2001 From: Arun Gupta Date: Fri, 10 Apr 2026 15:15:44 -0700 Subject: [PATCH 3/7] fix(claude): address review feedback on CLAUDE.md generation - Introduce CONSTITUTION_REL_PATH constant in specify_cli and reuse it in ensure_constitution_from_template and ClaudeIntegration.ensure_claude_md so the path cannot drift between init scaffolding and integration setup. - Make ensure_claude_md a classmethod that uses cls.context_file instead of hardcoding "CLAUDE.md". - Expand CLAUDE.md body to list all core speckit workflow commands (constitution, specify, clarify, plan, tasks, analyze, checklist, implement) so Claude Code sees the full workflow. - Strengthen tests to assert every section header and every command line via shared EXPECTED_CLAUDE_MD_SECTIONS / _COMMANDS constants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/specify_cli/__init__.py | 7 +++- .../integrations/claude/__init__.py | 32 ++++++++++++------- tests/integrations/test_integration_claude.py | 25 +++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e37c4b45f6..6267ee526c 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 diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 5a619a6a57..0c808d266b 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -148,34 +148,44 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) - @staticmethod - def ensure_claude_md(project_root: Path) -> Path | None: - """Create a minimal root ``CLAUDE.md`` if missing. + @classmethod + def ensure_claude_md(cls, project_root: Path) -> Path | None: + """Create a minimal root context file (``CLAUDE.md``) if missing. - Claude Code expects ``CLAUDE.md`` at the project root; this file - acts as a bridge to ``.specify/memory/constitution.md``. + 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. """ - constitution = project_root / ".specify" / "memory" / "constitution.md" - claude_file = project_root / "CLAUDE.md" - if claude_file.exists() or not constitution.exists(): + 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" - "Read `.specify/memory/constitution.md` first. It is the authoritative source of truth for this project. " + 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" ) - claude_file.write_text(content, encoding="utf-8") - return claude_file + context_file.write_text(content, encoding="utf-8") + return context_file def setup( self, diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 9b1e940f84..937a1781b2 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -286,6 +286,23 @@ 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.""" @@ -303,6 +320,10 @@ def test_setup_creates_claude_md_when_constitution_exists(self, tmp_path): 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") @@ -354,6 +375,10 @@ def test_init_cli_creates_claude_md(self, tmp_path): 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: From 6fdce5b5b951c30ca2917d6d18f0c7ac7c1eac46 Mon Sep 17 00:00:00 2001 From: Arun Gupta Date: Fri, 10 Apr 2026 15:49:20 -0700 Subject: [PATCH 4/7] fix(claude): create CLAUDE.md after constitution, not during setup() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous placement called ensure_claude_md() from ClaudeIntegration.setup(), which runs BEFORE ensure_constitution_from_template() in the init() flow. Since the creation is gated on the constitution file existing, CLAUDE.md was silently skipped on a fresh `specify init --ai claude` — the exact scenario this PR is meant to fix. The previous tests masked this bug by pre-creating the constitution file before invoking setup() or the CLI, so they never exercised the real ordering. Fix: - Add a generic `ensure_context_file(project_root, manifest)` hook on IntegrationBase (default no-op) that runs after the constitution is in place. Integrations needing a root context file (e.g. CLAUDE.md) override it. - Move the CLAUDE.md creation from ClaudeIntegration.setup() into ClaudeIntegration.ensure_context_file(), which also records the file in the integration manifest. - Call resolved_integration.ensure_context_file(...) from init() immediately after ensure_constitution_from_template(...), and re-save the manifest if a file was created. Tests: - Rewrite the CLI end-to-end test to start from a truly empty project (no pre-created constitution) so it fails if the ordering regresses. It now asserts that BOTH the constitution and CLAUDE.md exist. - Add a test proving setup() alone does NOT create CLAUDE.md. - Update the three unit tests to call ensure_context_file directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/specify_cli/__init__.py | 6 +++ src/specify_cli/integrations/base.py | 15 ++++++ .../integrations/claude/__init__.py | 32 ++++++------- tests/integrations/test_integration_claude.py | 46 ++++++++++++------- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6267ee526c..570c8e1543 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1191,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 1b09347dcd..4d15ae6efd 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -89,6 +89,21 @@ 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 hook: create the agent's root context file. + + Called from ``init()`` after ``ensure_constitution_from_template`` + has run, so the constitution is guaranteed to exist when this is + invoked. 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 0c808d266b..41187436bb 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -148,21 +148,26 @@ 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. + def ensure_context_file( + self, + project_root: Path, + manifest: IntegrationManifest, + ) -> Path | None: + """Create a minimal root ``CLAUDE.md`` if missing. + + Called from ``init()`` AFTER ``ensure_constitution_from_template`` + so the constitution file is guaranteed to exist at this point. + This file acts as a bridge to the constitution at + ``CONSTITUTION_REL_PATH``. Returns the created path or ``None`` + (existing file, or prerequisites not met). """ from specify_cli import CONSTITUTION_REL_PATH - if cls.context_file is None: + if self.context_file is None: return None constitution = project_root / CONSTITUTION_REL_PATH - context_file = project_root / cls.context_file + context_file = project_root / self.context_file if context_file.exists() or not constitution.exists(): return None @@ -185,6 +190,7 @@ def ensure_claude_md(cls, project_root: Path) -> Path | None: "Do not infer. Do not proceed.\n\n" ) context_file.write_text(content, encoding="utf-8") + self.record_file_in_manifest(context_file, project_root, manifest) return context_file def setup( @@ -194,15 +200,9 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, create CLAUDE.md, then inject frontmatter flags and argument-hints.""" + """Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint.""" 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() diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 937a1781b2..cb2a7d2d86 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -304,35 +304,36 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): class TestClaudeMdCreation: - """Verify that CLAUDE.md is created during setup when constitution exists.""" + """Verify that CLAUDE.md is created after the constitution is in place.""" - def test_setup_creates_claude_md_when_constitution_exists(self, tmp_path): + def test_ensure_context_file_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") + 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 ".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): + def test_ensure_context_file_skips_when_constitution_missing(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) - integration.setup(tmp_path, manifest, script_type="sh") + result = integration.ensure_context_file(tmp_path, manifest) + assert result is None assert not (tmp_path / "CLAUDE.md").exists() - def test_setup_preserves_existing_claude_md(self, tmp_path): + def test_ensure_context_file_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) @@ -342,37 +343,50 @@ def test_setup_preserves_existing_claude_md(self, tmp_path): claude_md.write_text("# Custom content\n", encoding="utf-8") manifest = IntegrationManifest("claude", tmp_path) - integration.setup(tmp_path, manifest, script_type="sh") + 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_init_cli_creates_claude_md(self, tmp_path): + 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() - # 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"], + ["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 / ".specify" / "memory" / "constitution.md" + 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() + assert claude_md.exists(), "init did not create CLAUDE.md" content = claude_md.read_text(encoding="utf-8") assert ".specify/memory/constitution.md" in content for section in EXPECTED_CLAUDE_MD_SECTIONS: From b798ab5291d671ec64d897f2855ba2d61ce62858 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:06:01 -0500 Subject: [PATCH 5/7] Update src/specify_cli/integrations/claude/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/claude/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 41187436bb..21eb9d38ff 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -155,11 +155,11 @@ def ensure_context_file( ) -> Path | None: """Create a minimal root ``CLAUDE.md`` if missing. - Called from ``init()`` AFTER ``ensure_constitution_from_template`` - so the constitution file is guaranteed to exist at this point. - This file acts as a bridge to the constitution at - ``CONSTITUTION_REL_PATH``. Returns the created path or ``None`` - (existing file, or prerequisites not met). + 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 From b9fd10db4a00315cbbdee8405a460989ee9b9707 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:06:29 -0500 Subject: [PATCH 6/7] Update src/specify_cli/integrations/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 4d15ae6efd..3fa7f59b4d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -94,13 +94,15 @@ def ensure_context_file( project_root: Path, manifest: "IntegrationManifest", ) -> Path | None: - """Post-constitution hook: create the agent's root context file. + """Post-constitution-setup hook: create the agent's root context file. Called from ``init()`` after ``ensure_constitution_from_template`` - has run, so the constitution is guaranteed to exist when this is - invoked. 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``. + 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 From c44c91cae1868939ca633c4c4802d22de9bcc540 Mon Sep 17 00:00:00 2001 From: Arun Gupta Date: Fri, 10 Apr 2026 16:30:21 -0700 Subject: [PATCH 7/7] fix(claude): use write_file_and_record and CONSTITUTION_REL_PATH in tests Addresses two review comments: - ensure_context_file now uses the base class's write_file_and_record() helper instead of Path.write_text + a separate record_file_in_manifest call. This matches the pattern used everywhere else in integrations, normalizes \r\n -> \n to avoid platform newline translation, and keeps the write+manifest update consistent. - Tests now import and use CONSTITUTION_REL_PATH instead of hardcoding ".specify/memory/constitution.md", so the path stays in sync with the production constant. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/specify_cli/integrations/claude/__init__.py | 4 +--- tests/integrations/test_integration_claude.py | 11 ++++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 21eb9d38ff..a1166d67a0 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -189,9 +189,7 @@ def ensure_context_file( "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") - self.record_file_in_manifest(context_file, project_root, manifest) - return context_file + return self.write_file_and_record(content, context_file, project_root, manifest) def setup( self, diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index cb2a7d2d86..a52c5ccd5c 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 @@ -308,7 +309,7 @@ class TestClaudeMdCreation: def test_ensure_context_file_creates_claude_md_when_constitution_exists(self, tmp_path): integration = get_integration("claude") - constitution = tmp_path / ".specify" / "memory" / "constitution.md" + constitution = tmp_path / CONSTITUTION_REL_PATH constitution.parent.mkdir(parents=True, exist_ok=True) constitution.write_text("# Constitution\n", encoding="utf-8") @@ -319,7 +320,7 @@ def test_ensure_context_file_creates_claude_md_when_constitution_exists(self, tm assert claude_md.exists() assert created == claude_md content = claude_md.read_text(encoding="utf-8") - assert ".specify/memory/constitution.md" in content + 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: @@ -335,7 +336,7 @@ def test_ensure_context_file_skips_when_constitution_missing(self, tmp_path): def test_ensure_context_file_preserves_existing_claude_md(self, tmp_path): integration = get_integration("claude") - constitution = tmp_path / ".specify" / "memory" / "constitution.md" + constitution = tmp_path / CONSTITUTION_REL_PATH constitution.parent.mkdir(parents=True, exist_ok=True) constitution.write_text("# Constitution\n", encoding="utf-8") @@ -381,14 +382,14 @@ def test_init_cli_creates_claude_md_on_fresh_project(self, tmp_path): assert result.exit_code == 0, result.output # Constitution must have been created by the init flow (not pre-seeded) - constitution = project / ".specify" / "memory" / "constitution.md" + 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 ".specify/memory/constitution.md" in content + 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: