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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-16 07:40 +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 docker.types import Mount
18logger = logging.getLogger(__name__)
20# =============================================================================
21# Image defaults
22# =============================================================================
23DEFAULT_IMAGE = "svange/augint-shell"
24CONTAINER_PREFIX = "augint-shell"
25SHM_SIZE = "2g"
27# =============================================================================
28# Volume names (prefixed to avoid collisions)
29# =============================================================================
30UV_CACHE_VOLUME = "augint-shell-uv-cache"
31GH_CONFIG_VOLUME = "augint-shell-gh-config"
34def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str:
35 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo.
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}"
47NPM_CACHE_VOLUME = "augint-shell-npm-cache"
48OLLAMA_DATA_VOLUME = "augint-shell-ollama-data"
49WEBUI_DATA_VOLUME = "augint-shell-webui-data"
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]
65# =============================================================================
66# Bedrock defaults
67# =============================================================================
68DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0"
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)
76# =============================================================================
77# Container names
78# =============================================================================
79OLLAMA_CONTAINER = "augint-shell-ollama"
80WEBUI_CONTAINER = "augint-shell-webui"
81LOBECHAT_CONTAINER = "augint-shell-lobechat"
83# =============================================================================
84# Docker network
85# =============================================================================
86LLM_NETWORK = "augint-shell-llm"
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"
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())
101def unique_project_name(path: Path, project_name: str | None = None) -> str:
102 """Build a path-stable project identifier for container naming.
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}"
112def dev_container_name(project_name: str, project_dir: Path | None = None) -> str:
113 """Build the dev container name for a project.
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"
124def build_dev_mounts(project_dir: Path, project_name: str) -> list[Mount]:
125 """Build the full mount list matching docker-compose.yml dev service.
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
132 mounts: list[Mount] = []
133 home = Path.home()
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 )
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 ]
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)
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 )
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 )
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 )
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 )
222 return mounts
225def _find_gh_config_dir() -> Path | None:
226 """Find the gh CLI config directory.
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
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
245 return None
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.
261 Loads .env from the project directory (if present), then layers on
262 host environment variables and hardcoded defaults.
264 Priority (highest wins): extra_env > .env file > os.environ > defaults.
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.
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
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")
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)
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 }
298 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve
299 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"]
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)
306 if bedrock:
307 env["CLAUDE_CODE_USE_BEDROCK"] = "1"
308 if bedrock_profile:
309 env["AWS_PROFILE"] = bedrock_profile
311 if team_mode:
312 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
314 if extra_env:
315 env.update(extra_env)
317 return env