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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-13 23:19 +0000
1"""Constants and configuration builders for augint-shell.
3Encodes all docker-compose.yml configuration as Python, so no compose file is needed.
4"""
6from __future__ import annotations
8import logging
9import os
10import re
11from hashlib import sha1
12from pathlib import Path
13from typing import TYPE_CHECKING
15if TYPE_CHECKING:
16 from collections.abc import Callable
18 from docker.types import Mount
20logger = logging.getLogger(__name__)
22# =============================================================================
23# Image defaults
24# =============================================================================
25DEFAULT_IMAGE = "svange/augint-shell"
26CONTAINER_PREFIX = "augint-shell"
27SHM_SIZE = "2g"
29# =============================================================================
30# Volume names (prefixed to avoid collisions)
31# =============================================================================
32UV_CACHE_VOLUME = "augint-shell-uv-cache"
33GH_CONFIG_VOLUME = "augint-shell-gh-config"
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-"
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"})
47def home_config_volume_name(config_name: str) -> str:
48 """Shared named-volume name backing an isolated home config directory.
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())}"
58def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str:
59 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo.
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}"
71NPM_CACHE_VOLUME = "augint-shell-npm-cache"
72NODE_MODULES_VOLUME_PREFIX = "augint-shell-node-modules-"
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.
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.
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}"
97COMPOSER_CACHE_VOLUME = "augint-shell-composer-cache"
98PNPM_STORE_VOLUME = "augint-shell-pnpm-store"
99VENDOR_VOLUME_PREFIX = "augint-shell-vendor-"
102def vendor_volume_name(repo_name: str, worktree_name: str | None = None) -> str:
103 """Per-project named volume that overlays composer's ``vendor/`` directory.
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}"
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"
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]
163# Deterministic dev port mapping (avoids Chrome debug range 40000-60000)
164DEV_PORT_RANGE_START = 10000
165DEV_PORT_RANGE_SIZE = 30000 # 10000-39999
167# =============================================================================
168# Bedrock defaults
169# =============================================================================
170DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0"
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)
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"
189# =============================================================================
190# Docker network
191# =============================================================================
192LLM_NETWORK = "augint-shell-llm"
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"
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())
207def unique_project_name(path: Path, project_name: str | None = None) -> str:
208 """Build a path-stable project identifier for container naming.
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}"
219def dev_container_name(project_name: str, project_dir: Path | None = None) -> str:
220 """Build the dev container name for a project.
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"
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
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.
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.
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
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.
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]
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.
308 Required mounts are always included. Optional mounts are skipped
309 if the source path doesn't exist on the host.
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.
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
327 mounts: list[Mount] = []
328 home = Path.home()
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 )
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)
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")
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 ]
368 isolate = isolate_home_paths or set()
369 for source, target, read_only in optional_binds:
370 name = source.name
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
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
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)
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
541 return mounts
544def docker_tcp_fallback_host() -> str | None:
545 """DOCKER_HOST value for dev containers when no Unix socket exists to mount.
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
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"
566def _find_gh_config_dir() -> Path | None:
567 """Find the gh CLI config directory.
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
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
586 return None
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.
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
602 layers: dict[str, str | None] = {}
604 global_path = Path.home() / ".augint" / ".env"
605 if global_path.is_file():
606 layers.update(dotenv_values(global_path))
608 if env_file is None:
609 return layers
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))
616 if env_file.is_file():
617 layers.update(dotenv_values(env_file))
619 return layers
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)
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.
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.
663 Priority (highest wins): extra_env > CLI flags > ``./.env`` (when ``--env``)
664 > ``~/.augint/.env`` > host ``os.environ`` (allowlisted) > defaults.
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``.
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.
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``.
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)
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)
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 )
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 }
708 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve
709 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"]
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)
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
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
744 if team_mode:
745 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
747 for var in _SHARED_ENV_PASSTHROUGH:
748 val = _resolve(var)
749 if val:
750 env[var] = val
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
758 if extra_env:
759 env.update(extra_env)
761 return env
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)
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.
780 Loads layered .env files (``~/.augint/.env`` then *env_file*),
781 then falls back to host environment variables.
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)
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 }
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
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
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"
821 return env
824def build_n8n_mounts(
825 workflow_dir: Path | None = None,
826) -> list[Mount]:
827 """Build the mount list for the n8n container.
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
835 home = Path.home()
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 ]
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)
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 )
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 )
882 return mounts