From 4a43ada65ed5c7a5f4bf695107857c4a44e922f2 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 23 Feb 2026 07:27:38 +0000 Subject: [PATCH 1/2] Move agent rules to ~/.claude/CLAUDE.md (global config) --- sandbox/entrypoint.py | 18 ++++++-------- tests/sandbox/test_entrypoint.py | 41 ++++++++++++++------------------ 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/sandbox/entrypoint.py b/sandbox/entrypoint.py index 8499c256c..cf470e2e7 100644 --- a/sandbox/entrypoint.py +++ b/sandbox/entrypoint.py @@ -810,8 +810,10 @@ 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: @@ -819,18 +821,12 @@ def setup_agent_rules(config: Config, logger: Logger) -> None: 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) - - 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)") diff --git a/tests/sandbox/test_entrypoint.py b/tests/sandbox/test_entrypoint.py index 99a2236ee..983b6a042 100644 --- a/tests/sandbox/test_entrypoint.py +++ b/tests/sandbox/test_entrypoint.py @@ -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) @@ -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 @@ -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) @@ -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 @@ -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 @@ -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) @@ -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 @@ -703,7 +698,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() assert "# Mission" in content assert "# Code Standards" in content From 4541a772b9aab1b9ed90668fb335d4b794ff75b2 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:36:37 +0000 Subject: [PATCH 2/2] Address review feedback on agent rules location change - Fix test_no_rules_does_nothing: remove stale os.lchown patch, add config.claude_dir to mock setup for consistency with other tests. - Update sandbox/.claude/rules/README.md to reference ~/.claude/CLAUDE.md instead of the old ~/CLAUDE.md location. - Add cleanup of stale ~/CLAUDE.md and ~/repos/CLAUDE.md symlink in setup_agent_rules to prevent duplicate rules on persistent volumes. --- sandbox/.claude/rules/README.md | 4 ++-- sandbox/entrypoint.py | 9 +++++++++ tests/sandbox/test_entrypoint.py | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/sandbox/.claude/rules/README.md b/sandbox/.claude/rules/README.md index 2ce729fc1..90501113d 100644 --- a/sandbox/.claude/rules/README.md +++ b/sandbox/.claude/rules/README.md @@ -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. diff --git a/sandbox/entrypoint.py b/sandbox/entrypoint.py index cf470e2e7..b5105ebe7 100644 --- a/sandbox/entrypoint.py +++ b/sandbox/entrypoint.py @@ -826,6 +826,15 @@ def setup_agent_rules(config: Config, logger: Logger) -> None: claude_md.write_text("\n\n---\n\n".join(content_parts)) os.chown(claude_md, 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/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)") diff --git a/tests/sandbox/test_entrypoint.py b/tests/sandbox/test_entrypoint.py index 983b6a042..14744ce38 100644 --- a/tests/sandbox/test_entrypoint.py +++ b/tests/sandbox/test_entrypoint.py @@ -704,11 +704,11 @@ def patched_path_new(cls, *args, **kwargs): 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