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

300 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-13 23:19 +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 collections.abc import Callable 

17 

18 from docker.types import Mount 

19 

20logger = logging.getLogger(__name__) 

21 

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

23# Image defaults 

24# ============================================================================= 

25DEFAULT_IMAGE = "svange/augint-shell" 

26CONTAINER_PREFIX = "augint-shell" 

27SHM_SIZE = "2g" 

28 

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

30# Volume names (prefixed to avoid collisions) 

31# ============================================================================= 

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

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

34 

35# Prefix for shared named volumes that back isolated home configs (e.g. 

36# ~/.claude, ~/.codex). One volume per config dir, shared across all projects 

37# on the host, so tool state persists in Docker instead of the host home dir. 

38HOME_CONFIG_VOLUME_PREFIX = "augint-shell-home-" 

39 

40# Home configs that are single files rather than directories. A named volume 

41# can only back a directory, so these can't be volume-isolated; when named in 

42# the isolate set their host bind is dropped instead (config stays 

43# container-local). 

44_SINGLE_FILE_HOME_CONFIGS = frozenset({".claude.json", ".gitconfig", ".zapierrc"}) 

45 

46 

47def home_config_volume_name(config_name: str) -> str: 

48 """Shared named-volume name backing an isolated home config directory. 

49 

50 ``.claude`` -> ``augint-shell-home-claude``. The volume is shared across 

51 all projects on the host (mirroring how the host ``~/.claude`` bind is 

52 shared today), so a single Claude/codex config persists in Docker instead 

53 of being written into the host home directory. 

54 """ 

55 return f"{HOME_CONFIG_VOLUME_PREFIX}{_sanitize_name(config_name.lstrip('.').lower())}" 

56 

57 

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

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

60 

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

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

63 isolate worktree venvs. 

64 """ 

65 suffix = repo_name 

66 if worktree_name: 

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

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

69 

70 

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

72NODE_MODULES_VOLUME_PREFIX = "augint-shell-node-modules-" 

73 

74 

75def node_modules_volume_name( 

76 repo_name: str, 

77 worktree_name: str | None = None, 

78 subpath: str | None = None, 

79) -> str: 

80 """Per-project named volume that overlays a node_modules directory. 

81 

82 Mirrors the UV venv-isolation scheme so the container's Linux node_modules 

83 never collides with the host's (e.g. Windows-built) node_modules in the 

84 bind-mounted project directory. 

85 

86 When *subpath* is given (e.g. ``"apps/web"``), a sanitized slug is appended 

87 so monorepos can isolate each workspace's node_modules independently. 

88 """ 

89 suffix = repo_name 

90 if worktree_name: 

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

92 if subpath: 

93 suffix = f"{suffix}-{_sanitize_name(subpath.lower())}" 

94 return f"{NODE_MODULES_VOLUME_PREFIX}{suffix}" 

95 

96 

97COMPOSER_CACHE_VOLUME = "augint-shell-composer-cache" 

98PNPM_STORE_VOLUME = "augint-shell-pnpm-store" 

99VENDOR_VOLUME_PREFIX = "augint-shell-vendor-" 

100 

101 

102def vendor_volume_name(repo_name: str, worktree_name: str | None = None) -> str: 

103 """Per-project named volume that overlays composer's ``vendor/`` directory. 

104 

105 Same rationale as :func:`node_modules_volume_name`: ``vendor/`` on a 

106 drvfs/9p bind mount is slow, and overlaying keeps the container's 

107 Linux-built vendor tree separate from whatever the host has. 

108 """ 

109 suffix = repo_name 

110 if worktree_name: 

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

112 return f"{VENDOR_VOLUME_PREFIX}{suffix}" 

113 

114 

115PRE_COMMIT_CACHE_VOLUME = "augint-shell-pre-commit-cache" 

116PRE_COMMIT_CACHE_PATH = "/root/.cache/pre-commit-container" 

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

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

119N8N_DATA_VOLUME = "augint-shell-n8n-data" 

120WHISPER_DATA_VOLUME = "augint-shell-whisper-cache" 

121VOICE_AGENT_DATA_VOLUME = "augint-shell-voice-agent-data" 

122COMFYUI_DATA_VOLUME = "augint-shell-comfyui-data" 

123 

124# ============================================================================= 

125# LLM defaults 

126# ============================================================================= 

127OLLAMA_IMAGE = "ollama/ollama" 

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

129KOKORO_IMAGE_CPU = "ghcr.io/remsky/kokoro-fastapi-cpu:latest" 

130KOKORO_IMAGE_GPU = "ghcr.io/remsky/kokoro-fastapi-gpu:latest" 

131N8N_IMAGE = "docker.n8n.io/n8nio/n8n" 

132WHISPER_IMAGE_CPU = "ghcr.io/speaches-ai/speaches:latest-cpu" 

133WHISPER_IMAGE_GPU = "ghcr.io/speaches-ai/speaches:latest-cuda" 

134# Voice-agent image is built locally from docker/voice-agent/ on first 

135# ensure call. Not pulled. The local tag keeps `images.get` fast once built. 

136VOICE_AGENT_IMAGE = "augint-shell/voice-agent:local" 

137# ComfyUI: ai-dock/comfyui is actively maintained and exposes PROVISIONING_SCRIPT 

138# which we use to download FLUX.1-dev + SDXL on first boot. GPU-only (no CPU variant). 

139COMFYUI_IMAGE = "ghcr.io/ai-dock/comfyui:latest-cuda" 

140# Model slots (RTX 4090-sized, validated April 2026). Primary = best available for 

141# the role; secondary = best uncensored alternative. See README "Local LLM stack" 

142# and the generated .ai-shell.yaml for per-slot rationale and caveats. 

143DEFAULT_PRIMARY_CHAT_MODEL = "qwen3.5:27b" 

144DEFAULT_SECONDARY_CHAT_MODEL = "huihui_ai/qwen3.5-abliterated:27b" 

145DEFAULT_PRIMARY_CODING_MODEL = "qwen3-coder:30b-a3b-q4_K_M" 

146DEFAULT_SECONDARY_CODING_MODEL = "huihui_ai/qwen3-coder-abliterated:30b-a3b-instruct-q4_K_M" 

147DEFAULT_EXTRA_MODELS: list[str] = [ 

148 "qwen3.5:9b", # ~6.6 GB mid-range chat, fast + capable 

149 "devstral:24b", # ~15 GB Mistral agentic coding, dense 24B 

150] 

151DEFAULT_CONTEXT_SIZE = 32768 

152DEFAULT_OLLAMA_PORT = 11434 

153DEFAULT_WEBUI_PORT = 3000 

154DEFAULT_KOKORO_PORT = 8880 

155DEFAULT_N8N_PORT = 5678 

156DEFAULT_WHISPER_PORT = 8001 

157DEFAULT_WHISPER_MODEL = "Systran/faster-distil-whisper-large-v3" 

158DEFAULT_VOICE_AGENT_PORT = 8010 

159DEFAULT_COMFYUI_PORT = 8188 

160DEFAULT_KOKORO_VOICE = "af_bella" 

161DEFAULT_DEV_PORTS = [3000, 4096, 4200, 5000, 5173, 5678, 8000, 8080, 8888, 19432, 31415] 

162 

163# Deterministic dev port mapping (avoids Chrome debug range 40000-60000) 

164DEV_PORT_RANGE_START = 10000 

165DEV_PORT_RANGE_SIZE = 30000 # 10000-39999 

166 

167# ============================================================================= 

168# Bedrock defaults 

169# ============================================================================= 

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

171 

172# ============================================================================= 

173# Ollama GPU defaults 

174# ============================================================================= 

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

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

177 

178# ============================================================================= 

179# Container names 

180# ============================================================================= 

181OLLAMA_CONTAINER = "augint-shell-ollama" 

182WEBUI_CONTAINER = "augint-shell-webui" 

183KOKORO_CONTAINER = "augint-shell-kokoro" 

184N8N_CONTAINER = "augint-shell-n8n" 

185WHISPER_CONTAINER = "augint-shell-whisper" 

186VOICE_AGENT_CONTAINER = "augint-shell-voice-agent" 

187COMFYUI_CONTAINER = "augint-shell-comfyui" 

188 

189# ============================================================================= 

190# Docker network 

191# ============================================================================= 

192LLM_NETWORK = "augint-shell-llm" 

193 

194 

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

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

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

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

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

200 

201 

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

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

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

205 

206 

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

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

209 

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

211 collisions between repos with the same leaf directory name. 

212 """ 

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

214 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 

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

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

217 

218 

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

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

221 

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

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

224 basename-only format is preserved for compatibility. 

225 """ 

226 if project_dir is None: 

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

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

229 

230 

231# Salted re-hash attempts per port before giving up. Each attempt is an 

232# independent draw from 30000 slots, so exhaustion means the host is 

233# rejecting essentially the whole range. 

234DEV_PORT_MAX_ATTEMPTS = 64 

235 

236 

237def project_dev_port_map( 

238 project_dir: Path, 

239 dev_ports: list[int], 

240 project_name: str | None = None, 

241 is_available: Callable[[int], bool] | None = None, 

242) -> dict[int, int]: 

243 """Map each container port to a unique, stable per-project host port. 

244 

245 Uses the same project identity as container naming (unique_project_name) 

246 combined with each container port to produce deterministic host ports in 

247 the 10000-39999 range. Different projects get different host ports for 

248 the same container port, so multiple projects can run simultaneously. 

249 

250 A candidate slot is rejected when it is already taken by another port in 

251 this map or when *is_available* (e.g. a host test-bind probe) refuses it. 

252 Rejected candidates are re-hashed with a salt counter rather than probed 

253 linearly: host-side reservations (Windows winnat excluded port ranges) 

254 are contiguous blocks, so adjacent slots tend to fail together while a 

255 re-hash jumps elsewhere in the range. Assignment runs in ascending 

256 container-port order so the result is deterministic, and an unsalted 

257 first attempt keeps existing assignments stable for the common case. 

258 """ 

259 slug = unique_project_name(project_dir, project_name) 

260 assigned: dict[int, int] = {} 

261 used: set[int] = set() 

262 for port in sorted(set(dev_ports)): 

263 for salt in range(DEV_PORT_MAX_ATTEMPTS): 

264 key = f"{slug}:{port}" if salt == 0 else f"{slug}:{port}:{salt}" 

265 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 

266 digest = sha1(key.encode(), usedforsecurity=False).hexdigest() 

267 candidate = DEV_PORT_RANGE_START + (int(digest[:8], 16) % DEV_PORT_RANGE_SIZE) 

268 if candidate not in used and (is_available is None or is_available(candidate)): 

269 break 

270 else: 

271 raise RuntimeError( 

272 f"Could not find a free host port for container port {port} " 

273 f"after {DEV_PORT_MAX_ATTEMPTS} attempts in range " 

274 f"{DEV_PORT_RANGE_START}-{DEV_PORT_RANGE_START + DEV_PORT_RANGE_SIZE - 1}. " 

275 "The host is rejecting nearly all bind attempts — check firewall " 

276 "or reserved port configuration." 

277 ) 

278 assigned[port] = candidate 

279 used.add(candidate) 

280 return assigned 

281 

282 

283def project_dev_port( 

284 project_dir: Path, 

285 container_port: int, 

286 project_name: str | None = None, 

287 dev_ports: list[int] | None = None, 

288) -> int: 

289 """Host port for one container port, consistent with project_dev_port_map. 

290 

291 *dev_ports* must be the full port set the container was created with — 

292 collision resolution depends on it. Defaults to DEFAULT_DEV_PORTS. 

293 """ 

294 ports = list(dev_ports) if dev_ports is not None else list(DEFAULT_DEV_PORTS) 

295 if container_port not in ports: 

296 ports.append(container_port) 

297 return project_dev_port_map(project_dir, ports, project_name)[container_port] 

298 

299 

300def build_dev_mounts( 

301 project_dir: Path, 

302 project_name: str, 

303 extra_node_modules_paths: list[str] | None = None, 

304 isolate_home_paths: set[str] | None = None, 

305) -> list[Mount]: 

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

307 

308 Required mounts are always included. Optional mounts are skipped 

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

310 

311 *extra_node_modules_paths* is a list of glob patterns relative to 

312 *project_dir*. Each glob match (that is a directory) gets its own named 

313 volume overlaid at ``{match}/node_modules`` inside the container, so each 

314 workspace in a monorepo isolates its Linux node_modules from the host 

315 bind mount the same way the root overlay does. 

316 

317 *isolate_home_paths* is a set of home-config basenames (e.g. ``.claude``, 

318 ``.codex``) that should be backed by a shared named volume instead of a 

319 host bind mount, so nothing is written into the host home directory. The 

320 volume persists across container recreations and is shared across projects. 

321 Single-file configs (``.claude.json`` etc.) can't be volume-backed; when 

322 named — or, for ``.claude.json``, when ``.claude`` is isolated — their host 

323 bind is dropped and the config stays container-local. 

324 """ 

325 from docker.types import Mount 

326 

327 mounts: list[Mount] = [] 

328 home = Path.home() 

329 

330 # Required: project directory (rw, delegated) 

331 mounts.append( 

332 Mount( 

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

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

335 type="bind", 

336 read_only=False, 

337 consistency="delegated", 

338 ) 

339 ) 

340 

341 # Ensure directories that tools need for persistent config exist on the 

342 # host so bind mounts aren't silently skipped. 

343 for d in (".pi", ".augint", ".plannotator"): 

344 (home / d).mkdir(parents=True, exist_ok=True) 

345 

346 # Zapier CLI auth is a single JSON file; pre-create it so the bind mount 

347 # exists and `zapier login` inside the container persists across 

348 # container recreations (mirrors the .claude.json single-file bind). 

349 zapierrc = home / ".zapierrc" 

350 if not zapierrc.exists(): 

351 zapierrc.write_text("{}\n") 

352 

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

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

355 (home / ".config", "/root/.config", False), 

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

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

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

359 (home / ".pi", "/root/.pi", False), 

360 (home / ".augint", "/root/.augint", False), 

361 (home / ".plannotator", "/root/.plannotator", False), 

362 (home / ".zapierrc", "/root/.zapierrc", False), 

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

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

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

366 ] 

367 

368 isolate = isolate_home_paths or set() 

369 for source, target, read_only in optional_binds: 

370 name = source.name 

371 

372 # Isolation: back the config with a shared named volume instead of a 

373 # host bind, so nothing is written into the host home directory. 

374 if name in isolate: 

375 if name in _SINGLE_FILE_HOME_CONFIGS: 

376 # A named volume can't back a single file — drop the bind so 

377 # the host file is never touched (config stays container-local). 

378 logger.debug("Isolating single-file home config (not persisted): %s", name) 

379 continue 

380 mounts.append( 

381 Mount( 

382 target=target, 

383 source=home_config_volume_name(name), 

384 type="volume", 

385 ) 

386 ) 

387 continue 

388 

389 # Keep ~/.claude.json off the host whenever ~/.claude is isolated. 

390 if name == ".claude.json" and ".claude" in isolate: 

391 logger.debug("Isolating .claude.json alongside isolated .claude (not persisted)") 

392 continue 

393 

394 if source.exists(): 

395 mounts.append( 

396 Mount( 

397 target=target, 

398 source=str(source), 

399 type="bind", 

400 read_only=read_only, 

401 ) 

402 ) 

403 else: 

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

405 

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

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

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

409 gh_config = _find_gh_config_dir() 

410 if gh_config is not None: 

411 mounts.append( 

412 Mount( 

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

414 source=str(gh_config), 

415 type="bind", 

416 read_only=False, 

417 ) 

418 ) 

419 else: 

420 mounts.append( 

421 Mount( 

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

423 source=GH_CONFIG_VOLUME, 

424 type="volume", 

425 ) 

426 ) 

427 

428 # Optional: Docker socket 

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

430 if docker_sock.exists(): 

431 mounts.append( 

432 Mount( 

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

434 source=str(docker_sock), 

435 type="bind", 

436 read_only=True, 

437 ) 

438 ) 

439 

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

441 mounts.append( 

442 Mount( 

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

444 source=UV_CACHE_VOLUME, 

445 type="volume", 

446 ) 

447 ) 

448 

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

450 mounts.append( 

451 Mount( 

452 target="/root/.npm", 

453 source=NPM_CACHE_VOLUME, 

454 type="volume", 

455 ) 

456 ) 

457 

458 # Named volume: composer cache (shared across all projects). Without it 

459 # composer re-downloads every package on container recreation. 

460 mounts.append( 

461 Mount( 

462 target="/root/.cache/composer", 

463 source=COMPOSER_CACHE_VOLUME, 

464 type="volume", 

465 ) 

466 ) 

467 

468 # Named volume: pnpm content-addressable store (shared across all 

469 # projects). Corepack's pnpm is container-local otherwise, so every 

470 # container rebuild is a cold install; the npm cache doesn't help pnpm. 

471 mounts.append( 

472 Mount( 

473 target="/root/.local/share/pnpm", 

474 source=PNPM_STORE_VOLUME, 

475 type="volume", 

476 ) 

477 ) 

478 

479 # Named volume: pre-commit cache (shared across all projects). 

480 # Isolates the container's hook environments from the Windows host's 

481 # ~/.cache/pre-commit so the two installs don't clobber each other. 

482 mounts.append( 

483 Mount( 

484 target=PRE_COMMIT_CACHE_PATH, 

485 source=PRE_COMMIT_CACHE_VOLUME, 

486 type="volume", 

487 ) 

488 ) 

489 

490 # Per-project named volume overlaying node_modules. Without this the 

491 # container's Linux `npm ci` would write into the bind-mounted host 

492 # project dir and collide with host-built (e.g. Windows) node_modules. 

493 # npm has no equivalent of UV_PROJECT_ENVIRONMENT, so we isolate at the 

494 # mount layer instead. Host node_modules underneath stays untouched. 

495 mounts.append( 

496 Mount( 

497 target=f"/root/projects/{project_name}/node_modules", 

498 source=node_modules_volume_name(project_name), 

499 type="volume", 

500 ) 

501 ) 

502 

503 # Per-project named volume overlaying composer's vendor/ — the exact 

504 # node_modules rationale applies verbatim. Conditional on composer.json 

505 # so non-PHP projects don't grow an empty vendor/ dir on the host. 

506 if (project_dir / "composer.json").is_file(): 

507 mounts.append( 

508 Mount( 

509 target=f"/root/projects/{project_name}/vendor", 

510 source=vendor_volume_name(project_name), 

511 type="volume", 

512 ) 

513 ) 

514 

515 # Monorepo workspaces: overlay an isolated named volume on each matched 

516 # workspace's node_modules so per-app installs (npm/pnpm/yarn workspaces) 

517 # land in container-only storage rather than the host bind mount. 

518 project_root = project_dir.resolve() 

519 seen_targets: set[str] = {f"/root/projects/{project_name}/node_modules"} 

520 for pattern in extra_node_modules_paths or []: 

521 for match in sorted(project_root.glob(pattern)): 

522 if not match.is_dir(): 

523 continue 

524 try: 

525 rel = match.relative_to(project_root) 

526 except ValueError: 

527 continue 

528 rel_posix = rel.as_posix() 

529 target = f"/root/projects/{project_name}/{rel_posix}/node_modules" 

530 if target in seen_targets: 

531 continue 

532 seen_targets.add(target) 

533 mounts.append( 

534 Mount( 

535 target=target, 

536 source=node_modules_volume_name(project_name, subpath=rel_posix), 

537 type="volume", 

538 ) 

539 ) 

540 

541 return mounts 

542 

543 

544def docker_tcp_fallback_host() -> str | None: 

545 """DOCKER_HOST value for dev containers when no Unix socket exists to mount. 

546 

547 On Windows hosts there is no /var/run/docker.sock for build_dev_mounts to 

548 bind-mount, but Docker Desktop can expose the daemon on localhost:2375 

549 ("Expose daemon on tcp://localhost:2375 without TLS"). When that endpoint 

550 is reachable, containers reach the same daemon via host.docker.internal — 

551 return the DOCKER_HOST value to inject. Returns None when the socket 

552 mount already covers Docker access or no tcp endpoint is listening. 

553 """ 

554 import socket 

555 

556 if Path("/var/run/docker.sock").exists(): 

557 return None 

558 try: 

559 with socket.create_connection(("localhost", 2375), timeout=1): 

560 pass 

561 except OSError: 

562 return None 

563 return "tcp://host.docker.internal:2375" 

564 

565 

566def _find_gh_config_dir() -> Path | None: 

567 """Find the gh CLI config directory. 

568 

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

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

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

572 """ 

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

574 if linux_path.exists(): 

575 return linux_path 

576 

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

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

579 if appdata and ":" in appdata: 

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

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

582 windows_path = wsl_appdata / "GitHub CLI" 

583 if windows_path.exists(): 

584 return windows_path 

585 

586 return None 

587 

588 

589def _load_layered_dotenv( 

590 project_dir: Path | None = None, 

591 env_file: Path | None = None, 

592) -> dict[str, str | None]: 

593 """Load layered .env files. 

594 

595 ``~/.augint/.env`` always loads (global augint suite config). The project 

596 ``./.env`` and explicit *env_file* only load when *env_file* is given 

597 (i.e. user passed ``--env`` on the CLI). Later layers override earlier 

598 ones. 

599 """ 

600 from dotenv import dotenv_values 

601 

602 layers: dict[str, str | None] = {} 

603 

604 global_path = Path.home() / ".augint" / ".env" 

605 if global_path.is_file(): 

606 layers.update(dotenv_values(global_path)) 

607 

608 if env_file is None: 

609 return layers 

610 

611 if project_dir is not None: 

612 project_path = project_dir / ".env" 

613 if project_path.is_file() and project_path.resolve() != env_file.resolve(): 

614 layers.update(dotenv_values(project_path)) 

615 

616 if env_file.is_file(): 

617 layers.update(dotenv_values(env_file)) 

618 

619 return layers 

620 

621 

622_SHARED_ENV_PASSTHROUGH = ( 

623 "ANTHROPIC_API_KEY", 

624 "OPENAI_API_KEY", 

625 "PRIMARY_CHAT_MODEL", 

626 "SECONDARY_CHAT_MODEL", 

627 "PRIMARY_CODING_MODEL", 

628 "SECONDARY_CODING_MODEL", 

629 "CONTEXT_SIZE", 

630 "OLLAMA_PORT", 

631 "WEBUI_PORT", 

632 "KOKORO_PORT", 

633 "WHISPER_PORT", 

634 "N8N_PORT", 

635 "COMFYUI_PORT", 

636 "OPENCODE_SERVER_PASSWORD", 

637 "OPENCODE_SERVER_USERNAME", 

638 "PI_STUDIO_HOST", 

639) 

640 

641 

642def build_dev_environment( 

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

644 project_dir: Path | None = None, 

645 *, 

646 project_name: str = "", 

647 bedrock: bool = False, 

648 aws_profile: str = "", 

649 aws_region: str = "", 

650 bedrock_profile: str = "", 

651 bedrock_region: str = "", 

652 bedrock_model: str = "", 

653 openai_profile: str = "", 

654 team_mode: bool = False, 

655 env_file: Path | None = None, 

656) -> dict[str, str]: 

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

658 

659 ``~/.augint/.env`` always loads (global augint suite config). The project 

660 ``./.env`` only loads when *env_file* is given (``--env`` on the CLI); 

661 when loaded, **all** of its keys flow through to the container. 

662 

663 Priority (highest wins): extra_env > CLI flags > ``./.env`` (when ``--env``) 

664 > ``~/.augint/.env`` > host ``os.environ`` (allowlisted) > defaults. 

665 

666 GitHub auth defaults to SSO via the ``~/.config/gh`` bind mount. To use a 

667 PAT instead, put ``GH_TOKEN`` in ``.env`` and pass ``--env``. 

668 

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

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

671 authenticates with the correct AWS account. 

672 

673 When *openai_profile* is set, the suffixed env vars 

674 ``OPENAI_API_KEY_{NAME}`` and ``OPENAI_ORG_ID_{NAME}`` are resolved from 

675 ``.env`` and injected as ``OPENAI_API_KEY`` / ``OPENAI_ORG_ID``. 

676 

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

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

679 """ 

680 dotenv = _load_layered_dotenv(project_dir, env_file=env_file) 

681 

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

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

684 dotenv_val = dotenv.get(key) 

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

686 return dotenv_val 

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

688 

689 _CONTAINER_BASE_PATH = ( 

690 "/root/.local/bin:/root/.opencode/bin:" 

691 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 

692 ) 

693 

694 env: dict[str, str] = { 

695 "PATH": _CONTAINER_BASE_PATH, 

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

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

698 "AWS_PAGER": "", 

699 "HUSKY": "0", 

700 "IS_SANDBOX": "1", 

701 "PRE_COMMIT_HOME": PRE_COMMIT_CACHE_PATH, 

702 "PI_STUDIO_HOST": "0.0.0.0", # nosec B104 

703 "PLANNOTATOR_REMOTE": "1", 

704 "PLANNOTATOR_PORT": "19432", 

705 "PLANNOTATOR_BROWSER": "echo", 

706 } 

707 

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

709 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"] 

710 

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

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

713 if project_name: 

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

715 

716 if bedrock: 

717 env["CLAUDE_CODE_USE_BEDROCK"] = "1" 

718 if bedrock_profile: 

719 env["AWS_PROFILE"] = bedrock_profile 

720 resolved_bedrock_region = ( 

721 bedrock_region or _resolve("AWS_BEDROCK_REGION") or env["AWS_REGION"] 

722 ) 

723 if resolved_bedrock_region != env["AWS_REGION"]: 

724 env["AWS_REGION"] = resolved_bedrock_region 

725 env["AWS_DEFAULT_REGION"] = resolved_bedrock_region 

726 if bedrock_model: 

727 env["ANTHROPIC_MODEL"] = bedrock_model 

728 

729 if openai_profile: 

730 suffix = openai_profile.upper() 

731 key_var = f"OPENAI_API_KEY_{suffix}" 

732 api_key = dotenv.get(key_var) 

733 if not api_key: 

734 raise ValueError( 

735 f"OpenAI profile '{openai_profile}' requires {key_var} in " 

736 "~/.augint/.env, or pass --env to load it from ./.env" 

737 ) 

738 env["OPENAI_API_KEY"] = api_key 

739 org_var = f"OPENAI_ORG_ID_{suffix}" 

740 org_id = dotenv.get(org_var) 

741 if org_id: 

742 env["OPENAI_ORG_ID"] = org_id 

743 

744 if team_mode: 

745 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" 

746 

747 for var in _SHARED_ENV_PASSTHROUGH: 

748 val = _resolve(var) 

749 if val: 

750 env[var] = val 

751 

752 # Pass through every var loaded from .env, except keys already populated 

753 # above (which preserves CLI-flag wins for AWS_PROFILE etc.). 

754 for key, value in dotenv.items(): 

755 if value is not None and value != "" and key not in env: 

756 env[key] = value 

757 

758 if extra_env: 

759 env.update(extra_env) 

760 

761 return env 

762 

763 

764def _resolve_env(dotenv: dict[str, str | None], key: str, default: str = "") -> str: 

765 """Resolve a value: dotenv > os.environ > default.""" 

766 dotenv_val = dotenv.get(key) 

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

768 return dotenv_val 

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

770 

771 

772def build_n8n_environment( 

773 env_file: Path | None = None, 

774 *, 

775 aws_profile: str = "", 

776 aws_region: str = "", 

777) -> dict[str, str]: 

778 """Build environment variables for the n8n workflow automation container. 

779 

780 Loads layered .env files (``~/.augint/.env`` then *env_file*), 

781 then falls back to host environment variables. 

782 

783 Service discovery URLs use internal Docker network hostnames so n8n 

784 workflows can reference them via ``{{ $env.OLLAMA_BASE_URL }}`` etc. 

785 """ 

786 dotenv = _load_layered_dotenv(env_file=env_file) 

787 

788 env: dict[str, str] = { 

789 # Disable secure-cookie so the UI works over plain http://localhost. 

790 "N8N_SECURE_COOKIE": "false", 

791 # Service discovery (internal Docker network URLs). 

792 "OLLAMA_BASE_URL": f"http://{OLLAMA_CONTAINER}:11434", 

793 "KOKORO_BASE_URL": f"http://{KOKORO_CONTAINER}:8880", 

794 "WHISPER_BASE_URL": f"http://{WHISPER_CONTAINER}:8000", 

795 "VOICE_AGENT_BASE_URL": f"http://{VOICE_AGENT_CONTAINER}:8000", 

796 "WEBUI_BASE_URL": f"http://{WEBUI_CONTAINER}:8080", 

797 "COMFYUI_BASE_URL": f"http://{COMFYUI_CONTAINER}:8188", 

798 } 

799 

800 # AWS credentials 

801 aws_prof = aws_profile or _resolve_env(dotenv, "AWS_PROFILE") 

802 aws_reg = aws_region or _resolve_env(dotenv, "AWS_REGION", "us-east-1") 

803 if aws_prof: 

804 env["AWS_PROFILE"] = aws_prof 

805 env["AWS_REGION"] = aws_reg 

806 env["AWS_DEFAULT_REGION"] = aws_reg 

807 

808 # API keys — only include when non-empty. 

809 for key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY"): 

810 val = _resolve_env(dotenv, key) 

811 if val: 

812 env[key] = val 

813 

814 if env_file is not None: 

815 gh_token = _resolve_env(dotenv, "GH_TOKEN") 

816 if gh_token: 

817 env["GH_TOKEN"] = gh_token 

818 env["GITHUB_TOKEN"] = gh_token 

819 env["GITHUB_MODELS_BASE_URL"] = "https://models.inference.ai.azure.com" 

820 

821 return env 

822 

823 

824def build_n8n_mounts( 

825 workflow_dir: Path | None = None, 

826) -> list[Mount]: 

827 """Build the mount list for the n8n container. 

828 

829 n8n runs as user ``node`` (UID 1000). Credential directories are mounted 

830 read-only under ``/home/node/`` so the AWS and GitHub CLIs resolve auth 

831 the same way the dev container does. 

832 """ 

833 from docker.types import Mount 

834 

835 home = Path.home() 

836 

837 mounts: list[Mount] = [ 

838 # Persistent data (workflows, credentials DB, settings). 

839 Mount( 

840 target="/home/node/.n8n", 

841 source=N8N_DATA_VOLUME, 

842 type="volume", 

843 ), 

844 ] 

845 

846 # Optional credential bind mounts (read-only). 

847 aws_dir = home / ".aws" 

848 if aws_dir.exists(): 

849 mounts.append( 

850 Mount( 

851 target="/home/node/.aws", 

852 source=str(aws_dir), 

853 type="bind", 

854 read_only=True, 

855 ) 

856 ) 

857 else: 

858 logger.debug("Skipping n8n AWS mount (not found): %s", aws_dir) 

859 

860 gh_config = _find_gh_config_dir() 

861 if gh_config is not None: 

862 mounts.append( 

863 Mount( 

864 target="/home/node/.config/gh", 

865 source=str(gh_config), 

866 type="bind", 

867 read_only=True, 

868 ) 

869 ) 

870 

871 # Starter workflow templates (read-only bind mount). 

872 if workflow_dir is not None and workflow_dir.is_dir(): 

873 mounts.append( 

874 Mount( 

875 target="/workflows", 

876 source=str(workflow_dir), 

877 type="bind", 

878 read_only=True, 

879 ) 

880 ) 

881 

882 return mounts