Skip to content
Merged
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
4 changes: 2 additions & 2 deletions sandbox/.claude/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ This directory contains instructions for the AI agent (Claude Code) operating in
Claude Code reads `CLAUDE.md` files automatically when starting. During container startup, all rule files are combined into a single `CLAUDE.md`:

**Installation:**
- `~/CLAUDE.md` → All rules combined
- `~/.claude/CLAUDE.md` → All rules combined (user-level global config)

**Why one file?** Since `~/repos/` is mounted from the host (not copied), we can't reliably write to it during container startup. Combining all rules into `~/CLAUDE.md` ensures they're always available.
**Why one file?** Since `~/repos/` is mounted from the host (not copied), we can't reliably write to it during container startup. Combining all rules into `~/.claude/CLAUDE.md` ensures they're always available regardless of CWD.

**Note**: `CLAUDE.md` is the [official Claude Code format](https://www.anthropic.com/engineering/claude-code-best-practices) for providing context and instructions to the agent.

Expand Down
25 changes: 15 additions & 10 deletions sandbox/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,27 +810,32 @@ def setup_agent_rules(config: Config, logger: Logger) -> None:
if not (rules_dir / "mission.md").exists():
return

# Combine rules into CLAUDE.md
claude_md = config.user_home / "CLAUDE.md"
# Combine rules into ~/.claude/CLAUDE.md (user-level global config)
# This is the documented Claude Code location for user-wide instructions,
# loaded automatically regardless of CWD or which repo is checked out.
claude_md = config.claude_dir / "CLAUDE.md"
content_parts = []

for rule_file in rules_order:
rule_path = rules_dir / rule_file
if rule_path.exists():
content_parts.append(rule_path.read_text())

# Ensure ~/.claude/ exists (setup_claude creates it later, but we need it now)
config.claude_dir.mkdir(parents=True, exist_ok=True)
claude_md.write_text("\n\n---\n\n".join(content_parts))
os.chown(claude_md, config.runtime_uid, config.runtime_gid)

# Symlink in ~/repos/
if config.repos_dir.exists():
repos_claude = config.repos_dir / "CLAUDE.md"
if repos_claude.is_symlink():
repos_claude.unlink()
repos_claude.symlink_to(claude_md)
os.lchown(repos_claude, config.runtime_uid, config.runtime_gid)
# Clean up stale files from previous location (~/CLAUDE.md, ~/repos/CLAUDE.md symlink)
# to prevent duplicate rules when upgrading on persistent volumes.
stale_home = config.user_home / "CLAUDE.md"
if stale_home.exists() or stale_home.is_symlink():
stale_home.unlink()
stale_repos = config.repos_dir / "CLAUDE.md"
if stale_repos.exists() or stale_repos.is_symlink():
stale_repos.unlink()

logger.success("AI agent rules installed: ~/CLAUDE.md (symlinked to ~/repos/)")
logger.success("AI agent rules installed: ~/.claude/CLAUDE.md (global)")
logger.info(f" Combined {len(rules_order)} rule files (index-based per LLM Doc ADR)")
logger.info(" Note: Reference docs at $EGG_REPO_PATH/docs/ (fetched on-demand)")

Expand Down
45 changes: 20 additions & 25 deletions tests/sandbox/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,11 +559,8 @@ def test_counts_mounted_repos(self, temp_dir, capsys):
class TestSetupAgentRules:
"""Tests for the setup_agent_rules function."""

@patch("os.lchown")
@patch("os.chown")
def test_includes_all_rules_in_any_session(
self, mock_chown, mock_lchown, temp_dir, monkeypatch
):
def test_includes_all_rules_in_any_session(self, mock_chown, temp_dir, monkeypatch):
"""All rules including CLI tools are included regardless of pipeline mode."""
monkeypatch.delenv("EGG_PIPELINE_ID", raising=False)

Expand All @@ -583,13 +580,13 @@ def test_includes_all_rules_in_any_session(
for f in all_rules:
(rules_dir / f).write_text(f"# {f} content")

# Create repos dir for symlink
repos_dir = temp_dir / "repos"
repos_dir.mkdir()
claude_dir = temp_dir / ".claude"
claude_dir.mkdir()

config = MagicMock()
config.user_home = temp_dir
config.repos_dir = repos_dir
config.claude_dir = claude_dir
config.repos_dir = temp_dir / "repos"
config.runtime_uid = 1000
config.runtime_gid = 1000

Expand All @@ -607,14 +604,13 @@ def patched_path_new(cls, *args, **kwargs):
with patch.object(Path, "__new__", patched_path_new):
entrypoint.setup_agent_rules(config, logger)

claude_md = temp_dir / "CLAUDE.md"
claude_md = claude_dir / "CLAUDE.md"
content = claude_md.read_text()
for f in all_rules:
assert f"{f} content" in content, f"Missing rule: {f}"

@patch("os.lchown")
@patch("os.chown")
def test_core_rules_order_preserved(self, mock_chown, mock_lchown, temp_dir, monkeypatch):
def test_core_rules_order_preserved(self, mock_chown, temp_dir, monkeypatch):
"""All rules are included in the expected order."""
monkeypatch.delenv("EGG_PIPELINE_ID", raising=False)

Expand All @@ -633,12 +629,13 @@ def test_core_rules_order_preserved(self, mock_chown, mock_lchown, temp_dir, mon
for f in core_rules:
(rules_dir / f).write_text(f"## {f} marker")

repos_dir = temp_dir / "repos"
repos_dir.mkdir()
claude_dir = temp_dir / ".claude"
claude_dir.mkdir()

config = MagicMock()
config.user_home = temp_dir
config.repos_dir = repos_dir
config.claude_dir = claude_dir
config.repos_dir = temp_dir / "repos"
config.runtime_uid = 1000
config.runtime_gid = 1000

Expand All @@ -655,7 +652,7 @@ def patched_path_new(cls, *args, **kwargs):
with patch.object(Path, "__new__", patched_path_new):
entrypoint.setup_agent_rules(config, logger)

claude_md = temp_dir / "CLAUDE.md"
claude_md = claude_dir / "CLAUDE.md"
content = claude_md.read_text()

# Verify all core rules present and in order
Expand All @@ -667,11 +664,8 @@ def patched_path_new(cls, *args, **kwargs):
positions.append(pos)
assert positions == sorted(positions), "Core rules are not in expected order"

@patch("os.lchown")
@patch("os.chown")
def test_missing_optional_rule_file_skipped(
self, mock_chown, mock_lchown, temp_dir, monkeypatch
):
def test_missing_optional_rule_file_skipped(self, mock_chown, temp_dir, monkeypatch):
"""Missing individual rule files are gracefully skipped."""
monkeypatch.delenv("EGG_PIPELINE_ID", raising=False)

Expand All @@ -681,12 +675,13 @@ def test_missing_optional_rule_file_skipped(
(rules_dir / "mission.md").write_text("# Mission")
(rules_dir / "code-standards.md").write_text("# Code Standards")

repos_dir = temp_dir / "repos"
repos_dir.mkdir()
claude_dir = temp_dir / ".claude"
claude_dir.mkdir()

config = MagicMock()
config.user_home = temp_dir
config.repos_dir = repos_dir
config.claude_dir = claude_dir
config.repos_dir = temp_dir / "repos"
config.runtime_uid = 1000
config.runtime_gid = 1000

Expand All @@ -703,17 +698,17 @@ def patched_path_new(cls, *args, **kwargs):
with patch.object(Path, "__new__", patched_path_new):
entrypoint.setup_agent_rules(config, logger)

claude_md = temp_dir / "CLAUDE.md"
claude_md = claude_dir / "CLAUDE.md"
content = claude_md.read_text()
assert "# Mission" in content
assert "# Code Standards" in content

@patch("os.chown")
@patch("os.lchown")
def test_no_rules_does_nothing(self, mock_lchown, mock_chown, temp_dir):
def test_no_rules_does_nothing(self, mock_chown, temp_dir):
"""Does nothing when no rules directory exists."""
config = MagicMock()
config.user_home = temp_dir
config.claude_dir = temp_dir / ".claude"
config.repos_dir = temp_dir / "repos"
config.runtime_uid = 1000
config.runtime_gid = 1000
Expand Down
Loading