personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add settings CLI — sol call settings <command>

Comprehensive CLI for journal settings management with 21 commands
across keys, providers, google-backend, vertex-credentials,
transcribe, identity, and observer groups. Auto-discovered by
think.call as sol call settings.

+1147
+669
apps/settings/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for journal settings management. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call settings ...``. 7 + """ 8 + 9 + import json 10 + import os 11 + import subprocess 12 + from datetime import datetime, timezone 13 + from pathlib import Path 14 + 15 + import typer 16 + 17 + app = typer.Typer( 18 + help="Journal settings — keys, providers, transcription, identity, and observer." 19 + ) 20 + keys_app = typer.Typer(help="API key management.") 21 + app.add_typer(keys_app, name="keys") 22 + providers_app = typer.Typer(help="AI provider configuration.") 23 + app.add_typer(providers_app, name="providers") 24 + google_backend_app = typer.Typer(help="Google backend selection.") 25 + app.add_typer(google_backend_app, name="google-backend") 26 + vertex_app = typer.Typer(help="Vertex AI service account credentials.") 27 + app.add_typer(vertex_app, name="vertex-credentials") 28 + transcribe_app = typer.Typer(help="Transcription backend configuration.") 29 + app.add_typer(transcribe_app, name="transcribe") 30 + identity_app = typer.Typer(help="Journal owner identity.") 31 + app.add_typer(identity_app, name="identity") 32 + observer_app = typer.Typer(help="Observer capture settings.") 33 + app.add_typer(observer_app, name="observer") 34 + 35 + 36 + def _get_config(): 37 + """Read journal config.""" 38 + from think.utils import get_config 39 + 40 + return get_config() 41 + 42 + 43 + def _write_config(config: dict) -> None: 44 + """Write journal config with indent=2, trailing newline, 0o600.""" 45 + from think.utils import get_journal 46 + 47 + config_path = Path(get_journal()) / "config" / "journal.json" 48 + config_path.parent.mkdir(parents=True, exist_ok=True) 49 + with open(config_path, "w", encoding="utf-8") as f: 50 + json.dump(config, f, indent=2, ensure_ascii=False) 51 + f.write("\n") 52 + os.chmod(config_path, 0o600) 53 + 54 + 55 + def _provider_for_env_var(env_var: str) -> str | None: 56 + """Return the provider mapped to an API env var, if any.""" 57 + from think.providers import PROVIDER_METADATA 58 + 59 + env_to_provider = { 60 + meta["env_key"]: name 61 + for name, meta in PROVIDER_METADATA.items() 62 + if "env_key" in meta 63 + } 64 + return env_to_provider.get(env_var) 65 + 66 + 67 + def _validate_env_var_or_exit(env_var: str) -> None: 68 + """Exit if env_var is not a supported API key variable.""" 69 + from apps.settings.routes import API_KEY_ENV_VARS 70 + 71 + if env_var not in API_KEY_ENV_VARS: 72 + typer.echo( 73 + f"Invalid env var: {env_var}. Must be one of: {', '.join(API_KEY_ENV_VARS)}", 74 + err=True, 75 + ) 76 + raise typer.Exit(1) 77 + 78 + 79 + def _set_provider_type( 80 + agent_type: str, 81 + provider: str | None, 82 + tier: int | None, 83 + backup: str | None, 84 + ) -> dict: 85 + """Validate and update the provider settings for a single agent type.""" 86 + from think.providers import PROVIDER_REGISTRY 87 + 88 + config = _get_config() 89 + config.setdefault("providers", {}) 90 + config["providers"].setdefault(agent_type, {}) 91 + 92 + if provider is not None: 93 + if provider not in PROVIDER_REGISTRY: 94 + typer.echo( 95 + f"Invalid provider: {provider}. Must be one of: {', '.join(sorted(PROVIDER_REGISTRY.keys()))}", 96 + err=True, 97 + ) 98 + raise typer.Exit(1) 99 + config["providers"][agent_type]["provider"] = provider 100 + 101 + if tier is not None: 102 + if tier not in {1, 2, 3}: 103 + typer.echo(f"Invalid tier: {tier}. Must be 1, 2, or 3.", err=True) 104 + raise typer.Exit(1) 105 + config["providers"][agent_type]["tier"] = tier 106 + 107 + if backup is not None: 108 + if backup not in PROVIDER_REGISTRY: 109 + typer.echo( 110 + f"Invalid backup provider: {backup}. Must be one of: {', '.join(sorted(PROVIDER_REGISTRY.keys()))}", 111 + err=True, 112 + ) 113 + raise typer.Exit(1) 114 + config["providers"][agent_type]["backup"] = backup 115 + 116 + _write_config(config) 117 + return config["providers"][agent_type] 118 + 119 + 120 + @app.command("show") 121 + def show() -> None: 122 + """Show a summary of journal settings.""" 123 + from apps.settings.routes import API_KEY_ENV_VARS 124 + from think.models import TYPE_DEFAULTS 125 + 126 + config = _get_config() 127 + providers_config = config.get("providers", {}) 128 + type_settings = {} 129 + for agent_type in ("generate", "cogitate"): 130 + defaults = TYPE_DEFAULTS[agent_type] 131 + type_config = providers_config.get(agent_type, {}) 132 + type_settings[agent_type] = { 133 + "provider": type_config.get("provider", defaults["provider"]), 134 + "tier": type_config.get("tier", defaults["tier"]), 135 + "backup": type_config.get("backup", defaults["backup"]), 136 + } 137 + 138 + summary = { 139 + "identity": config.get("identity", {}), 140 + "providers": { 141 + "generate": type_settings["generate"], 142 + "cogitate": type_settings["cogitate"], 143 + "google_backend": providers_config.get("google_backend", "auto"), 144 + "auth": providers_config.get("auth", {}), 145 + "key_validation": providers_config.get("key_validation", {}), 146 + }, 147 + "transcribe": config.get("transcribe", {}), 148 + "observe": config.get("observe", {}), 149 + "keys": {k: bool(config.get("env", {}).get(k)) for k in API_KEY_ENV_VARS}, 150 + } 151 + typer.echo(json.dumps(summary, indent=2)) 152 + 153 + 154 + @keys_app.command("show") 155 + def keys_show() -> None: 156 + """Show configured API key status.""" 157 + from apps.settings.routes import API_KEY_ENV_VARS 158 + 159 + config = _get_config() 160 + env_config = config.get("env", {}) 161 + status = {k: bool(env_config.get(k)) for k in API_KEY_ENV_VARS} 162 + typer.echo(json.dumps(status, indent=2)) 163 + 164 + 165 + @keys_app.command("set") 166 + def keys_set( 167 + env_var: str = typer.Argument(..., help="Environment variable to set."), 168 + value: str = typer.Argument(..., help="API key value."), 169 + ) -> None: 170 + """Set an API key in journal config.""" 171 + from think.providers import validate_key 172 + 173 + _validate_env_var_or_exit(env_var) 174 + config = _get_config() 175 + config.setdefault("env", {}) 176 + config["env"][env_var] = value 177 + os.environ[env_var] = value 178 + 179 + validation = None 180 + provider = _provider_for_env_var(env_var) 181 + if provider: 182 + config.setdefault("providers", {}) 183 + config["providers"].setdefault("auth", {}) 184 + config["providers"]["auth"][provider] = "api_key" 185 + validation = validate_key(provider, value) 186 + validation["timestamp"] = datetime.now(timezone.utc).isoformat() 187 + config["providers"].setdefault("key_validation", {}) 188 + config["providers"]["key_validation"][provider] = validation 189 + 190 + _write_config(config) 191 + typer.echo( 192 + json.dumps( 193 + {"env_var": env_var, "set": True, "validation": validation}, 194 + indent=2, 195 + ) 196 + ) 197 + 198 + 199 + @keys_app.command("clear") 200 + def keys_clear( 201 + env_var: str = typer.Argument(..., help="Environment variable to clear."), 202 + ) -> None: 203 + """Clear an API key from journal config.""" 204 + _validate_env_var_or_exit(env_var) 205 + config = _get_config() 206 + env_config = config.setdefault("env", {}) 207 + env_config.pop(env_var, None) 208 + os.environ.pop(env_var, None) 209 + 210 + provider = _provider_for_env_var(env_var) 211 + if provider: 212 + config.setdefault("providers", {}) 213 + config["providers"].setdefault("auth", {}) 214 + config["providers"]["auth"][provider] = "platform" 215 + config["providers"].setdefault("key_validation", {}) 216 + config["providers"]["key_validation"].pop(provider, None) 217 + 218 + _write_config(config) 219 + typer.echo(json.dumps({"env_var": env_var, "cleared": True}, indent=2)) 220 + 221 + 222 + @keys_app.command("validate") 223 + def keys_validate() -> None: 224 + """Re-validate all configured API keys.""" 225 + from think.providers import PROVIDER_METADATA, validate_key 226 + from think.providers.google import validate_vertex_credentials 227 + 228 + config = _get_config() 229 + env_config = config.get("env", {}) 230 + env_to_provider = { 231 + meta["env_key"]: name 232 + for name, meta in PROVIDER_METADATA.items() 233 + if "env_key" in meta 234 + } 235 + 236 + key_validation = {} 237 + for env_var, provider in env_to_provider.items(): 238 + api_key = env_config.get(env_var, "") 239 + if api_key: 240 + result = validate_key(provider, api_key) 241 + result["timestamp"] = datetime.now(timezone.utc).isoformat() 242 + key_validation[provider] = result 243 + 244 + providers_config = config.get("providers", {}) 245 + if ( 246 + providers_config.get("google_backend") == "vertex" 247 + and providers_config.get("vertex_credentials") 248 + ): 249 + result = validate_vertex_credentials(providers_config["vertex_credentials"]) 250 + result["timestamp"] = datetime.now(timezone.utc).isoformat() 251 + key_validation["google"] = result 252 + 253 + config.setdefault("providers", {}) 254 + config["providers"]["key_validation"] = key_validation 255 + _write_config(config) 256 + typer.echo(json.dumps({"key_validation": key_validation}, indent=2)) 257 + 258 + 259 + @providers_app.command("show") 260 + def providers_show() -> None: 261 + """Show provider configuration.""" 262 + from think.models import TYPE_DEFAULTS 263 + from think.providers import get_provider_list 264 + 265 + config = _get_config() 266 + providers_config = config.get("providers", {}) 267 + type_settings = {} 268 + for agent_type in ("generate", "cogitate"): 269 + defaults = TYPE_DEFAULTS[agent_type] 270 + type_config = providers_config.get(agent_type, {}) 271 + type_settings[agent_type] = { 272 + "provider": type_config.get("provider", defaults["provider"]), 273 + "tier": type_config.get("tier", defaults["tier"]), 274 + "backup": type_config.get("backup", defaults["backup"]), 275 + } 276 + 277 + providers_list = get_provider_list() 278 + api_keys = {} 279 + for provider in providers_list: 280 + env_key = provider.get("env_key", "") 281 + api_keys[provider["name"]] = bool(os.getenv(env_key)) if env_key else False 282 + 283 + auth_config = providers_config.get("auth", {}) 284 + auth = { 285 + provider["name"]: auth_config.get(provider["name"], "platform") 286 + for provider in providers_list 287 + } 288 + result = { 289 + "providers": providers_list, 290 + "generate": type_settings["generate"], 291 + "cogitate": type_settings["cogitate"], 292 + "api_keys": api_keys, 293 + "auth": auth, 294 + "key_validation": providers_config.get("key_validation", {}), 295 + } 296 + typer.echo(json.dumps(result, indent=2)) 297 + 298 + 299 + @providers_app.command("set-generate") 300 + def providers_set_generate( 301 + provider: str | None = typer.Option(None, "--provider", help="Primary provider."), 302 + tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), 303 + backup: str | None = typer.Option(None, "--backup", help="Backup provider."), 304 + ) -> None: 305 + """Set generate provider defaults.""" 306 + typer.echo(json.dumps(_set_provider_type("generate", provider, tier, backup), indent=2)) 307 + 308 + 309 + @providers_app.command("set-cogitate") 310 + def providers_set_cogitate( 311 + provider: str | None = typer.Option(None, "--provider", help="Primary provider."), 312 + tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), 313 + backup: str | None = typer.Option(None, "--backup", help="Backup provider."), 314 + ) -> None: 315 + """Set cogitate provider defaults.""" 316 + typer.echo(json.dumps(_set_provider_type("cogitate", provider, tier, backup), indent=2)) 317 + 318 + 319 + @providers_app.command("set-auth") 320 + def providers_set_auth( 321 + provider: str = typer.Argument(..., help="Provider name."), 322 + mode: str = typer.Argument(..., help="Auth mode."), 323 + ) -> None: 324 + """Set provider auth mode.""" 325 + from think.providers import PROVIDER_REGISTRY 326 + 327 + if provider not in PROVIDER_REGISTRY: 328 + typer.echo(f"Invalid provider in auth: {provider}", err=True) 329 + raise typer.Exit(1) 330 + if mode not in ("platform", "api_key"): 331 + typer.echo( 332 + f"Invalid auth mode: {mode}. Must be 'platform' or 'api_key'.", 333 + err=True, 334 + ) 335 + raise typer.Exit(1) 336 + 337 + config = _get_config() 338 + config.setdefault("providers", {}) 339 + config["providers"].setdefault("auth", {}) 340 + config["providers"]["auth"][provider] = mode 341 + _write_config(config) 342 + typer.echo(json.dumps({provider: mode}, indent=2)) 343 + 344 + 345 + @google_backend_app.command("show") 346 + def google_backend_show() -> None: 347 + """Show Google backend status.""" 348 + config = _get_config() 349 + providers_config = config.get("providers", {}) 350 + google_backend = providers_config.get("google_backend", "auto") 351 + vertex_creds_path = providers_config.get("vertex_credentials") 352 + vertex_configured = False 353 + vertex_email = "" 354 + if vertex_creds_path and Path(vertex_creds_path).exists(): 355 + vertex_configured = True 356 + try: 357 + creds_data = json.loads(Path(vertex_creds_path).read_text()) 358 + vertex_email = creds_data.get("client_email", "") 359 + except Exception: 360 + pass 361 + result = { 362 + "google_backend": google_backend, 363 + "vertex_credentials_configured": vertex_configured, 364 + "vertex_credentials_email": vertex_email, 365 + } 366 + typer.echo(json.dumps(result, indent=2)) 367 + 368 + 369 + @google_backend_app.command("set") 370 + def google_backend_set( 371 + backend: str = typer.Argument(..., help="Google backend to use."), 372 + ) -> None: 373 + """Set the Google provider backend.""" 374 + if backend not in ("auto", "aistudio", "vertex"): 375 + typer.echo( 376 + f"Invalid google_backend: {backend}. Must be 'auto', 'aistudio', or 'vertex'.", 377 + err=True, 378 + ) 379 + raise typer.Exit(1) 380 + 381 + config = _get_config() 382 + config.setdefault("providers", {}) 383 + config["providers"]["google_backend"] = backend 384 + _write_config(config) 385 + typer.echo(json.dumps({"google_backend": backend}, indent=2)) 386 + 387 + 388 + @vertex_app.command("show") 389 + def vertex_credentials_show() -> None: 390 + """Show Vertex credential status without secrets.""" 391 + config = _get_config() 392 + providers_config = config.get("providers", {}) 393 + vertex_creds_path = providers_config.get("vertex_credentials") 394 + configured = False 395 + email = "" 396 + if vertex_creds_path and Path(vertex_creds_path).exists(): 397 + configured = True 398 + try: 399 + creds_data = json.loads(Path(vertex_creds_path).read_text()) 400 + email = creds_data.get("client_email", "") 401 + except Exception: 402 + pass 403 + validation = providers_config.get("key_validation", {}).get("google_vertex", {}) 404 + result = { 405 + "configured": configured, 406 + "email": email, 407 + "path": vertex_creds_path or "", 408 + "validation": validation, 409 + } 410 + typer.echo(json.dumps(result, indent=2)) 411 + 412 + 413 + @vertex_app.command("import") 414 + def vertex_credentials_import( 415 + file_path: str = typer.Argument(..., help="Path to service account JSON."), 416 + skip_validation: bool = typer.Option( 417 + False, "--skip-validation", help="Skip API validation of credentials." 418 + ), 419 + ) -> None: 420 + """Import Vertex service account credentials into the journal config.""" 421 + from think.providers.google import validate_vertex_credentials 422 + from think.utils import get_journal 423 + 424 + source = Path(file_path) 425 + if not source.exists(): 426 + typer.echo(f"Credential file not found: {file_path}", err=True) 427 + raise typer.Exit(1) 428 + 429 + try: 430 + creds_data = json.loads(source.read_text(encoding="utf-8")) 431 + except json.JSONDecodeError: 432 + typer.echo(f"Invalid JSON in credential file: {file_path}", err=True) 433 + raise typer.Exit(1) 434 + 435 + required_fields = ("type", "project_id", "client_email", "private_key") 436 + missing = [field for field in required_fields if field not in creds_data] 437 + if missing: 438 + typer.echo(f"Missing required fields: {', '.join(missing)}", err=True) 439 + raise typer.Exit(1) 440 + 441 + journal_root = Path(get_journal()) 442 + creds_dir = journal_root / ".config" 443 + creds_dir.mkdir(parents=True, exist_ok=True) 444 + creds_file = creds_dir / "vertex-credentials.json" 445 + 446 + with open(creds_file, "w", encoding="utf-8") as f: 447 + json.dump(creds_data, f, indent=2, ensure_ascii=False) 448 + f.write("\n") 449 + os.chmod(creds_file, 0o600) 450 + 451 + config = _get_config() 452 + config.setdefault("providers", {}) 453 + config["providers"]["vertex_credentials"] = str(creds_file) 454 + 455 + validation = None 456 + if not skip_validation: 457 + validation = validate_vertex_credentials(str(creds_file)) 458 + validation["timestamp"] = datetime.now(timezone.utc).isoformat() 459 + config["providers"].setdefault("key_validation", {}) 460 + config["providers"]["key_validation"]["google_vertex"] = validation 461 + 462 + _write_config(config) 463 + typer.echo( 464 + json.dumps( 465 + { 466 + "configured": True, 467 + "email": creds_data.get("client_email", ""), 468 + "path": str(creds_file), 469 + "validation": validation, 470 + }, 471 + indent=2, 472 + ) 473 + ) 474 + 475 + 476 + @vertex_app.command("clear") 477 + def vertex_credentials_clear() -> None: 478 + """Clear stored Vertex credentials.""" 479 + from think.utils import get_journal 480 + 481 + config = _get_config() 482 + config.setdefault("providers", {}) 483 + old_path = config["providers"].get("vertex_credentials") 484 + if old_path: 485 + canonical = Path(get_journal()) / ".config" / "vertex-credentials.json" 486 + if Path(old_path).resolve() == canonical.resolve(): 487 + try: 488 + canonical.unlink(missing_ok=True) 489 + except OSError: 490 + pass 491 + config["providers"].pop("vertex_credentials", None) 492 + config["providers"].setdefault("key_validation", {}) 493 + config["providers"]["key_validation"].pop("google_vertex", None) 494 + 495 + _write_config(config) 496 + typer.echo(json.dumps({"configured": False}, indent=2)) 497 + 498 + 499 + @transcribe_app.command("show") 500 + def transcribe_show() -> None: 501 + """Show transcription backend configuration.""" 502 + from observe.transcribe import get_backend_list 503 + 504 + config = _get_config() 505 + transcribe_config = config.get("transcribe", {}) 506 + backends = get_backend_list() 507 + api_keys = {} 508 + for backend in backends: 509 + env_key = backend.get("env_key") 510 + if env_key: 511 + api_keys[backend["name"]] = bool(os.getenv(env_key)) 512 + else: 513 + api_keys[backend["name"]] = True 514 + result = {"backends": backends, "api_keys": api_keys, "config": transcribe_config} 515 + typer.echo(json.dumps(result, indent=2)) 516 + 517 + 518 + @transcribe_app.command("set-backend") 519 + def transcribe_set_backend( 520 + backend: str = typer.Argument(..., help="Transcription backend."), 521 + ) -> None: 522 + """Set the transcription backend.""" 523 + from observe.transcribe import BACKEND_REGISTRY 524 + 525 + if backend not in BACKEND_REGISTRY: 526 + typer.echo( 527 + f"Invalid backend: {backend}. Must be one of: {', '.join(sorted(BACKEND_REGISTRY.keys()))}", 528 + err=True, 529 + ) 530 + raise typer.Exit(1) 531 + 532 + config = _get_config() 533 + config.setdefault("transcribe", {}) 534 + config["transcribe"]["backend"] = backend 535 + _write_config(config) 536 + typer.echo(json.dumps(config["transcribe"], indent=2)) 537 + 538 + 539 + @transcribe_app.command("set") 540 + def transcribe_set( 541 + enrich: bool | None = typer.Option(None, "--enrich/--no-enrich"), 542 + noise_upgrade: bool | None = typer.Option( 543 + None, "--noise-upgrade/--no-noise-upgrade" 544 + ), 545 + ) -> None: 546 + """Set transcription options.""" 547 + config = _get_config() 548 + config.setdefault("transcribe", {}) 549 + if enrich is not None: 550 + config["transcribe"]["enrich"] = enrich 551 + if noise_upgrade is not None: 552 + config["transcribe"]["noise_upgrade"] = noise_upgrade 553 + _write_config(config) 554 + typer.echo(json.dumps(config["transcribe"], indent=2)) 555 + 556 + 557 + @identity_app.command("show") 558 + def identity_show() -> None: 559 + """Show journal identity config.""" 560 + config = _get_config() 561 + identity = config.get("identity", {}) 562 + typer.echo(json.dumps(identity, indent=2)) 563 + 564 + 565 + @identity_app.command("set") 566 + def identity_set( 567 + name: str | None = typer.Option(None, "--name"), 568 + preferred: str | None = typer.Option(None, "--preferred"), 569 + bio: str | None = typer.Option(None, "--bio"), 570 + timezone_name: str | None = typer.Option(None, "--timezone"), 571 + pronouns: str | None = typer.Option(None, "--pronouns"), 572 + add_email: str | None = typer.Option(None, "--add-email"), 573 + remove_email: str | None = typer.Option(None, "--remove-email"), 574 + add_alias: str | None = typer.Option(None, "--add-alias"), 575 + remove_alias: str | None = typer.Option(None, "--remove-alias"), 576 + ) -> None: 577 + """Update journal owner identity.""" 578 + config = _get_config() 579 + config.setdefault("identity", {}) 580 + identity = config["identity"] 581 + 582 + if name is not None: 583 + identity["name"] = name 584 + if preferred is not None: 585 + identity["preferred"] = preferred 586 + if bio is not None: 587 + identity["bio"] = bio 588 + if timezone_name is not None: 589 + identity["timezone"] = timezone_name 590 + 591 + if pronouns is not None: 592 + try: 593 + identity["pronouns"] = json.loads(pronouns) 594 + except json.JSONDecodeError: 595 + typer.echo("Invalid JSON in pronouns", err=True) 596 + raise typer.Exit(1) 597 + 598 + if add_email is not None or remove_email is not None: 599 + emails = list(identity.get("email_addresses", [])) 600 + if add_email is not None and add_email not in emails: 601 + emails.append(add_email) 602 + if remove_email is not None: 603 + emails = [email for email in emails if email != remove_email] 604 + identity["email_addresses"] = emails 605 + 606 + if add_alias is not None or remove_alias is not None: 607 + aliases = list(identity.get("aliases", [])) 608 + if add_alias is not None and add_alias not in aliases: 609 + aliases.append(add_alias) 610 + if remove_alias is not None: 611 + aliases = [alias for alias in aliases if alias != remove_alias] 612 + identity["aliases"] = aliases 613 + 614 + _write_config(config) 615 + project_root = Path(__file__).resolve().parent.parent.parent 616 + subprocess.run( 617 + ["make", "skills"], cwd=project_root, check=False, capture_output=True 618 + ) 619 + typer.echo(json.dumps(identity, indent=2)) 620 + 621 + 622 + @observer_app.command("show") 623 + def observer_show() -> None: 624 + """Show observer configuration with defaults.""" 625 + from apps.settings.routes import OBSERVE_TMUX_DEFAULTS 626 + 627 + config = _get_config() 628 + observe_config = config.get("observe", {}) 629 + tmux_config = observe_config.get("tmux", {}) 630 + result = { 631 + "tmux": { 632 + "enabled": tmux_config.get("enabled", OBSERVE_TMUX_DEFAULTS["enabled"]), 633 + "capture_interval": tmux_config.get( 634 + "capture_interval", OBSERVE_TMUX_DEFAULTS["capture_interval"] 635 + ), 636 + }, 637 + "defaults": {"tmux": OBSERVE_TMUX_DEFAULTS}, 638 + } 639 + typer.echo(json.dumps(result, indent=2)) 640 + 641 + 642 + @observer_app.command("set") 643 + def observer_set( 644 + enabled: bool | None = typer.Option(None, "--enabled/--no-enabled"), 645 + capture_interval: int | None = typer.Option(None, "--capture-interval"), 646 + ) -> None: 647 + """Update observer capture settings.""" 648 + from apps.settings.routes import OBSERVE_TMUX_DEFAULTS 649 + 650 + config = _get_config() 651 + config.setdefault("observe", {}) 652 + config["observe"].setdefault("tmux", {}) 653 + 654 + if capture_interval is not None: 655 + min_val = OBSERVE_TMUX_DEFAULTS["capture_interval_min"] 656 + max_val = OBSERVE_TMUX_DEFAULTS["capture_interval_max"] 657 + if capture_interval < min_val or capture_interval > max_val: 658 + typer.echo( 659 + f"tmux.capture_interval must be an integer between {min_val} and {max_val}", 660 + err=True, 661 + ) 662 + raise typer.Exit(1) 663 + config["observe"]["tmux"]["capture_interval"] = capture_interval 664 + 665 + if enabled is not None: 666 + config["observe"]["tmux"]["enabled"] = enabled 667 + 668 + _write_config(config) 669 + typer.echo(json.dumps(config["observe"]["tmux"], indent=2))
+1
apps/settings/tests/__init__.py
··· 1 +
+71
apps/settings/tests/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Self-contained fixtures for settings app tests.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + 10 + import pytest 11 + 12 + 13 + @pytest.fixture 14 + def settings_env(tmp_path, monkeypatch): 15 + """Create a temporary journal with settings config.""" 16 + 17 + def _create(config: dict | None = None): 18 + config_dir = tmp_path / "config" 19 + config_dir.mkdir(parents=True, exist_ok=True) 20 + config_path = config_dir / "journal.json" 21 + if config is None: 22 + config = { 23 + "identity": { 24 + "name": "Test User", 25 + "preferred": "Tester", 26 + "bio": "A test user", 27 + "pronouns": { 28 + "subject": "they", 29 + "object": "them", 30 + "possessive": "their", 31 + "reflexive": "themselves", 32 + }, 33 + "aliases": ["tester"], 34 + "email_addresses": ["test@example.com"], 35 + "timezone": "UTC", 36 + }, 37 + "env": { 38 + "GOOGLE_API_KEY": "test-google-key", 39 + "OPENAI_API_KEY": "test-openai-key", 40 + }, 41 + "providers": { 42 + "generate": { 43 + "provider": "google", 44 + "tier": 2, 45 + "backup": "anthropic", 46 + }, 47 + "cogitate": { 48 + "provider": "openai", 49 + "tier": 2, 50 + "backup": "anthropic", 51 + }, 52 + "auth": { 53 + "google": "api_key", 54 + "openai": "api_key", 55 + "anthropic": "platform", 56 + }, 57 + "google_backend": "auto", 58 + "key_validation": {}, 59 + }, 60 + "transcribe": { 61 + "backend": "whisper", 62 + "enrich": True, 63 + "noise_upgrade": False, 64 + }, 65 + "observe": {"tmux": {"enabled": True, "capture_interval": 5}}, 66 + } 67 + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") 68 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 69 + return tmp_path, config 70 + 71 + return _create
+406
apps/settings/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for settings CLI commands (``sol call settings ...``).""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import os 10 + from unittest.mock import patch 11 + 12 + from typer.testing import CliRunner 13 + 14 + from think.call import call_app 15 + 16 + runner = CliRunner() 17 + 18 + 19 + class TestShow: 20 + def test_show(self, settings_env): 21 + settings_env() 22 + 23 + result = runner.invoke(call_app, ["settings", "show"]) 24 + 25 + assert result.exit_code == 0 26 + payload = json.loads(result.output) 27 + assert "identity" in payload 28 + assert "keys" in payload 29 + assert "providers" in payload 30 + assert "transcribe" in payload 31 + assert "observe" in payload 32 + 33 + 34 + class TestKeysShow: 35 + def test_keys_show(self, settings_env): 36 + settings_env() 37 + 38 + result = runner.invoke(call_app, ["settings", "keys", "show"]) 39 + 40 + assert result.exit_code == 0 41 + payload = json.loads(result.output) 42 + assert payload["GOOGLE_API_KEY"] is True 43 + assert payload["ANTHROPIC_API_KEY"] is False 44 + 45 + 46 + class TestKeysSet: 47 + def test_keys_set(self, settings_env): 48 + tmp_path, _config = settings_env() 49 + 50 + with ( 51 + patch.dict(os.environ, {}, clear=False), 52 + patch("think.providers.validate_key", return_value={"valid": True}), 53 + ): 54 + result = runner.invoke( 55 + call_app, 56 + ["settings", "keys", "set", "ANTHROPIC_API_KEY", "test-key"], 57 + ) 58 + 59 + assert result.exit_code == 0 60 + payload = json.loads(result.output) 61 + assert payload["set"] is True 62 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 63 + assert saved["env"]["ANTHROPIC_API_KEY"] == "test-key" 64 + assert saved["providers"]["auth"]["anthropic"] == "api_key" 65 + 66 + def test_keys_set_invalid_var(self, settings_env): 67 + settings_env() 68 + 69 + result = runner.invoke( 70 + call_app, ["settings", "keys", "set", "BAD_KEY", "value"] 71 + ) 72 + 73 + assert result.exit_code == 1 74 + 75 + 76 + class TestKeysClear: 77 + def test_keys_clear(self, settings_env): 78 + tmp_path, _config = settings_env() 79 + 80 + result = runner.invoke(call_app, ["settings", "keys", "clear", "GOOGLE_API_KEY"]) 81 + 82 + assert result.exit_code == 0 83 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 84 + assert "GOOGLE_API_KEY" not in saved["env"] 85 + assert saved["providers"]["auth"]["google"] == "platform" 86 + 87 + 88 + class TestProvidersShow: 89 + def test_providers_show(self, settings_env): 90 + settings_env() 91 + 92 + result = runner.invoke(call_app, ["settings", "providers", "show"]) 93 + 94 + assert result.exit_code == 0 95 + payload = json.loads(result.output) 96 + assert payload["generate"]["provider"] == "google" 97 + assert payload["cogitate"]["provider"] == "openai" 98 + 99 + 100 + class TestProvidersSetGenerate: 101 + def test_set_generate_provider(self, settings_env): 102 + tmp_path, _config = settings_env() 103 + 104 + result = runner.invoke( 105 + call_app, 106 + ["settings", "providers", "set-generate", "--provider", "openai"], 107 + ) 108 + 109 + assert result.exit_code == 0 110 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 111 + assert saved["providers"]["generate"]["provider"] == "openai" 112 + 113 + def test_set_generate_invalid_provider(self, settings_env): 114 + settings_env() 115 + 116 + result = runner.invoke( 117 + call_app, 118 + ["settings", "providers", "set-generate", "--provider", "invalid"], 119 + ) 120 + 121 + assert result.exit_code == 1 122 + 123 + def test_set_generate_invalid_tier(self, settings_env): 124 + settings_env() 125 + 126 + result = runner.invoke( 127 + call_app, 128 + ["settings", "providers", "set-generate", "--tier", "5"], 129 + ) 130 + 131 + assert result.exit_code == 1 132 + 133 + 134 + class TestGoogleBackend: 135 + def test_show(self, settings_env): 136 + settings_env() 137 + 138 + result = runner.invoke(call_app, ["settings", "google-backend", "show"]) 139 + 140 + assert result.exit_code == 0 141 + payload = json.loads(result.output) 142 + assert payload["google_backend"] == "auto" 143 + 144 + def test_set(self, settings_env): 145 + tmp_path, _config = settings_env() 146 + 147 + result = runner.invoke( 148 + call_app, ["settings", "google-backend", "set", "vertex"] 149 + ) 150 + 151 + assert result.exit_code == 0 152 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 153 + assert saved["providers"]["google_backend"] == "vertex" 154 + 155 + def test_set_invalid(self, settings_env): 156 + settings_env() 157 + 158 + result = runner.invoke( 159 + call_app, ["settings", "google-backend", "set", "invalid"] 160 + ) 161 + 162 + assert result.exit_code == 1 163 + 164 + 165 + class TestVertexCredentials: 166 + def test_import(self, settings_env, tmp_path): 167 + journal_path, _config = settings_env() 168 + creds_path = tmp_path / "creds.json" 169 + creds_path.write_text( 170 + json.dumps( 171 + { 172 + "type": "service_account", 173 + "project_id": "test-project", 174 + "client_email": "test@test.iam.gserviceaccount.com", 175 + "private_key": "private-key", 176 + } 177 + ), 178 + encoding="utf-8", 179 + ) 180 + 181 + with patch( 182 + "think.providers.google.validate_vertex_credentials", 183 + return_value={ 184 + "valid": True, 185 + "email": "test@test.iam.gserviceaccount.com", 186 + }, 187 + ): 188 + result = runner.invoke( 189 + call_app, 190 + ["settings", "vertex-credentials", "import", str(creds_path)], 191 + ) 192 + 193 + assert result.exit_code == 0 194 + canonical = journal_path / ".config" / "vertex-credentials.json" 195 + assert canonical.exists() 196 + saved = json.loads((journal_path / "config" / "journal.json").read_text()) 197 + assert saved["providers"]["vertex_credentials"] == str(canonical) 198 + 199 + def test_import_missing_fields(self, settings_env, tmp_path): 200 + settings_env() 201 + creds_path = tmp_path / "creds.json" 202 + creds_path.write_text(json.dumps({"type": "service_account"}), encoding="utf-8") 203 + 204 + result = runner.invoke( 205 + call_app, 206 + ["settings", "vertex-credentials", "import", str(creds_path)], 207 + ) 208 + 209 + assert result.exit_code == 1 210 + 211 + def test_import_skip_validation(self, settings_env, tmp_path): 212 + journal_path, _config = settings_env() 213 + creds_path = tmp_path / "creds.json" 214 + creds_path.write_text( 215 + json.dumps( 216 + { 217 + "type": "service_account", 218 + "project_id": "test-project", 219 + "client_email": "test@test.iam.gserviceaccount.com", 220 + "private_key": "private-key", 221 + } 222 + ), 223 + encoding="utf-8", 224 + ) 225 + 226 + with patch("think.providers.google.validate_vertex_credentials") as mock_validate: 227 + result = runner.invoke( 228 + call_app, 229 + [ 230 + "settings", 231 + "vertex-credentials", 232 + "import", 233 + str(creds_path), 234 + "--skip-validation", 235 + ], 236 + ) 237 + 238 + assert result.exit_code == 0 239 + assert mock_validate.call_count == 0 240 + assert (journal_path / ".config" / "vertex-credentials.json").exists() 241 + 242 + def test_clear(self, settings_env): 243 + journal_path, config = settings_env() 244 + creds_dir = journal_path / ".config" 245 + creds_dir.mkdir(parents=True, exist_ok=True) 246 + creds_file = creds_dir / "vertex-credentials.json" 247 + creds_file.write_text( 248 + json.dumps( 249 + { 250 + "type": "service_account", 251 + "project_id": "test-project", 252 + "client_email": "test@test.iam.gserviceaccount.com", 253 + "private_key": "private-key", 254 + } 255 + ), 256 + encoding="utf-8", 257 + ) 258 + 259 + config_path = journal_path / "config" / "journal.json" 260 + saved = json.loads(config_path.read_text()) 261 + saved["providers"]["vertex_credentials"] = str(creds_file) 262 + saved["providers"]["key_validation"]["google_vertex"] = {"valid": True} 263 + config_path.write_text(json.dumps(saved, indent=2) + "\n", encoding="utf-8") 264 + 265 + result = runner.invoke(call_app, ["settings", "vertex-credentials", "clear"]) 266 + 267 + assert result.exit_code == 0 268 + assert not creds_file.exists() 269 + updated = json.loads(config_path.read_text()) 270 + assert "vertex_credentials" not in updated["providers"] 271 + assert "google_vertex" not in updated["providers"]["key_validation"] 272 + 273 + def test_show(self, settings_env): 274 + settings_env() 275 + 276 + result = runner.invoke(call_app, ["settings", "vertex-credentials", "show"]) 277 + 278 + assert result.exit_code == 0 279 + payload = json.loads(result.output) 280 + assert payload["configured"] is False 281 + 282 + 283 + class TestTranscribe: 284 + def test_show(self, settings_env): 285 + settings_env() 286 + 287 + result = runner.invoke(call_app, ["settings", "transcribe", "show"]) 288 + 289 + assert result.exit_code == 0 290 + payload = json.loads(result.output) 291 + assert payload["backends"] 292 + 293 + def test_set_backend(self, settings_env): 294 + tmp_path, _config = settings_env() 295 + 296 + result = runner.invoke( 297 + call_app, ["settings", "transcribe", "set-backend", "gemini"] 298 + ) 299 + 300 + assert result.exit_code == 0 301 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 302 + assert saved["transcribe"]["backend"] == "gemini" 303 + 304 + def test_set_backend_invalid(self, settings_env): 305 + settings_env() 306 + 307 + result = runner.invoke( 308 + call_app, ["settings", "transcribe", "set-backend", "invalid"] 309 + ) 310 + 311 + assert result.exit_code == 1 312 + 313 + 314 + class TestIdentity: 315 + def test_show(self, settings_env): 316 + settings_env() 317 + 318 + result = runner.invoke(call_app, ["settings", "identity", "show"]) 319 + 320 + assert result.exit_code == 0 321 + payload = json.loads(result.output) 322 + assert payload["name"] == "Test User" 323 + 324 + def test_set_name(self, settings_env): 325 + tmp_path, _config = settings_env() 326 + 327 + with patch("apps.settings.call.subprocess.run") as mock_run: 328 + result = runner.invoke( 329 + call_app, ["settings", "identity", "set", "--name", "New Name"] 330 + ) 331 + 332 + assert result.exit_code == 0 333 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 334 + assert saved["identity"]["name"] == "New Name" 335 + assert mock_run.call_count == 1 336 + 337 + def test_set_add_email(self, settings_env): 338 + tmp_path, _config = settings_env() 339 + 340 + with patch("apps.settings.call.subprocess.run"): 341 + result = runner.invoke( 342 + call_app, 343 + ["settings", "identity", "set", "--add-email", "new@example.com"], 344 + ) 345 + 346 + assert result.exit_code == 0 347 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 348 + assert "new@example.com" in saved["identity"]["email_addresses"] 349 + 350 + def test_set_remove_email(self, settings_env): 351 + tmp_path, _config = settings_env() 352 + 353 + with patch("apps.settings.call.subprocess.run"): 354 + result = runner.invoke( 355 + call_app, 356 + ["settings", "identity", "set", "--remove-email", "test@example.com"], 357 + ) 358 + 359 + assert result.exit_code == 0 360 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 361 + assert "test@example.com" not in saved["identity"]["email_addresses"] 362 + 363 + 364 + class TestObserver: 365 + def test_show(self, settings_env): 366 + settings_env() 367 + 368 + result = runner.invoke(call_app, ["settings", "observer", "show"]) 369 + 370 + assert result.exit_code == 0 371 + payload = json.loads(result.output) 372 + assert payload["tmux"]["enabled"] is True 373 + assert payload["tmux"]["capture_interval"] == 5 374 + 375 + def test_set_enabled(self, settings_env): 376 + tmp_path, _config = settings_env() 377 + 378 + result = runner.invoke( 379 + call_app, ["settings", "observer", "set", "--no-enabled"] 380 + ) 381 + 382 + assert result.exit_code == 0 383 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 384 + assert saved["observe"]["tmux"]["enabled"] is False 385 + 386 + def test_set_capture_interval(self, settings_env): 387 + tmp_path, _config = settings_env() 388 + 389 + result = runner.invoke( 390 + call_app, 391 + ["settings", "observer", "set", "--capture-interval", "10"], 392 + ) 393 + 394 + assert result.exit_code == 0 395 + saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 396 + assert saved["observe"]["tmux"]["capture_interval"] == 10 397 + 398 + def test_set_capture_interval_invalid(self, settings_env): 399 + settings_env() 400 + 401 + result = runner.invoke( 402 + call_app, 403 + ["settings", "observer", "set", "--capture-interval", "100"], 404 + ) 405 + 406 + assert result.exit_code == 1