feat: add per-project .env config support

Add per-project configuration via .claude/last30days.env, discovered by
walking up from cwd. Uses the same .env format as the existing global
config — no new parsers or formats.

Priority (highest wins):
  1. Environment variables
  2. .claude/last30days.env (per-project)
  3. ~/.config/last30days/.env (global)

Also adds file permission checking — warns to stderr if config files
are readable by other users (should be chmod 600).

Includes tests for discovery, precedence, source tracking, and
permission warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
P
2026-03-09 16:48:23 -04:00
parent 627947fc2c
commit 3e3615e2c8
2 changed files with 244 additions and 8 deletions
+67 -8
View File
@@ -47,11 +47,28 @@ class OpenAIAuth:
codex_auth_file: str
def _check_file_permissions(path: Path) -> None:
"""Warn to stderr if a secrets file has overly permissive permissions."""
try:
mode = path.stat().st_mode
# Check if group or other can read (bits 0o044)
if mode & 0o044:
import sys
sys.stderr.write(
f"[last30days] WARNING: {path} is readable by other users. "
f"Run: chmod 600 {path}\n"
)
sys.stderr.flush()
except OSError:
pass
def load_env_file(path: Path) -> Dict[str, str]:
"""Load environment variables from a file."""
env = {}
if not path.exists():
if not path or not path.exists():
return env
_check_file_permissions(path)
with open(path, 'r') as f:
for line in f:
@@ -178,14 +195,44 @@ def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
)
def _find_project_env() -> Optional[Path]:
"""Find per-project .env by walking up from cwd.
Searches for .claude/last30days.env in each parent directory,
stopping at the user's home directory or filesystem root.
"""
cwd = Path.cwd()
for parent in [cwd, *cwd.parents]:
candidate = parent / '.claude' / 'last30days.env'
if candidate.exists():
return candidate
# Stop at filesystem root or home
if parent == Path.home() or parent == parent.parent:
break
return None
def get_config() -> Dict[str, Any]:
"""Load configuration from ~/.config/last30days/.env and environment."""
# Load from config file first (if configured)
"""Load configuration from multiple sources.
Priority (highest wins):
1. Environment variables (os.environ)
2. .claude/last30days.env (per-project config)
3. ~/.config/last30days/.env (global config)
"""
# Load from global config file
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
openai_auth = get_openai_auth(file_env)
# Load from per-project config (overrides global)
project_env_path = _find_project_env()
project_env = load_env_file(project_env_path) if project_env_path else {}
# Build config: Codex/OpenAI auth + process.env > .env file
# Merge: project overrides global
merged_env = {**file_env, **project_env}
openai_auth = get_openai_auth(merged_env)
# Build config: Codex/OpenAI auth + process.env > project .env > global .env
config = {
'OPENAI_API_KEY': openai_auth.token,
'OPENAI_AUTH_SOURCE': openai_auth.source,
@@ -211,14 +258,26 @@ def get_config() -> Dict[str, Any]:
]
for key, default in keys:
config[key] = os.environ.get(key) or file_env.get(key, default)
config[key] = os.environ.get(key) or merged_env.get(key, default)
# Track which config source was used
if project_env_path:
config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
elif CONFIG_FILE and CONFIG_FILE.exists():
config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}'
else:
config['_CONFIG_SOURCE'] = 'env_only'
return config
def config_exists() -> bool:
"""Check if configuration file exists."""
return CONFIG_FILE.exists()
"""Check if any configuration source exists."""
if _find_project_env():
return True
if CONFIG_FILE:
return CONFIG_FILE.exists()
return False
def is_reddit_available(config: Dict[str, Any]) -> bool: