Coverage for src/ai_shell/config.py: 88%

331 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-13 23:19 +0000

1"""Configuration loading for ai-shell. 

2 

3Priority (highest wins): CLI flags > env vars > project config > global config > defaults. 

4 

5Global config lookup order (first match wins): 

6 ~/.ai-shell.yaml > ~/.ai-shell.yml > ~/.ai-shell.toml 

7 > ~/.config/ai-shell/config.yaml > ~/.config/ai-shell/config.yml > ~/.config/ai-shell/config.toml 

8 

9Project config lookup order (first match wins): 

10 .ai-shell.yaml > .ai-shell.yml > .ai-shell.toml > ai-shell.toml 

11""" 

12 

13from __future__ import annotations 

14 

15import logging 

16import os 

17import tomllib 

18from dataclasses import dataclass, field 

19from pathlib import Path 

20 

21import yaml 

22 

23from ai_shell.defaults import ( 

24 DEFAULT_COMFYUI_PORT, 

25 DEFAULT_CONTEXT_SIZE, 

26 DEFAULT_DEV_PORTS, 

27 DEFAULT_EXTRA_MODELS, 

28 DEFAULT_IMAGE, 

29 DEFAULT_KOKORO_PORT, 

30 DEFAULT_KOKORO_VOICE, 

31 DEFAULT_N8N_PORT, 

32 DEFAULT_OLLAMA_PORT, 

33 DEFAULT_PRIMARY_CHAT_MODEL, 

34 DEFAULT_PRIMARY_CODING_MODEL, 

35 DEFAULT_SECONDARY_CHAT_MODEL, 

36 DEFAULT_SECONDARY_CODING_MODEL, 

37 DEFAULT_VOICE_AGENT_PORT, 

38 DEFAULT_WEBUI_PORT, 

39 DEFAULT_WHISPER_MODEL, 

40 DEFAULT_WHISPER_PORT, 

41) 

42 

43logger = logging.getLogger(__name__) 

44 

45 

46@dataclass 

47class VoiceAgentModelProfile: 

48 """A named pair of primary + secondary chat models for the voice agent.""" 

49 

50 primary: str = "" 

51 secondary: str = "" 

52 

53 

54@dataclass 

55class VoiceAgentVadConfig: 

56 """Silero VAD / barge-in behavior.""" 

57 

58 silence_timeout_ms: int = 2500 

59 barge_in: bool = True 

60 

61 

62@dataclass 

63class VoiceAgentFilesystemConfig: 

64 """Filesystem tool scoping. Consumed by Phase 4.""" 

65 

66 root: str = "~/gigachad" 

67 read: list[str] = field(default_factory=lambda: ["~/gigachad"]) 

68 write: list[str] = field(default_factory=lambda: ["~/gigachad"]) 

69 deny_glob: list[str] = field(default_factory=lambda: ["**/.env*", "**/.git/**"]) 

70 

71 

72@dataclass 

73class VoiceAgentMemoryConfig: 

74 """Sqlite memory behavior. Consumed by Phase 5.""" 

75 

76 enabled: bool = True 

77 summarize_after_turns: int = 20 

78 

79 

80@dataclass 

81class VoiceAgentAuthConfig: 

82 """App-level session auth. Consumed by Phase 3.""" 

83 

84 username: str = "" 

85 password_bcrypt: str = "" 

86 session_secret: str = "" 

87 

88 

89@dataclass 

90class VoiceAgentProvidersConfig: 

91 """LLM provider selection. Consumed by Phase 6.""" 

92 

93 default: str = "ollama" 

94 available: list[str] = field(default_factory=lambda: ["ollama"]) 

95 

96 

97@dataclass 

98class VoiceAgentToolConfig: 

99 """A single tool entry under `voice_agent.tools`.""" 

100 

101 enabled: bool = False 

102 provider: str = "" 

103 

104 

105@dataclass 

106class VoiceAgentToolsConfig: 

107 """Tool registry. Consumed by Phase 4.""" 

108 

109 filesystem: VoiceAgentToolConfig = field(default_factory=VoiceAgentToolConfig) 

110 web_search: VoiceAgentToolConfig = field( 

111 default_factory=lambda: VoiceAgentToolConfig(provider="brave") 

112 ) 

113 github: VoiceAgentToolConfig = field(default_factory=VoiceAgentToolConfig) 

114 

115 

116@dataclass 

117class VoiceAgentWakeWordConfig: 

118 """Wake-word gating. Consumed by Phase 3.""" 

119 

120 enabled: bool = False 

121 name: str = "hey_jarvis" 

122 

123 

124@dataclass 

125class VoiceAgentConfig: 

126 """Full voice-agent config tree. 

127 

128 Phase 2 wires only ``port`` at the container layer. The remaining fields 

129 are schema placeholders for Phases 3-6 with reasonable defaults so early 

130 adopters can see the shape without the CLI refusing unknown keys. 

131 """ 

132 

133 port: int = DEFAULT_VOICE_AGENT_PORT 

134 domain: str = "" 

135 profile: str = "resident" 

136 profiles: dict[str, VoiceAgentModelProfile] = field( 

137 default_factory=lambda: { 

138 "resident": VoiceAgentModelProfile( 

139 primary="qwen3.5:9b", 

140 secondary="huihui_ai/qwen3.5-abliterated:9b", 

141 ), 

142 "swap": VoiceAgentModelProfile( 

143 primary="qwen3.5:27b", 

144 secondary="dolphin3:8b", 

145 ), 

146 } 

147 ) 

148 vad: VoiceAgentVadConfig = field(default_factory=VoiceAgentVadConfig) 

149 filesystem: VoiceAgentFilesystemConfig = field(default_factory=VoiceAgentFilesystemConfig) 

150 memory: VoiceAgentMemoryConfig = field(default_factory=VoiceAgentMemoryConfig) 

151 auth: VoiceAgentAuthConfig = field(default_factory=VoiceAgentAuthConfig) 

152 providers: VoiceAgentProvidersConfig = field(default_factory=VoiceAgentProvidersConfig) 

153 tools: VoiceAgentToolsConfig = field(default_factory=VoiceAgentToolsConfig) 

154 wake_word: VoiceAgentWakeWordConfig = field(default_factory=VoiceAgentWakeWordConfig) 

155 

156 

157@dataclass 

158class AiShellConfig: 

159 """Configuration for ai-shell.""" 

160 

161 # Container 

162 image: str = DEFAULT_IMAGE 

163 image_tag: str = "latest" 

164 project_name: str = "" 

165 project_dir: Path = field(default_factory=Path.cwd) 

166 

167 # LLM model slots. Primary = best-available; secondary = best uncensored 

168 # alternative. Chat slots are routed to Open WebUI, coding slots to 

169 # OpenCode. `extra_models` is a free-form list of additional 

170 # Ollama tags to pull alongside the 4 slots (deduped). 

171 primary_chat_model: str = DEFAULT_PRIMARY_CHAT_MODEL 

172 secondary_chat_model: str = DEFAULT_SECONDARY_CHAT_MODEL 

173 primary_coding_model: str = DEFAULT_PRIMARY_CODING_MODEL 

174 secondary_coding_model: str = DEFAULT_SECONDARY_CODING_MODEL 

175 extra_models: list[str] = field(default_factory=lambda: list(DEFAULT_EXTRA_MODELS)) 

176 context_size: int = DEFAULT_CONTEXT_SIZE 

177 ollama_port: int = DEFAULT_OLLAMA_PORT 

178 webui_port: int = DEFAULT_WEBUI_PORT 

179 kokoro_port: int = DEFAULT_KOKORO_PORT 

180 kokoro_voice: str = DEFAULT_KOKORO_VOICE 

181 n8n_port: int = DEFAULT_N8N_PORT 

182 whisper_port: int = DEFAULT_WHISPER_PORT 

183 whisper_model: str = DEFAULT_WHISPER_MODEL 

184 comfyui_port: int = DEFAULT_COMFYUI_PORT 

185 

186 # Voice agent (Phase 2 wires `port`; remaining fields are schema 

187 # placeholders that Phases 3-6 consume — see VoiceAgentConfig). 

188 voice_agent: VoiceAgentConfig = field(default_factory=VoiceAgentConfig) 

189 

190 # Extra configuration 

191 extra_env: dict[str, str] = field(default_factory=dict) 

192 extra_volumes: list[str] = field(default_factory=list) 

193 extra_ports: list[int] = field(default_factory=list) 

194 # Glob patterns (relative to project_dir) for monorepo workspace 

195 # node_modules directories. Each match gets an isolated named volume. 

196 node_modules_paths: list[str] = field(default_factory=list) 

197 # Home-config basenames (e.g. ".claude", ".codex") to back with a shared 

198 # named volume instead of a host bind mount, so tool config/state never 

199 # touches the host home directory. Empty = current bind-mount behavior. 

200 isolate_home_paths: list[str] = field(default_factory=list) 

201 

202 # AWS 

203 ai_profile: str = "" # AWS profile for infra (sets AWS_PROFILE in container) 

204 aws_region: str = "" # Override AWS_REGION 

205 bedrock_profile: str = "" # AWS profile for Bedrock LLM API calls 

206 bedrock_region: str = "" # AWS region for Bedrock (falls back to aws_region) 

207 bedrock_model: str = "us.meta.llama3-3-70b-instruct-v1:0" 

208 

209 # OpenAI 

210 openai_profile: str = "" # Suffixed .env key name for multi-account switching 

211 

212 # Claude options 

213 local_chrome: bool = False # Attach Chrome DevTools MCP to project-scoped host Chrome 

214 skip_updates: bool = False # When True, skip pre-launch tool freshness checks 

215 

216 # Pre-launch cache TTLs (seconds). Set to 0 to disable. 

217 image_pull_cache_ttl: int = 900 # 15 min: skip docker pull if checked recently 

218 bedrock_check_cache_ttl: int = 86400 # 24 h: skip Bedrock preflight if checked recently 

219 

220 # Per-tool provider 

221 claude_provider: str = "" # "anthropic" (default) or "aws" 

222 

223 @property 

224 def full_image(self) -> str: 

225 """Return the full image reference with tag.""" 

226 return f"{self.image}:{self.image_tag}" 

227 

228 @property 

229 def dev_ports(self) -> list[int]: 

230 """Return deduplicated, sorted list of dev container ports to expose.""" 

231 return sorted(set(DEFAULT_DEV_PORTS + self.extra_ports)) 

232 

233 @property 

234 def models_to_pull(self) -> list[str]: 

235 """Return the full deduped list of Ollama model tags to pull. 

236 

237 The 4 slots in order, followed by any ``extra_models``. Duplicates 

238 are removed while preserving first-occurrence order. 

239 """ 

240 ordered = [ 

241 self.primary_chat_model, 

242 self.secondary_chat_model, 

243 self.primary_coding_model, 

244 self.secondary_coding_model, 

245 *self.extra_models, 

246 ] 

247 seen: set[str] = set() 

248 deduped: list[str] = [] 

249 for model in ordered: 

250 if model and model not in seen: 

251 seen.add(model) 

252 deduped.append(model) 

253 return deduped 

254 

255 

256def load_config( 

257 project_override: str | None = None, 

258 project_dir: Path | None = None, 

259) -> AiShellConfig: 

260 """Load configuration from all sources. 

261 

262 Priority: CLI overrides > env vars > project toml > global toml > defaults. 

263 """ 

264 config = AiShellConfig() 

265 

266 if project_dir: 

267 config.project_dir = project_dir 

268 

269 # Load global config (~/.augint/ canonical, ~/.ai-shell.yaml legacy fallback) 

270 home = Path.home() 

271 for candidate in ( 

272 home / ".augint" / ".ai-shell.yaml", 

273 home / ".ai-shell.yaml", 

274 home / ".ai-shell.yml", 

275 home / ".ai-shell.toml", 

276 home / ".config" / "ai-shell" / "config.yaml", 

277 home / ".config" / "ai-shell" / "config.yml", 

278 home / ".config" / "ai-shell" / "config.toml", 

279 ): 

280 if candidate.exists(): 

281 _apply_config(config, candidate) 

282 break 

283 

284 # Load project config (first match wins) 

285 for name in (".ai-shell.yaml", ".ai-shell.yml", ".ai-shell.toml", "ai-shell.toml"): 

286 candidate = config.project_dir / name 

287 if candidate.exists(): 

288 _apply_config(config, candidate) 

289 break 

290 

291 # Apply environment variable overrides 

292 _apply_env_vars(config) 

293 

294 # Apply CLI overrides 

295 if project_override: 

296 config.project_name = project_override 

297 

298 # Auto-derive project name from CWD if not set 

299 if not config.project_name: 

300 from ai_shell.defaults import sanitize_project_name 

301 

302 config.project_name = sanitize_project_name(config.project_dir) 

303 

304 return config 

305 

306 

307def _load_config_file(path: Path) -> dict: 

308 """Load a YAML or TOML config file and return the parsed dict.""" 

309 suffix = path.suffix.lower() 

310 if suffix in (".yaml", ".yml"): 

311 with open(path, encoding="utf-8") as f: 

312 return yaml.safe_load(f) or {} 

313 with open(path, "rb") as f: 

314 return tomllib.load(f) 

315 

316 

317_LEGACY_LLM_KEY_HINT = { 

318 "primary_model": ( 

319 "renamed to `primary_coding_model` (coding) or `primary_chat_model` " 

320 "(chat). The new config uses 4 role-specific slots; pick the one " 

321 "that matches your intent. See the generated .ai-shell.yaml for the " 

322 "full layout." 

323 ), 

324 "fallback_model": ( 

325 "removed. The previous `fallback_model` was role-ambiguous. Use " 

326 "`secondary_chat_model` and `secondary_coding_model` instead " 

327 "(both default to the best uncensored variants). See the generated " 

328 ".ai-shell.yaml for the full layout." 

329 ), 

330} 

331 

332 

333def _reject_legacy_llm_keys(llm_section: dict, path: Path) -> None: 

334 """Raise on deprecated `primary_model` / `fallback_model` keys. 

335 

336 These were removed when the llm config split into 4 role-specific slots 

337 (primary/secondary x chat/coding). Silently aliasing them would corrupt 

338 intent — e.g. the old `fallback_model` meant different things to chat and 

339 coding users. Fail loudly with migration guidance. 

340 """ 

341 bad = [k for k in _LEGACY_LLM_KEY_HINT if k in llm_section] 

342 if not bad: 

343 return 

344 lines = [f"\nDeprecated llm key(s) found in {path}:"] 

345 for key in bad: 

346 lines.append(f" - `{key}`: {_LEGACY_LLM_KEY_HINT[key]}") 

347 raise ValueError("\n".join(lines)) 

348 

349 

350def _apply_voice_agent_config(va: VoiceAgentConfig, data: dict) -> None: 

351 """Merge a parsed ``voice_agent:`` section into a VoiceAgentConfig. 

352 

353 Only keys present in *data* override defaults; everything else keeps 

354 the dataclass default. Nested sections are merged field-by-field so 

355 partial user configs work. 

356 """ 

357 if "port" in data: 

358 va.port = int(data["port"]) 

359 if "domain" in data: 

360 va.domain = str(data["domain"]) 

361 if "profile" in data: 

362 va.profile = str(data["profile"]) 

363 if "profiles" in data and isinstance(data["profiles"], dict): 

364 for name, entry in data["profiles"].items(): 

365 profile = va.profiles.get(name, VoiceAgentModelProfile()) 

366 if isinstance(entry, dict): 

367 if "primary" in entry: 

368 profile.primary = str(entry["primary"]) 

369 if "secondary" in entry: 

370 profile.secondary = str(entry["secondary"]) 

371 va.profiles[name] = profile 

372 if "vad" in data and isinstance(data["vad"], dict): 

373 vad = data["vad"] 

374 if "silence_timeout_ms" in vad: 

375 va.vad.silence_timeout_ms = int(vad["silence_timeout_ms"]) 

376 if "barge_in" in vad: 

377 va.vad.barge_in = bool(vad["barge_in"]) 

378 if "filesystem" in data and isinstance(data["filesystem"], dict): 

379 fs = data["filesystem"] 

380 if "root" in fs: 

381 va.filesystem.root = str(fs["root"]) 

382 if "read" in fs: 

383 va.filesystem.read = [str(p) for p in fs["read"]] 

384 if "write" in fs: 

385 va.filesystem.write = [str(p) for p in fs["write"]] 

386 if "deny_glob" in fs: 

387 va.filesystem.deny_glob = [str(p) for p in fs["deny_glob"]] 

388 if "memory" in data and isinstance(data["memory"], dict): 

389 mem = data["memory"] 

390 if "enabled" in mem: 

391 va.memory.enabled = bool(mem["enabled"]) 

392 if "summarize_after_turns" in mem: 

393 va.memory.summarize_after_turns = int(mem["summarize_after_turns"]) 

394 if "auth" in data and isinstance(data["auth"], dict): 

395 auth = data["auth"] 

396 if "username" in auth: 

397 va.auth.username = str(auth["username"]) 

398 if "password_bcrypt" in auth: 

399 va.auth.password_bcrypt = str(auth["password_bcrypt"]) 

400 if "session_secret" in auth: 

401 va.auth.session_secret = str(auth["session_secret"]) 

402 if "providers" in data and isinstance(data["providers"], dict): 

403 providers = data["providers"] 

404 if "default" in providers: 

405 va.providers.default = str(providers["default"]) 

406 if "available" in providers: 

407 va.providers.available = [str(p) for p in providers["available"]] 

408 if "tools" in data and isinstance(data["tools"], dict): 

409 tools = data["tools"] 

410 for tool_name in ("filesystem", "web_search", "github"): 

411 entry = tools.get(tool_name) 

412 if isinstance(entry, dict): 

413 tool = getattr(va.tools, tool_name) 

414 if "enabled" in entry: 

415 tool.enabled = bool(entry["enabled"]) 

416 if "provider" in entry: 

417 tool.provider = str(entry["provider"]) 

418 if "wake_word" in data and isinstance(data["wake_word"], dict): 

419 wake = data["wake_word"] 

420 if "enabled" in wake: 

421 va.wake_word.enabled = bool(wake["enabled"]) 

422 if "name" in wake: 

423 va.wake_word.name = str(wake["name"]) 

424 

425 

426def _apply_config(config: AiShellConfig, path: Path) -> None: 

427 """Apply settings from a YAML or TOML config file.""" 

428 try: 

429 data = _load_config_file(path) 

430 except (OSError, tomllib.TOMLDecodeError, yaml.YAMLError) as e: 

431 logger.warning("Failed to load config from %s: %s", path, e) 

432 return 

433 

434 logger.debug("Loading config from %s", path) 

435 

436 # [container] section 

437 container = data.get("container", {}) 

438 if "image" in container: 

439 config.image = container["image"] 

440 if "image_tag" in container: 

441 config.image_tag = container["image_tag"] 

442 if "extra_env" in container: 

443 config.extra_env.update(container["extra_env"]) 

444 if "extra_volumes" in container: 

445 config.extra_volumes.extend(container["extra_volumes"]) 

446 if "ports" in container: 

447 config.extra_ports.extend(int(p) for p in container["ports"]) 

448 if "node_modules_paths" in container: 

449 config.node_modules_paths.extend(str(p) for p in container["node_modules_paths"]) 

450 if "isolate_home_paths" in container: 

451 config.isolate_home_paths.extend(str(p) for p in container["isolate_home_paths"]) 

452 

453 # [llm] section 

454 llm = data.get("llm", {}) 

455 _reject_legacy_llm_keys(llm, path) 

456 if "primary_chat_model" in llm: 

457 config.primary_chat_model = llm["primary_chat_model"] 

458 if "secondary_chat_model" in llm: 

459 config.secondary_chat_model = llm["secondary_chat_model"] 

460 if "primary_coding_model" in llm: 

461 config.primary_coding_model = llm["primary_coding_model"] 

462 if "secondary_coding_model" in llm: 

463 config.secondary_coding_model = llm["secondary_coding_model"] 

464 if "extra_models" in llm: 

465 config.extra_models = [str(m) for m in llm["extra_models"]] 

466 if "context_size" in llm: 

467 config.context_size = int(llm["context_size"]) 

468 if "ollama_port" in llm: 

469 config.ollama_port = int(llm["ollama_port"]) 

470 if "webui_port" in llm: 

471 config.webui_port = int(llm["webui_port"]) 

472 if "kokoro_port" in llm: 

473 config.kokoro_port = int(llm["kokoro_port"]) 

474 if "kokoro_voice" in llm: 

475 config.kokoro_voice = str(llm["kokoro_voice"]) 

476 if "n8n_port" in llm: 

477 config.n8n_port = int(llm["n8n_port"]) 

478 if "whisper_port" in llm: 

479 config.whisper_port = int(llm["whisper_port"]) 

480 if "whisper_model" in llm: 

481 config.whisper_model = str(llm["whisper_model"]) 

482 if "comfyui_port" in llm: 

483 config.comfyui_port = int(llm["comfyui_port"]) 

484 

485 # [voice_agent] section (top-level, not under llm) 

486 if "voice_agent" in data: 

487 _apply_voice_agent_config(config.voice_agent, data["voice_agent"]) 

488 

489 # [aws] section 

490 aws = data.get("aws", {}) 

491 if "ai_profile" in aws: 

492 config.ai_profile = aws["ai_profile"] 

493 if "region" in aws: 

494 config.aws_region = aws["region"] 

495 if "bedrock_profile" in aws: 

496 config.bedrock_profile = aws["bedrock_profile"] 

497 if "bedrock_region" in aws: 

498 config.bedrock_region = aws["bedrock_region"] 

499 if "bedrock_model" in aws: 

500 config.bedrock_model = aws["bedrock_model"] 

501 

502 # [openai] section 

503 openai = data.get("openai", {}) 

504 if "profile" in openai: 

505 config.openai_profile = openai["profile"] 

506 

507 # [claude] section 

508 claude_sec = data.get("claude", {}) 

509 if "provider" in claude_sec: 

510 config.claude_provider = claude_sec["provider"] 

511 if "local_chrome" in claude_sec: 

512 config.local_chrome = bool(claude_sec["local_chrome"]) 

513 if "skip_updates" in container: 

514 config.skip_updates = bool(container["skip_updates"]) 

515 if "image_pull_cache_ttl" in container: 

516 config.image_pull_cache_ttl = int(container["image_pull_cache_ttl"]) 

517 if "bedrock_check_cache_ttl" in aws: 

518 config.bedrock_check_cache_ttl = int(aws["bedrock_check_cache_ttl"]) 

519 

520 

521_LEGACY_ENV_VARS = { 

522 "AI_SHELL_PRIMARY_MODEL": ("AI_SHELL_PRIMARY_CODING_MODEL or AI_SHELL_PRIMARY_CHAT_MODEL"), 

523 "AI_SHELL_FALLBACK_MODEL": ("AI_SHELL_SECONDARY_CHAT_MODEL or AI_SHELL_SECONDARY_CODING_MODEL"), 

524} 

525 

526 

527def _apply_env_vars(config: AiShellConfig) -> None: 

528 """Apply AI_SHELL_* environment variable overrides.""" 

529 bad_env = [k for k in _LEGACY_ENV_VARS if os.environ.get(k) is not None] 

530 if bad_env: 

531 lines = ["\nDeprecated AI_SHELL_* env var(s) set:"] 

532 for key in bad_env: 

533 lines.append(f" - {key}: use {_LEGACY_ENV_VARS[key]} instead") 

534 raise ValueError("\n".join(lines)) 

535 

536 env_map: dict[str, tuple[str, type]] = { 

537 "AI_SHELL_IMAGE": ("image", str), 

538 "AI_SHELL_IMAGE_TAG": ("image_tag", str), 

539 "AI_SHELL_PROJECT": ("project_name", str), 

540 "AI_SHELL_PRIMARY_CHAT_MODEL": ("primary_chat_model", str), 

541 "AI_SHELL_SECONDARY_CHAT_MODEL": ("secondary_chat_model", str), 

542 "AI_SHELL_PRIMARY_CODING_MODEL": ("primary_coding_model", str), 

543 "AI_SHELL_SECONDARY_CODING_MODEL": ("secondary_coding_model", str), 

544 "AI_SHELL_CONTEXT_SIZE": ("context_size", int), 

545 "AI_SHELL_OLLAMA_PORT": ("ollama_port", int), 

546 "AI_SHELL_WEBUI_PORT": ("webui_port", int), 

547 "AI_SHELL_KOKORO_PORT": ("kokoro_port", int), 

548 "AI_SHELL_KOKORO_VOICE": ("kokoro_voice", str), 

549 "AI_SHELL_N8N_PORT": ("n8n_port", int), 

550 "AI_SHELL_WHISPER_PORT": ("whisper_port", int), 

551 "AI_SHELL_WHISPER_MODEL": ("whisper_model", str), 

552 "AI_SHELL_COMFYUI_PORT": ("comfyui_port", int), 

553 "AI_SHELL_AI_PROFILE": ("ai_profile", str), 

554 "AI_SHELL_AWS_REGION": ("aws_region", str), 

555 "AI_SHELL_BEDROCK_PROFILE": ("bedrock_profile", str), 

556 "AI_SHELL_BEDROCK_REGION": ("bedrock_region", str), 

557 "AI_SHELL_BEDROCK_MODEL": ("bedrock_model", str), 

558 "AI_SHELL_OPENAI_PROFILE": ("openai_profile", str), 

559 "AI_SHELL_CLAUDE_PROVIDER": ("claude_provider", str), 

560 "AI_SHELL_LOCAL_CHROME": ("local_chrome", bool), 

561 "AI_SHELL_SKIP_UPDATES": ("skip_updates", bool), 

562 "AI_SHELL_IMAGE_PULL_CACHE_TTL": ("image_pull_cache_ttl", int), 

563 "AI_SHELL_BEDROCK_CHECK_CACHE_TTL": ("bedrock_check_cache_ttl", int), 

564 } 

565 

566 for env_key, (attr, type_fn) in env_map.items(): 

567 value = os.environ.get(env_key) 

568 if value is not None: 

569 if type_fn is bool: 

570 coerced = value.lower() not in ("0", "false", "no", "") 

571 else: 

572 coerced = type_fn(value) 

573 setattr(config, attr, coerced) 

574 logger.debug("Config override from env: %s=%s", env_key, value) 

575 

576 # AI_SHELL_PORTS is comma-separated, extends extra_ports 

577 ports_value = os.environ.get("AI_SHELL_PORTS") 

578 if ports_value: 

579 config.extra_ports.extend(int(p.strip()) for p in ports_value.split(",") if p.strip()) 

580 

581 # AI_SHELL_ISOLATE_HOME_PATHS is comma-separated, extends isolate_home_paths 

582 isolate_value = os.environ.get("AI_SHELL_ISOLATE_HOME_PATHS") 

583 if isolate_value: 

584 config.isolate_home_paths.extend(p.strip() for p in isolate_value.split(",") if p.strip()) 

585 

586 # Nested voice_agent overrides (flat env vars map to nested fields) 

587 voice_agent_port = os.environ.get("AI_SHELL_VOICE_AGENT_PORT") 

588 if voice_agent_port is not None: 

589 config.voice_agent.port = int(voice_agent_port) 

590 voice_agent_domain = os.environ.get("AI_SHELL_VOICE_AGENT_DOMAIN") 

591 if voice_agent_domain is not None: 

592 config.voice_agent.domain = voice_agent_domain 

593 voice_agent_profile = os.environ.get("AI_SHELL_VOICE_AGENT_PROFILE") 

594 if voice_agent_profile is not None: 

595 config.voice_agent.profile = voice_agent_profile