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

335 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-03 01:05 +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 # Expo. When False, a detected Expo app is not started automatically; 

217 # an explicit --expo still works. --expo/--no-expo override this. 

218 expo_auto: bool = True 

219 

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

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

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

223 

224 # Per-tool provider 

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

226 

227 @property 

228 def full_image(self) -> str: 

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

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

231 

232 @property 

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

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

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

236 

237 @property 

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

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

240 

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

242 are removed while preserving first-occurrence order. 

243 """ 

244 ordered = [ 

245 self.primary_chat_model, 

246 self.secondary_chat_model, 

247 self.primary_coding_model, 

248 self.secondary_coding_model, 

249 *self.extra_models, 

250 ] 

251 seen: set[str] = set() 

252 deduped: list[str] = [] 

253 for model in ordered: 

254 if model and model not in seen: 

255 seen.add(model) 

256 deduped.append(model) 

257 return deduped 

258 

259 

260def load_config( 

261 project_override: str | None = None, 

262 project_dir: Path | None = None, 

263) -> AiShellConfig: 

264 """Load configuration from all sources. 

265 

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

267 """ 

268 config = AiShellConfig() 

269 

270 if project_dir: 

271 config.project_dir = project_dir 

272 

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

274 home = Path.home() 

275 for candidate in ( 

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

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

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

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

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

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

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

283 ): 

284 if candidate.exists(): 

285 _apply_config(config, candidate) 

286 break 

287 

288 # Load project config (first match wins) 

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

290 candidate = config.project_dir / name 

291 if candidate.exists(): 

292 _apply_config(config, candidate) 

293 break 

294 

295 # Apply environment variable overrides 

296 _apply_env_vars(config) 

297 

298 # Apply CLI overrides 

299 if project_override: 

300 config.project_name = project_override 

301 

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

303 if not config.project_name: 

304 from ai_shell.defaults import sanitize_project_name 

305 

306 config.project_name = sanitize_project_name(config.project_dir) 

307 

308 return config 

309 

310 

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

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

313 suffix = path.suffix.lower() 

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

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

316 return yaml.safe_load(f) or {} 

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

318 return tomllib.load(f) 

319 

320 

321_LEGACY_LLM_KEY_HINT = { 

322 "primary_model": ( 

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

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

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

326 "full layout." 

327 ), 

328 "fallback_model": ( 

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

330 "`secondary_chat_model` and `secondary_coding_model` instead " 

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

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

333 ), 

334} 

335 

336 

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

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

339 

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

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

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

343 coding users. Fail loudly with migration guidance. 

344 """ 

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

346 if not bad: 

347 return 

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

349 for key in bad: 

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

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

352 

353 

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

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

356 

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

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

359 partial user configs work. 

360 """ 

361 if "port" in data: 

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

363 if "domain" in data: 

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

365 if "profile" in data: 

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

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

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

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

370 if isinstance(entry, dict): 

371 if "primary" in entry: 

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

373 if "secondary" in entry: 

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

375 va.profiles[name] = profile 

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

377 vad = data["vad"] 

378 if "silence_timeout_ms" in vad: 

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

380 if "barge_in" in vad: 

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

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

383 fs = data["filesystem"] 

384 if "root" in fs: 

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

386 if "read" in fs: 

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

388 if "write" in fs: 

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

390 if "deny_glob" in fs: 

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

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

393 mem = data["memory"] 

394 if "enabled" in mem: 

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

396 if "summarize_after_turns" in mem: 

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

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

399 auth = data["auth"] 

400 if "username" in auth: 

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

402 if "password_bcrypt" in auth: 

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

404 if "session_secret" in auth: 

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

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

407 providers = data["providers"] 

408 if "default" in providers: 

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

410 if "available" in providers: 

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

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

413 tools = data["tools"] 

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

415 entry = tools.get(tool_name) 

416 if isinstance(entry, dict): 

417 tool = getattr(va.tools, tool_name) 

418 if "enabled" in entry: 

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

420 if "provider" in entry: 

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

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

423 wake = data["wake_word"] 

424 if "enabled" in wake: 

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

426 if "name" in wake: 

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

428 

429 

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

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

432 try: 

433 data = _load_config_file(path) 

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

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

436 return 

437 

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

439 

440 # [container] section 

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

442 if "image" in container: 

443 config.image = container["image"] 

444 if "image_tag" in container: 

445 config.image_tag = container["image_tag"] 

446 if "extra_env" in container: 

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

448 if "extra_volumes" in container: 

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

450 if "ports" in container: 

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

452 if "node_modules_paths" in container: 

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

454 if "isolate_home_paths" in container: 

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

456 

457 # [expo] section 

458 expo = data.get("expo", {}) 

459 if "auto" in expo: 

460 config.expo_auto = bool(expo["auto"]) 

461 

462 # [llm] section 

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

464 _reject_legacy_llm_keys(llm, path) 

465 if "primary_chat_model" in llm: 

466 config.primary_chat_model = llm["primary_chat_model"] 

467 if "secondary_chat_model" in llm: 

468 config.secondary_chat_model = llm["secondary_chat_model"] 

469 if "primary_coding_model" in llm: 

470 config.primary_coding_model = llm["primary_coding_model"] 

471 if "secondary_coding_model" in llm: 

472 config.secondary_coding_model = llm["secondary_coding_model"] 

473 if "extra_models" in llm: 

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

475 if "context_size" in llm: 

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

477 if "ollama_port" in llm: 

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

479 if "webui_port" in llm: 

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

481 if "kokoro_port" in llm: 

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

483 if "kokoro_voice" in llm: 

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

485 if "n8n_port" in llm: 

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

487 if "whisper_port" in llm: 

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

489 if "whisper_model" in llm: 

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

491 if "comfyui_port" in llm: 

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

493 

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

495 if "voice_agent" in data: 

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

497 

498 # [aws] section 

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

500 if "ai_profile" in aws: 

501 config.ai_profile = aws["ai_profile"] 

502 if "region" in aws: 

503 config.aws_region = aws["region"] 

504 if "bedrock_profile" in aws: 

505 config.bedrock_profile = aws["bedrock_profile"] 

506 if "bedrock_region" in aws: 

507 config.bedrock_region = aws["bedrock_region"] 

508 if "bedrock_model" in aws: 

509 config.bedrock_model = aws["bedrock_model"] 

510 

511 # [openai] section 

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

513 if "profile" in openai: 

514 config.openai_profile = openai["profile"] 

515 

516 # [claude] section 

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

518 if "provider" in claude_sec: 

519 config.claude_provider = claude_sec["provider"] 

520 if "local_chrome" in claude_sec: 

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

522 if "skip_updates" in container: 

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

524 if "image_pull_cache_ttl" in container: 

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

526 if "bedrock_check_cache_ttl" in aws: 

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

528 

529 

530_LEGACY_ENV_VARS = { 

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

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

533} 

534 

535 

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

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

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

539 if bad_env: 

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

541 for key in bad_env: 

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

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

544 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

570 "AI_SHELL_EXPO_AUTO": ("expo_auto", bool), 

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

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

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

574 } 

575 

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

577 value = os.environ.get(env_key) 

578 if value is not None: 

579 if type_fn is bool: 

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

581 else: 

582 coerced = type_fn(value) 

583 setattr(config, attr, coerced) 

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

585 

586 # AI_SHELL_PORTS is comma-separated, extends extra_ports 

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

588 if ports_value: 

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

590 

591 # AI_SHELL_ISOLATE_HOME_PATHS is comma-separated, extends isolate_home_paths 

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

593 if isolate_value: 

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

595 

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

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

598 if voice_agent_port is not None: 

599 config.voice_agent.port = int(voice_agent_port) 

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

601 if voice_agent_domain is not None: 

602 config.voice_agent.domain = voice_agent_domain 

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

604 if voice_agent_profile is not None: 

605 config.voice_agent.profile = voice_agent_profile