Coverage for src/infra/tools/env.py: 57%

1"""Environment configuration and loading for mala.

2

3 Centralizes config paths and dotenv loading. Import this module early

4 to ensure environment variables are set before Braintrust setup.

5"""

6

7 import os

8 from pathlib import Path

9

10 from dotenv import load_dotenv

11

12

13# User config directory (stores .env, runs, etc.)

14# Respects XDG_CONFIG_HOME if set, otherwise defaults to ~/.config

15 def _get_xdg_config_home() -> Path:

16 """Get XDG_CONFIG_HOME or default to ~/.config."""

17 return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))

18

19

20 USER_CONFIG_DIR = _get_xdg_config_home() / "mala"

21

22

23 def get_cache_dir() -> Path:

24 """Get the mala cache directory.

25

26 Returns the path to mala's cache directory, following XDG conventions.

27 Uses $XDG_CONFIG_HOME/mala/.cache if XDG_CONFIG_HOME is set,

28 otherwise ~/.config/mala/.cache.

29

30 The directory is created if it doesn't exist.

31

32 Returns:

33 Path to the cache directory.

34 """

35 cache_dir = USER_CONFIG_DIR / ".cache"

36 cache_dir.mkdir(parents=True, exist_ok=True)

37 return cache_dir

38

39

40# Run metadata directory

41# Can be overridden via MALA_RUNS_DIR environment variable

42 def get_runs_dir() -> Path:

43 """Get the runs directory, respecting MALA_RUNS_DIR env var.

44

45 This function evaluates the env var at call time, so it respects

46 values loaded from .env via load_user_env().

47 """

48 return Path(os.environ.get("MALA_RUNS_DIR", str(USER_CONFIG_DIR / "runs")))

49

50

51 def get_repo_runs_dir(repo_path: Path) -> Path:

52 """Get runs directory segmented by repo path.

53

54 Converts repo path to a safe directory name by replacing '/' with '-'.

55 Example: /home/cyou/mala -> -home-cyou-mala

56

57 Args:

58 repo_path: Repository path to segment runs by.

59

60 Returns:

61 Path to the repo-specific runs directory.

62 """

63 safe_name = encode_repo_path(repo_path)

64 return get_runs_dir() / safe_name

65

66

67# Lock directory for multi-agent coordination

68# Can be overridden via MALA_LOCK_DIR environment variable

69 def get_lock_dir() -> Path:

70 """Get the lock directory, respecting MALA_LOCK_DIR env var.

71

72 This function evaluates the env var at call time, so it respects

73 values loaded from .env via load_user_env().

74 """

75 return Path(os.environ.get("MALA_LOCK_DIR", "/tmp/mala-locks"))

76

77

78# Lock scripts directory (relative to this file: src/infra/tools/env.py -> src/scripts/)

79 SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts"

80

81# Prompts directory (relative to this file: src/infra/tools/env.py -> src/prompts/)

82 PROMPTS_DIR = Path(__file__).resolve().parents[2] / "prompts"

83

84

85 def load_user_env() -> None:

86 """Load environment from user config directory.

87

88 Loads ${USER_CONFIG_DIR}/.env (typically ~/.config/mala/.env).

89 Call this early for Braintrust API key setup before SDK imports.

90 """

91 load_dotenv(dotenv_path=USER_CONFIG_DIR / ".env")

92

93

94 def load_env(repo_path: Path | None = None) -> None:

95 """Load environment from user config and optionally repo.

96

97 NOTE: The repo_path parameter is for TESTING ONLY. Production code should

98 only use load_user_env() to load from ~/.config/mala/.env.

99

100 Args:

101 repo_path: Optional repository path. If provided, loads <repo_path>/.env

102 with override=True. FOR TESTING ONLY.

103 """

104 load_user_env()

105 if repo_path is not None:

106 load_dotenv(dotenv_path=repo_path / ".env", override=True)

107

108

109 def encode_repo_path(repo_path: Path) -> str:

110 """Encode repo path to match Claude SDK project directory naming.

111

112 Claude SDK stores session logs in ~/.claude/projects/{encoded-path}/.

113 The encoding replaces path separators with hyphens, normalizes underscores

114 to hyphens, and prefixes with a hyphen.

115

116 Example: /home/cyou/mala -> -home-cyou-mala

117

118 Args:

119 repo_path: Repository path to encode.

120

121 Returns:

122 Encoded path string suitable for Claude projects directory.

123 """

124 resolved = repo_path.resolve()

125 # Skip root and join parts with hyphens, prefix with hyphen.

126 # Normalize underscores to hyphens to match Claude SDK project dir naming.

127 encoded = "-" + "-".join(resolved.parts[1:])

128 return encoded.replace("_", "-")

129

130

131 def get_claude_config_dir() -> Path:

132 """Get the Claude config directory, respecting CLAUDE_CONFIG_DIR env var.

133

134 Claude SDK uses ~/.claude by default, but this can be overridden

135 via CLAUDE_CONFIG_DIR environment variable for testing.

136

137 Returns:

138 Path to the Claude config directory.

139 """

140 return Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))

141

142

143 def get_claude_log_path(repo_path: Path, session_id: str) -> Path:

144 """Get path to Claude SDK's session log file.

145

146 Claude SDK writes session logs to:

147 {claude_config_dir}/projects/{encoded-repo-path}/{session_id}.jsonl

148

149 The base directory can be overridden via CLAUDE_CONFIG_DIR env var.

150

151 Args:

152 repo_path: Repository path the session was run in.

153 session_id: Claude SDK session ID (UUID from ResultMessage).

154

155 Returns:

156 Path to the JSONL log file.

157 """

158 encoded = encode_repo_path(repo_path)

159 return get_claude_config_dir() / "projects" / encoded / f"{session_id}.jsonl"

160

161

162 class EnvConfig:

163 """Environment configuration implementing EnvConfigPort.

164

165 Provides access to environment paths for dependency injection into domain modules.

166 This class wraps the module-level functions to satisfy the EnvConfigPort protocol.

167 """

168

169 @property

170 def scripts_dir(self) -> Path:

171 """Path to the scripts directory (e.g., test-mutex.sh)."""

172 return SCRIPTS_DIR

173

174 @property

175 def cache_dir(self) -> Path:

176 """Path to the mala cache directory."""

177 return get_cache_dir()

178

179 @property

180 def lock_dir(self) -> Path:

181 """Path to the lock directory for multi-agent coordination."""

182 return get_lock_dir()

183

184 def find_cerberus_bin_path(self) -> Path | None:

185 """Find the cerberus plugin bin directory.

186

187 Returns:

188 Path to cerberus bin directory, or None if not found.

189 """

190 from src.infra.io.config import _find_cerberus_bin_path

191

192 return _find_cerberus_bin_path(get_claude_config_dir())