Coverage for src / ai_shell / defaults.py: 95%

108 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-16 07:40 +0000

1"""Constants and configuration builders for augint-shell. 

2 

3Encodes all docker-compose.yml configuration as Python, so no compose file is needed. 

4""" 

5 

6from __future__ import annotations 

7 

8import logging 

9import os 

10import re 

11from hashlib import sha1 

12from pathlib import Path 

13from typing import TYPE_CHECKING 

14 

15if TYPE_CHECKING: 

16 from docker.types import Mount 

17 

18logger = logging.getLogger(__name__) 

19 

20# ============================================================================= 

21# Image defaults 

22# ============================================================================= 

23DEFAULT_IMAGE = "svange/augint-shell" 

24CONTAINER_PREFIX = "augint-shell" 

25SHM_SIZE = "2g" 

26 

27# ============================================================================= 

28# Volume names (prefixed to avoid collisions) 

29# ============================================================================= 

30UV_CACHE_VOLUME = "augint-shell-uv-cache" 

31GH_CONFIG_VOLUME = "augint-shell-gh-config" 

32 

33 

34def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str: 

35 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo. 

36 

37 Matches the venv isolation scheme used by both ``--multi`` and ``--team`` 

38 modes. When *worktree_name* is set, appends ``-wt-{worktree_name}`` to 

39 isolate worktree venvs. 

40 """ 

41 suffix = repo_name 

42 if worktree_name: 

43 suffix = f"{repo_name}-wt-{worktree_name}" 

44 return f"/root/.cache/uv/venvs/{suffix}" 

45 

46 

47NPM_CACHE_VOLUME = "augint-shell-npm-cache" 

48OLLAMA_DATA_VOLUME = "augint-shell-ollama-data" 

49WEBUI_DATA_VOLUME = "augint-shell-webui-data" 

50 

51# ============================================================================= 

52# LLM defaults 

53# ============================================================================= 

54OLLAMA_IMAGE = "ollama/ollama" 

55WEBUI_IMAGE = "ghcr.io/open-webui/open-webui:main" 

56LOBECHAT_IMAGE = "lobehub/lobe-chat:latest" 

57DEFAULT_PRIMARY_MODEL = "qwen3-coder:32b-a3b-q4_K_M" 

58DEFAULT_FALLBACK_MODEL = "huihui_ai/llama3.3-abliterated" 

59DEFAULT_CONTEXT_SIZE = 32768 

60DEFAULT_OLLAMA_PORT = 11434 

61DEFAULT_WEBUI_PORT = 3000 

62DEFAULT_LOBECHAT_PORT = 3210 

63DEFAULT_DEV_PORTS = [3000, 4200, 5000, 5173, 5678, 8000, 8080, 8888] 

64 

65# ============================================================================= 

66# Bedrock defaults 

67# ============================================================================= 

68DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0" 

69 

70# ============================================================================= 

71# Ollama GPU defaults 

72# ============================================================================= 

73OLLAMA_VRAM_BUFFER_BYTES = 1 * 1024**3 # 1 GiB safety buffer reserved as overhead 

74OLLAMA_CPU_SHARES = 1024 # Docker CPU scheduling priority (default 0 = fair-share) 

75 

76# ============================================================================= 

77# Container names 

78# ============================================================================= 

79OLLAMA_CONTAINER = "augint-shell-ollama" 

80WEBUI_CONTAINER = "augint-shell-webui" 

81LOBECHAT_CONTAINER = "augint-shell-lobechat" 

82 

83# ============================================================================= 

84# Docker network 

85# ============================================================================= 

86LLM_NETWORK = "augint-shell-llm" 

87 

88 

89def _sanitize_name(name: str) -> str: 

90 """Convert an arbitrary string into a Docker-safe slug.""" 

91 name = re.sub(r"[^a-z0-9-]", "-", name) 

92 name = re.sub(r"-+", "-", name) 

93 return name.strip("-") or "project" 

94 

95 

96def sanitize_project_name(path: Path) -> str: 

97 """Derive a safe project slug from a directory basename.""" 

98 return _sanitize_name(path.resolve().name.lower()) 

99 

100 

101def unique_project_name(path: Path, project_name: str | None = None) -> str: 

102 """Build a path-stable project identifier for container naming. 

103 

104 The basename remains human-readable while a short path hash prevents 

105 collisions between repos with the same leaf directory name. 

106 """ 

107 slug = _sanitize_name((project_name or path.resolve().name).lower()) 

108 digest = sha1(str(path.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:8] 

109 return f"{slug}-{digest}" 

110 

111 

112def dev_container_name(project_name: str, project_dir: Path | None = None) -> str: 

113 """Build the dev container name for a project. 

114 

115 When *project_dir* is provided, the full resolved path is folded into the 

116 name to avoid collisions across nested repo layouts. Without it, the legacy 

117 basename-only format is preserved for compatibility. 

118 """ 

119 if project_dir is None: 

120 return f"{CONTAINER_PREFIX}-{project_name}-dev" 

121 return f"{CONTAINER_PREFIX}-{unique_project_name(project_dir, project_name)}-dev" 

122 

123 

124def build_dev_mounts(project_dir: Path, project_name: str) -> list[Mount]: 

125 """Build the full mount list matching docker-compose.yml dev service. 

126 

127 Required mounts are always included. Optional mounts are skipped 

128 if the source path doesn't exist on the host. 

129 """ 

130 from docker.types import Mount 

131 

132 mounts: list[Mount] = [] 

133 home = Path.home() 

134 

135 # Required: project directory (rw, delegated) 

136 mounts.append( 

137 Mount( 

138 target=f"/root/projects/{project_name}", 

139 source=str(project_dir.resolve()), 

140 type="bind", 

141 read_only=False, 

142 consistency="delegated", 

143 ) 

144 ) 

145 

146 # Optional bind mounts — skip if source doesn't exist 

147 optional_binds: list[tuple[Path, str, bool]] = [ 

148 (home / ".codex", "/root/.codex", False), 

149 (home / ".claude", "/root/.claude", False), 

150 (home / ".claude.json", "/root/.claude.json", False), 

151 (home / "projects" / "CLAUDE.md", "/root/projects/CLAUDE.md", True), 

152 (home / ".ssh", "/root/.ssh", True), 

153 (home / ".gitconfig", "/root/.gitconfig.windows", True), 

154 (home / ".aws", "/root/.aws", False), 

155 ] 

156 

157 for source, target, read_only in optional_binds: 

158 if source.exists(): 

159 mounts.append( 

160 Mount( 

161 target=target, 

162 source=str(source), 

163 type="bind", 

164 read_only=read_only, 

165 ) 

166 ) 

167 else: 

168 logger.debug("Skipping optional mount (not found): %s", source) 

169 

170 # gh CLI config: bind-mount the host path when found (Linux/Mac/WSL2), 

171 # otherwise use a named volume so auth persists across container recreations 

172 # (needed on Windows where gh stores tokens in keyring, not a file). 

173 gh_config = _find_gh_config_dir() 

174 if gh_config is not None: 

175 mounts.append( 

176 Mount( 

177 target="/root/.config/gh", 

178 source=str(gh_config), 

179 type="bind", 

180 read_only=False, 

181 ) 

182 ) 

183 else: 

184 mounts.append( 

185 Mount( 

186 target="/root/.config/gh", 

187 source=GH_CONFIG_VOLUME, 

188 type="volume", 

189 ) 

190 ) 

191 

192 # Optional: Docker socket 

193 docker_sock = Path("/var/run/docker.sock") 

194 if docker_sock.exists(): 

195 mounts.append( 

196 Mount( 

197 target="/var/run/docker.sock", 

198 source=str(docker_sock), 

199 type="bind", 

200 read_only=True, 

201 ) 

202 ) 

203 

204 # Named volume: uv cache (shared across all projects) 

205 mounts.append( 

206 Mount( 

207 target="/root/.cache/uv", 

208 source=UV_CACHE_VOLUME, 

209 type="volume", 

210 ) 

211 ) 

212 

213 # Named volume: npm cache (shared across all projects) 

214 mounts.append( 

215 Mount( 

216 target="/root/.npm", 

217 source=NPM_CACHE_VOLUME, 

218 type="volume", 

219 ) 

220 ) 

221 

222 return mounts 

223 

224 

225def _find_gh_config_dir() -> Path | None: 

226 """Find the gh CLI config directory. 

227 

228 Checks the standard Linux/Mac path (~/.config/gh) first, then falls back 

229 to the Windows APPDATA path for WSL2 environments where gh is installed on 

230 the Windows side (%APPDATA%\\GitHub CLI\\). 

231 """ 

232 linux_path = Path.home() / ".config" / "gh" 

233 if linux_path.exists(): 

234 return linux_path 

235 

236 # WSL2 fallback: APPDATA is set as a Windows path (e.g. C:\Users\foo\AppData\Roaming) 

237 appdata = os.environ.get("APPDATA", "") 

238 if appdata and ":" in appdata: 

239 drive, rest = appdata.split(":", 1) 

240 wsl_appdata = Path(f"/mnt/{drive.lower()}{rest.replace(chr(92), '/')}") 

241 windows_path = wsl_appdata / "GitHub CLI" 

242 if windows_path.exists(): 

243 return windows_path 

244 

245 return None 

246 

247 

248def build_dev_environment( 

249 extra_env: dict[str, str] | None = None, 

250 project_dir: Path | None = None, 

251 *, 

252 project_name: str = "", 

253 bedrock: bool = False, 

254 aws_profile: str = "", 

255 aws_region: str = "", 

256 bedrock_profile: str = "", 

257 team_mode: bool = False, 

258) -> dict[str, str]: 

259 """Build environment variables matching docker-compose.yml dev service. 

260 

261 Loads .env from the project directory (if present), then layers on 

262 host environment variables and hardcoded defaults. 

263 

264 Priority (highest wins): extra_env > .env file > os.environ > defaults. 

265 

266 When *bedrock* is True, ``CLAUDE_CODE_USE_BEDROCK=1`` is injected and 

267 *bedrock_profile* (if set) overrides ``AWS_PROFILE`` so the LLM provider 

268 authenticates with the correct AWS account. 

269 

270 When *team_mode* is True, ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`` is 

271 injected to enable Claude Code's Agent Teams feature. 

272 """ 

273 from dotenv import dotenv_values 

274 

275 # Load .env from project directory (returns empty dict if missing) 

276 dotenv: dict[str, str | None] = {} 

277 if project_dir is not None: 

278 dotenv = dotenv_values(project_dir / ".env") 

279 

280 def _resolve(key: str, default: str = "") -> str: 

281 """Resolve a value: .env > os.environ > default.""" 

282 dotenv_val = dotenv.get(key) 

283 if dotenv_val is not None and dotenv_val != "": 

284 return dotenv_val 

285 return os.environ.get(key, default) 

286 

287 gh_token = _resolve("GH_TOKEN") 

288 env: dict[str, str] = { 

289 "AWS_PROFILE": aws_profile or _resolve("AWS_PROFILE"), 

290 "AWS_REGION": aws_region or _resolve("AWS_REGION", "us-east-1"), 

291 "AWS_PAGER": "", 

292 "GH_TOKEN": gh_token, 

293 "GITHUB_TOKEN": gh_token, 

294 "HUSKY": "0", 

295 "IS_SANDBOX": "1", 

296 } 

297 

298 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve 

299 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"] 

300 

301 # Isolate UV venvs per-project within the shared cache volume. 

302 # Overrides Dockerfile default of /root/.cache/uv/venvs/project. 

303 if project_name: 

304 env["UV_PROJECT_ENVIRONMENT"] = uv_venv_path(project_name) 

305 

306 if bedrock: 

307 env["CLAUDE_CODE_USE_BEDROCK"] = "1" 

308 if bedrock_profile: 

309 env["AWS_PROFILE"] = bedrock_profile 

310 

311 if team_mode: 

312 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" 

313 

314 if extra_env: 

315 env.update(extra_env) 

316 

317 return env