Skip to content

Commit 6fdce5b

Browse files
arun-guptaclaude
andcommitted
fix(claude): create CLAUDE.md after constitution, not during setup()
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) <noreply@anthropic.com>
1 parent 0d0e848 commit 6fdce5b

4 files changed

Lines changed: 67 additions & 32 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,12 @@ def init(
11911191

11921192
ensure_constitution_from_template(project_path, tracker=tracker)
11931193

1194+
# Post-constitution hook: let the integration create its root
1195+
# context file (e.g. CLAUDE.md) now that the constitution exists.
1196+
context_file = resolved_integration.ensure_context_file(project_path, manifest)
1197+
if context_file is not None:
1198+
manifest.save()
1199+
11941200
if not no_git:
11951201
tracker.start("git")
11961202
git_messages = []

src/specify_cli/integrations/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ def options(cls) -> list[IntegrationOption]:
8989
"""Return options this integration accepts. Default: none."""
9090
return []
9191

92+
def ensure_context_file(
93+
self,
94+
project_root: Path,
95+
manifest: "IntegrationManifest",
96+
) -> Path | None:
97+
"""Post-constitution hook: create the agent's root context file.
98+
99+
Called from ``init()`` after ``ensure_constitution_from_template``
100+
has run, so the constitution is guaranteed to exist when this is
101+
invoked. Default: no-op. Integrations that need a root file
102+
(e.g. ``CLAUDE.md``) should override this. Returns the created
103+
path (to be recorded in the manifest) or ``None``.
104+
"""
105+
return None
106+
92107
# -- Primitives — building blocks for setup() -------------------------
93108

94109
def shared_commands_dir(self) -> Path | None:

src/specify_cli/integrations/claude/__init__.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,21 +148,26 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
148148
out.append(line)
149149
return "".join(out)
150150

151-
@classmethod
152-
def ensure_claude_md(cls, project_root: Path) -> Path | None:
153-
"""Create a minimal root context file (``CLAUDE.md``) if missing.
154-
155-
Claude Code expects ``context_file`` at the project root; this file
156-
acts as a bridge to the constitution at ``CONSTITUTION_REL_PATH``.
157-
Returns the path if created, ``None`` otherwise.
151+
def ensure_context_file(
152+
self,
153+
project_root: Path,
154+
manifest: IntegrationManifest,
155+
) -> Path | None:
156+
"""Create a minimal root ``CLAUDE.md`` if missing.
157+
158+
Called from ``init()`` AFTER ``ensure_constitution_from_template``
159+
so the constitution file is guaranteed to exist at this point.
160+
This file acts as a bridge to the constitution at
161+
``CONSTITUTION_REL_PATH``. Returns the created path or ``None``
162+
(existing file, or prerequisites not met).
158163
"""
159164
from specify_cli import CONSTITUTION_REL_PATH
160165

161-
if cls.context_file is None:
166+
if self.context_file is None:
162167
return None
163168

164169
constitution = project_root / CONSTITUTION_REL_PATH
165-
context_file = project_root / cls.context_file
170+
context_file = project_root / self.context_file
166171
if context_file.exists() or not constitution.exists():
167172
return None
168173

@@ -185,6 +190,7 @@ def ensure_claude_md(cls, project_root: Path) -> Path | None:
185190
"Do not infer. Do not proceed.\n\n"
186191
)
187192
context_file.write_text(content, encoding="utf-8")
193+
self.record_file_in_manifest(context_file, project_root, manifest)
188194
return context_file
189195

190196
def setup(
@@ -194,15 +200,9 @@ def setup(
194200
parsed_options: dict[str, Any] | None = None,
195201
**opts: Any,
196202
) -> list[Path]:
197-
"""Install Claude skills, create CLAUDE.md, then inject frontmatter flags and argument-hints."""
203+
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
198204
created = super().setup(project_root, manifest, parsed_options, **opts)
199205

200-
# Create root CLAUDE.md pointing to the constitution
201-
claude_md = self.ensure_claude_md(project_root)
202-
if claude_md is not None:
203-
created.append(claude_md)
204-
self.record_file_in_manifest(claude_md, project_root, manifest)
205-
206206
# Post-process generated skill files
207207
skills_dir = self.skills_dest(project_root).resolve()
208208

tests/integrations/test_integration_claude.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -304,35 +304,36 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
304304

305305

306306
class TestClaudeMdCreation:
307-
"""Verify that CLAUDE.md is created during setup when constitution exists."""
307+
"""Verify that CLAUDE.md is created after the constitution is in place."""
308308

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

315315
manifest = IntegrationManifest("claude", tmp_path)
316-
created = integration.setup(tmp_path, manifest, script_type="sh")
316+
created = integration.ensure_context_file(tmp_path, manifest)
317317

318318
claude_md = tmp_path / "CLAUDE.md"
319319
assert claude_md.exists()
320+
assert created == claude_md
320321
content = claude_md.read_text(encoding="utf-8")
321322
assert ".specify/memory/constitution.md" in content
322-
assert claude_md in created
323323
for section in EXPECTED_CLAUDE_MD_SECTIONS:
324324
assert section in content, f"missing section header: {section}"
325325
for command in EXPECTED_CLAUDE_MD_COMMANDS:
326326
assert f"`{command}`" in content, f"missing command: {command}"
327327

328-
def test_setup_skips_claude_md_when_constitution_missing(self, tmp_path):
328+
def test_ensure_context_file_skips_when_constitution_missing(self, tmp_path):
329329
integration = get_integration("claude")
330330
manifest = IntegrationManifest("claude", tmp_path)
331-
integration.setup(tmp_path, manifest, script_type="sh")
331+
result = integration.ensure_context_file(tmp_path, manifest)
332332

333+
assert result is None
333334
assert not (tmp_path / "CLAUDE.md").exists()
334335

335-
def test_setup_preserves_existing_claude_md(self, tmp_path):
336+
def test_ensure_context_file_preserves_existing_claude_md(self, tmp_path):
336337
integration = get_integration("claude")
337338
constitution = tmp_path / ".specify" / "memory" / "constitution.md"
338339
constitution.parent.mkdir(parents=True, exist_ok=True)
@@ -342,37 +343,50 @@ def test_setup_preserves_existing_claude_md(self, tmp_path):
342343
claude_md.write_text("# Custom content\n", encoding="utf-8")
343344

344345
manifest = IntegrationManifest("claude", tmp_path)
345-
integration.setup(tmp_path, manifest, script_type="sh")
346+
result = integration.ensure_context_file(tmp_path, manifest)
346347

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

349-
def test_init_cli_creates_claude_md(self, tmp_path):
351+
def test_setup_does_not_create_claude_md_without_constitution(self, tmp_path):
352+
"""``setup()`` alone must not create CLAUDE.md — that's the context-file hook's job,
353+
and it only runs after the constitution exists."""
354+
integration = get_integration("claude")
355+
manifest = IntegrationManifest("claude", tmp_path)
356+
integration.setup(tmp_path, manifest, script_type="sh")
357+
assert not (tmp_path / "CLAUDE.md").exists()
358+
359+
def test_init_cli_creates_claude_md_on_fresh_project(self, tmp_path):
360+
"""End-to-end: a fresh ``specify init --ai claude`` must produce
361+
BOTH the constitution AND CLAUDE.md, proving the init-flow ordering
362+
is correct (context file created after constitution)."""
350363
from typer.testing import CliRunner
351364
from specify_cli import app
352365

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

356-
# Pre-create constitution so ensure_claude_md has something to gate on
357-
constitution = project / ".specify" / "memory" / "constitution.md"
358-
constitution.parent.mkdir(parents=True, exist_ok=True)
359-
constitution.write_text("# Constitution\n", encoding="utf-8")
360-
361369
old_cwd = os.getcwd()
362370
try:
363371
os.chdir(project)
364372
runner = CliRunner()
365373
result = runner.invoke(
366374
app,
367-
["init", "--here", "--force", "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
375+
["init", "--here", "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
368376
catch_exceptions=False,
369377
)
370378
finally:
371379
os.chdir(old_cwd)
372380

373381
assert result.exit_code == 0, result.output
382+
383+
# Constitution must have been created by the init flow (not pre-seeded)
384+
constitution = project / ".specify" / "memory" / "constitution.md"
385+
assert constitution.exists(), "init did not create the constitution"
386+
387+
# CLAUDE.md must exist and point at the constitution
374388
claude_md = project / "CLAUDE.md"
375-
assert claude_md.exists()
389+
assert claude_md.exists(), "init did not create CLAUDE.md"
376390
content = claude_md.read_text(encoding="utf-8")
377391
assert ".specify/memory/constitution.md" in content
378392
for section in EXPECTED_CLAUDE_MD_SECTIONS:

0 commit comments

Comments
 (0)