feat(config): add persistent API key storage for Gemini

Saves the Gemini API key to ~/.config/contextzip/config.json on Linux/macOS
and %APPDATA%\contextzip\config.json on Windows. Validates keys must start
with AIza before use. Includes diagnose_api_key() for surfacing bad keys early.
This commit is contained in:
akadeepesh
2026-05-24 01:27:27 +05:30
parent fb5632aa73
commit d7da3a802e
+158
View File
@@ -0,0 +1,158 @@
"""
config.py — Persistent configuration storage for contextzip.
Stores user settings (e.g. Gemini API key) in the platform-appropriate
config directory:
Linux/macOS : ~/.config/contextzip/config.json
Windows : C:\\Users\\<user>\\AppData\\Roaming\\contextzip\\config.json
This follows the XDG standard and mirrors what tools like gh, gcloud,
and aws configure use. Never uses temp — config persists across sessions.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
# ---------------------------------------------------------------------------
# Config directory resolution (no platformdirs dependency)
# ---------------------------------------------------------------------------
def _config_dir() -> Path:
"""
Return the platform-appropriate config directory for contextzip.
Mirrors what platformdirs.user_config_dir("contextzip") would return,
but without the extra dependency.
"""
if os.name == "nt":
# Windows: %APPDATA%\\contextzip
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
else:
# Linux / macOS: $XDG_CONFIG_HOME/contextzip or ~/.config/contextzip
xdg = os.environ.get("XDG_CONFIG_HOME", "")
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "contextzip"
_CONFIG_FILE = _config_dir() / "config.json"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_api_key() -> str | None:
"""
Return the stored Gemini API key, or None if not configured.
Checks in order:
1. GEMINI_API_KEY environment variable (must look like a real key)
2. Persisted config file
A key is considered valid only if it starts with "AIza" — this prevents
a garbage or placeholder env var from bypassing the onboarding flow and
causing a cryptic API error downstream.
"""
env_key = os.environ.get("GEMINI_API_KEY", "").strip()
if env_key and env_key.startswith("AIza"):
return env_key
# Env var present but malformed — don't silently use it, fall through
# to config file. If config file also has nothing, onboarding triggers.
try:
data = _read_config()
key = data.get("gemini_api_key", "").strip()
# Same validation as env var — must look like a real Gemini key
return key if (key and key.startswith("AIza")) else None
except Exception:
return None
def save_api_key(key: str) -> None:
"""
Persist *key* to the config file.
Creates the config directory if it doesn't exist.
Raises OSError if the directory or file cannot be written.
"""
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
data = _read_config()
data["gemini_api_key"] = key.strip()
_CONFIG_FILE.write_text(
json.dumps(data, indent=2) + "\n",
encoding="utf-8",
)
def delete_api_key() -> bool:
"""
Remove the stored API key from config.
Returns True if a key was present and removed, False if there was
nothing to remove.
"""
try:
data = _read_config()
if "gemini_api_key" not in data:
return False
del data["gemini_api_key"]
_CONFIG_FILE.write_text(
json.dumps(data, indent=2) + "\n",
encoding="utf-8",
)
return True
except Exception:
return False
def diagnose_api_key() -> str | None:
"""
Return a human-readable explanation if a key exists but is invalid,
or None if everything is fine (or nothing is configured at all).
Used by the CLI to surface "you have a bad key" before onboarding.
"""
env_key = os.environ.get("GEMINI_API_KEY", "").strip()
if env_key and not env_key.startswith("AIza"):
return (
f"GEMINI_API_KEY is set in your environment but doesn't look like "
f"a valid Gemini key (got: {env_key[:12]}…). "
f"Unset it or replace it with a key from https://aistudio.google.com/apikey"
)
try:
data = _read_config()
key = data.get("gemini_api_key", "").strip()
if key and not key.startswith("AIza"):
return (
f"Saved API key in {_CONFIG_FILE} doesn't look valid. "
f"Run [cyan]contextzip config --reset-key[/] to replace it."
)
except Exception:
pass
return None
def config_path() -> Path:
"""Return the path to the config file (may or may not exist yet)."""
return _CONFIG_FILE
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _read_config() -> dict:
"""Read and parse the config file. Returns {} if missing or malformed."""
if not _CONFIG_FILE.is_file():
return {}
try:
return json.loads(_CONFIG_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}