mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
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:
@@ -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 {}
|
||||
Reference in New Issue
Block a user