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:
+67
-8
@@ -47,11 +47,28 @@ class OpenAIAuth:
|
|||||||
codex_auth_file: str
|
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]:
|
def load_env_file(path: Path) -> Dict[str, str]:
|
||||||
"""Load environment variables from a file."""
|
"""Load environment variables from a file."""
|
||||||
env = {}
|
env = {}
|
||||||
if not path.exists():
|
if not path or not path.exists():
|
||||||
return env
|
return env
|
||||||
|
_check_file_permissions(path)
|
||||||
|
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
for line in 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]:
|
def get_config() -> Dict[str, Any]:
|
||||||
"""Load configuration from ~/.config/last30days/.env and environment."""
|
"""Load configuration from multiple sources.
|
||||||
# Load from config file first (if configured)
|
|
||||||
|
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 {}
|
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 = {
|
config = {
|
||||||
'OPENAI_API_KEY': openai_auth.token,
|
'OPENAI_API_KEY': openai_auth.token,
|
||||||
'OPENAI_AUTH_SOURCE': openai_auth.source,
|
'OPENAI_AUTH_SOURCE': openai_auth.source,
|
||||||
@@ -211,14 +258,26 @@ def get_config() -> Dict[str, Any]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for key, default in keys:
|
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
|
return config
|
||||||
|
|
||||||
|
|
||||||
def config_exists() -> bool:
|
def config_exists() -> bool:
|
||||||
"""Check if configuration file exists."""
|
"""Check if any configuration source exists."""
|
||||||
return CONFIG_FILE.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:
|
def is_reddit_available(config: Dict[str, Any]) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Tests for per-project .env config discovery and precedence."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Add lib to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from lib import env
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindProjectEnv(unittest.TestCase):
|
||||||
|
"""Tests for _find_project_env() directory walking."""
|
||||||
|
|
||||||
|
def test_finds_env_in_cwd(self, ):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
claude_dir = Path(tmpdir) / ".claude"
|
||||||
|
claude_dir.mkdir()
|
||||||
|
env_file = claude_dir / "last30days.env"
|
||||||
|
env_file.write_text("OPENAI_API_KEY=sk-test\n")
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
|
||||||
|
result = env._find_project_env()
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result.name, "last30days.env")
|
||||||
|
|
||||||
|
def test_returns_none_when_no_config(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
|
||||||
|
result = env._find_project_env()
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigPrecedence(unittest.TestCase):
|
||||||
|
"""Tests for config source priority."""
|
||||||
|
|
||||||
|
def test_project_overrides_global(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create global config
|
||||||
|
global_dir = Path(tmpdir) / "global"
|
||||||
|
global_dir.mkdir()
|
||||||
|
global_env = global_dir / ".env"
|
||||||
|
global_env.write_text("BRAVE_API_KEY=global-key\nPARALLEL_API_KEY=global-only\n")
|
||||||
|
|
||||||
|
# Create project config
|
||||||
|
project_dir = Path(tmpdir) / "project" / ".claude"
|
||||||
|
project_dir.mkdir(parents=True)
|
||||||
|
project_env = project_dir / "last30days.env"
|
||||||
|
project_env.write_text("BRAVE_API_KEY=project-key\n")
|
||||||
|
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir) / "project"), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', global_env), \
|
||||||
|
patch.dict(os.environ, {}, clear=False):
|
||||||
|
# Remove any env vars that would override
|
||||||
|
for k in ('BRAVE_API_KEY', 'PARALLEL_API_KEY', 'OPENAI_API_KEY'):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
config = env.get_config()
|
||||||
|
# Project value wins over global
|
||||||
|
self.assertEqual(config['BRAVE_API_KEY'], 'project-key')
|
||||||
|
# Global value still available for keys not in project
|
||||||
|
self.assertEqual(config['PARALLEL_API_KEY'], 'global-only')
|
||||||
|
|
||||||
|
def test_env_var_overrides_project(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
project_dir = Path(tmpdir) / ".claude"
|
||||||
|
project_dir.mkdir()
|
||||||
|
project_env = project_dir / "last30days.env"
|
||||||
|
project_env.write_text("BRAVE_API_KEY=project-key\n")
|
||||||
|
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', None), \
|
||||||
|
patch.dict(os.environ, {'BRAVE_API_KEY': 'env-key'}):
|
||||||
|
config = env.get_config()
|
||||||
|
self.assertEqual(config['BRAVE_API_KEY'], 'env-key')
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSource(unittest.TestCase):
|
||||||
|
"""Tests for _CONFIG_SOURCE tracking."""
|
||||||
|
|
||||||
|
def test_tracks_project_source(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
project_dir = Path(tmpdir) / ".claude"
|
||||||
|
project_dir.mkdir()
|
||||||
|
project_env = project_dir / "last30days.env"
|
||||||
|
project_env.write_text("BRAVE_API_KEY=test\n")
|
||||||
|
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', None):
|
||||||
|
config = env.get_config()
|
||||||
|
self.assertIn('project:', config['_CONFIG_SOURCE'])
|
||||||
|
|
||||||
|
def test_tracks_global_source(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
global_env = Path(tmpdir) / ".env"
|
||||||
|
global_env.write_text("BRAVE_API_KEY=test\n")
|
||||||
|
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', global_env):
|
||||||
|
config = env.get_config()
|
||||||
|
self.assertIn('global:', config['_CONFIG_SOURCE'])
|
||||||
|
|
||||||
|
def test_tracks_env_only(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', None):
|
||||||
|
config = env.get_config()
|
||||||
|
self.assertEqual(config['_CONFIG_SOURCE'], 'env_only')
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigExists(unittest.TestCase):
|
||||||
|
"""Tests for config_exists() with project support."""
|
||||||
|
|
||||||
|
def test_true_with_project_env(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
project_dir = Path(tmpdir) / ".claude"
|
||||||
|
project_dir.mkdir()
|
||||||
|
(project_dir / "last30days.env").write_text("KEY=val\n")
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
|
||||||
|
self.assertTrue(env.config_exists())
|
||||||
|
|
||||||
|
def test_true_with_global_env(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
global_env = Path(tmpdir) / ".env"
|
||||||
|
global_env.write_text("KEY=val\n")
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', global_env):
|
||||||
|
self.assertTrue(env.config_exists())
|
||||||
|
|
||||||
|
def test_false_with_nothing(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||||
|
patch.object(env, 'CONFIG_FILE', None):
|
||||||
|
self.assertFalse(env.config_exists())
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilePermissions(unittest.TestCase):
|
||||||
|
"""Tests for _check_file_permissions() warnings."""
|
||||||
|
|
||||||
|
def test_warns_on_world_readable(self):
|
||||||
|
import tempfile
|
||||||
|
import io
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
f = Path(tmpdir) / ".env"
|
||||||
|
f.write_text("KEY=val\n")
|
||||||
|
f.chmod(0o644)
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with patch('sys.stderr', stderr):
|
||||||
|
env._check_file_permissions(f)
|
||||||
|
self.assertIn("WARNING", stderr.getvalue())
|
||||||
|
|
||||||
|
def test_no_warning_on_600(self):
|
||||||
|
import tempfile
|
||||||
|
import io
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
f = Path(tmpdir) / ".env"
|
||||||
|
f.write_text("KEY=val\n")
|
||||||
|
f.chmod(0o600)
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with patch('sys.stderr', stderr):
|
||||||
|
env._check_file_permissions(f)
|
||||||
|
self.assertEqual(stderr.getvalue(), "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user