init
This commit is contained in:
341
.opencode/scripts/resolve_env.py
Executable file
341
.opencode/scripts/resolve_env.py
Executable file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Centralized environment variable resolver for Claude Code skills.
|
||||
|
||||
Resolves environment variables following the Claude Code hierarchy:
|
||||
1. process.env - Runtime environment (HIGHEST)
|
||||
2. .opencode/skills/<skill>/.env - Project skill-specific
|
||||
3. .opencode/skills/.env - Project shared
|
||||
4. .opencode/.env - Project global
|
||||
5. ~/.opencode/skills/<skill>/.env - User skill-specific
|
||||
6. ~/.opencode/skills/.env - User shared
|
||||
7. ~/.opencode/.env - User global (LOWEST)
|
||||
|
||||
Usage:
|
||||
from resolve_env import resolve_env
|
||||
|
||||
api_key = resolve_env('GEMINI_API_KEY', skill='ai-multimodal')
|
||||
api_key = resolve_env('GEMINI_API_KEY') # Without skill context
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
def _parse_env_file_fallback(path) -> Dict[str, str]:
|
||||
"""
|
||||
Pure-Python fallback .env parser when python-dotenv is not installed.
|
||||
|
||||
Handles basic .env format:
|
||||
- KEY=value
|
||||
- KEY="quoted value"
|
||||
- KEY='single quoted'
|
||||
- # comments (full line)
|
||||
- Empty lines ignored
|
||||
|
||||
Args:
|
||||
path: Path to .env file (str or Path)
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables
|
||||
"""
|
||||
env_vars = {}
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# Parse KEY=value
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
# Remove surrounding quotes
|
||||
if (value.startswith('"') and value.endswith('"')) or \
|
||||
(value.startswith("'") and value.endswith("'")):
|
||||
value = value[1:-1]
|
||||
env_vars[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
return env_vars
|
||||
|
||||
|
||||
try:
|
||||
from dotenv import dotenv_values
|
||||
except ImportError:
|
||||
# Use fallback parser when python-dotenv not installed
|
||||
dotenv_values = _parse_env_file_fallback
|
||||
|
||||
|
||||
def find_project_root() -> Optional[Path]:
|
||||
"""Find project root by looking for .git or .claude directory."""
|
||||
current = Path.cwd()
|
||||
|
||||
# Check current directory and all parents
|
||||
for directory in [current] + list(current.parents):
|
||||
if (directory / '.git').exists() or (directory / '.claude').exists():
|
||||
return directory
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_env_file_paths(skill: Optional[str] = None) -> List[Tuple[str, Path]]:
|
||||
"""
|
||||
Get all potential .env file paths in priority order.
|
||||
|
||||
Args:
|
||||
skill: Optional skill name for skill-specific configs
|
||||
|
||||
Returns:
|
||||
List of (description, path) tuples in priority order (highest to lowest)
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# Find project root
|
||||
project_root = find_project_root()
|
||||
|
||||
# User home directory
|
||||
home = Path.home()
|
||||
|
||||
# Priority 2-4: Project-level configs (if project root found)
|
||||
if project_root:
|
||||
if skill:
|
||||
paths.append((
|
||||
f"Project skill-specific ({skill})",
|
||||
project_root / '.claude' / 'skills' / skill / '.env'
|
||||
))
|
||||
|
||||
paths.append((
|
||||
"Project skills shared",
|
||||
project_root / '.claude' / 'skills' / '.env'
|
||||
))
|
||||
|
||||
paths.append((
|
||||
"Project global",
|
||||
project_root / '.claude' / '.env'
|
||||
))
|
||||
|
||||
# Priority 5-7: User-level configs
|
||||
if skill:
|
||||
paths.append((
|
||||
f"User skill-specific ({skill})",
|
||||
home / '.claude' / 'skills' / skill / '.env'
|
||||
))
|
||||
|
||||
paths.append((
|
||||
"User skills shared",
|
||||
home / '.claude' / 'skills' / '.env'
|
||||
))
|
||||
|
||||
paths.append((
|
||||
"User global",
|
||||
home / '.claude' / '.env'
|
||||
))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def resolve_env(
|
||||
var_name: str,
|
||||
skill: Optional[str] = None,
|
||||
default: Optional[str] = None,
|
||||
verbose: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve environment variable following Claude Code hierarchy.
|
||||
|
||||
Args:
|
||||
var_name: Name of the environment variable to resolve
|
||||
skill: Optional skill name for skill-specific resolution
|
||||
default: Default value if variable not found anywhere
|
||||
verbose: If True, print resolution details
|
||||
|
||||
Returns:
|
||||
Resolved value or default if not found
|
||||
"""
|
||||
# Priority 1: Check process environment (HIGHEST)
|
||||
value = os.getenv(var_name)
|
||||
if value:
|
||||
if verbose:
|
||||
print(f"✓ {var_name} found in: Runtime environment (process.env)")
|
||||
return value
|
||||
|
||||
if verbose:
|
||||
print(f"✗ {var_name} not in: Runtime environment")
|
||||
|
||||
# Note: dotenv_values is always available (uses fallback if python-dotenv not installed)
|
||||
|
||||
# Priority 2-7: Check .env files in order
|
||||
env_paths = get_env_file_paths(skill)
|
||||
|
||||
for description, path in env_paths:
|
||||
if path.exists():
|
||||
try:
|
||||
env_vars = dotenv_values(path)
|
||||
value = env_vars.get(var_name)
|
||||
|
||||
if value:
|
||||
if verbose:
|
||||
print(f"✓ {var_name} found in: {description}")
|
||||
print(f" Path: {path}")
|
||||
return value
|
||||
else:
|
||||
if verbose:
|
||||
print(f"✗ {var_name} not in: {description} (file exists)")
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"⚠ Error reading {description}: {e}")
|
||||
else:
|
||||
if verbose:
|
||||
print(f"✗ {var_name} not in: {description} (file not found)")
|
||||
|
||||
# Not found anywhere — always show checked locations to help users debug
|
||||
checked_files = [str(p) for _, p in env_paths if p.exists()]
|
||||
missing_files = [str(p) for _, p in env_paths if not p.exists()]
|
||||
print(f"[!] {var_name} not found in any location", file=sys.stderr)
|
||||
if checked_files:
|
||||
print(f" Checked (file exists, key absent):", file=sys.stderr)
|
||||
for f in checked_files:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
if missing_files and verbose:
|
||||
print(f" Not found (file missing):", file=sys.stderr)
|
||||
for f in missing_files:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
print(f" Tip: Add {var_name}=<value> to one of the .env files above", file=sys.stderr)
|
||||
|
||||
if default:
|
||||
if verbose:
|
||||
print(f" Using default: {default}", file=sys.stderr)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def find_all(var_name: str, skill: Optional[str] = None) -> List[Tuple[str, str, Path]]:
|
||||
"""
|
||||
Find all locations where a variable is defined.
|
||||
|
||||
Args:
|
||||
var_name: Name of the environment variable
|
||||
skill: Optional skill name
|
||||
|
||||
Returns:
|
||||
List of (description, value, path) tuples for all found locations
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Check process environment
|
||||
value = os.getenv(var_name)
|
||||
if value:
|
||||
results.append(("Runtime environment", value, None))
|
||||
|
||||
# Check all .env files (dotenv_values always available via fallback)
|
||||
env_paths = get_env_file_paths(skill)
|
||||
|
||||
for description, path in env_paths:
|
||||
if path.exists():
|
||||
try:
|
||||
env_vars = dotenv_values(path)
|
||||
value = env_vars.get(var_name)
|
||||
|
||||
if value:
|
||||
results.append((description, value, path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def show_hierarchy(skill: Optional[str] = None):
|
||||
"""Print the environment variable resolution hierarchy."""
|
||||
print("Environment Variable Resolution Hierarchy")
|
||||
print("=" * 60)
|
||||
print("\nPriority order (highest to lowest):")
|
||||
print("1. process.env - Runtime environment")
|
||||
|
||||
env_paths = get_env_file_paths(skill)
|
||||
for i, (description, path) in enumerate(env_paths, start=2):
|
||||
exists = "✓" if path.exists() else "✗"
|
||||
print(f"{i}. {description:30} {exists} {path}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI interface for environment variable resolution."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Resolve environment variables following Claude Code hierarchy',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Resolve GEMINI_API_KEY for ai-multimodal skill
|
||||
%(prog)s GEMINI_API_KEY --skill ai-multimodal
|
||||
|
||||
# Resolve with verbose output
|
||||
%(prog)s GEMINI_API_KEY --skill ai-multimodal --verbose
|
||||
|
||||
# Find all locations where variable is defined
|
||||
%(prog)s GEMINI_API_KEY --find-all
|
||||
|
||||
# Show hierarchy
|
||||
%(prog)s --show-hierarchy --skill ai-multimodal
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('var_name', nargs='?', help='Environment variable name to resolve')
|
||||
parser.add_argument('--skill', help='Skill name for skill-specific resolution')
|
||||
parser.add_argument('--default', help='Default value if not found')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Show resolution details')
|
||||
parser.add_argument('--find-all', action='store_true', help='Find all locations where variable is defined')
|
||||
parser.add_argument('--show-hierarchy', action='store_true', help='Show resolution hierarchy')
|
||||
parser.add_argument('--export', action='store_true', help='Output in export format for shell sourcing')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.show_hierarchy:
|
||||
show_hierarchy(args.skill)
|
||||
sys.exit(0)
|
||||
|
||||
if not args.var_name:
|
||||
parser.error("var_name is required unless --show-hierarchy is used")
|
||||
|
||||
if args.find_all:
|
||||
results = find_all(args.var_name, args.skill)
|
||||
|
||||
if results:
|
||||
print(f"Variable '{args.var_name}' found in {len(results)} location(s):")
|
||||
print("=" * 60)
|
||||
|
||||
for i, (description, value, path) in enumerate(results, start=1):
|
||||
priority = i if i == 1 else i + 1 # Account for process.env being priority 1
|
||||
print(f"\n{priority}. {description}")
|
||||
if path:
|
||||
print(f" Path: {path}")
|
||||
print(f" Value: {value[:50]}{'...' if len(value) > 50 else ''}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"✓ Resolved value (highest priority): {results[0][1][:50]}{'...' if len(results[0][1]) > 50 else ''}")
|
||||
else:
|
||||
print(f"❌ Variable '{args.var_name}' not found in any location")
|
||||
sys.exit(1)
|
||||
else:
|
||||
value = resolve_env(args.var_name, args.skill, args.default, args.verbose)
|
||||
|
||||
if value:
|
||||
if args.export:
|
||||
print(f"export {args.var_name}='{value}'")
|
||||
else:
|
||||
print(value)
|
||||
sys.exit(0)
|
||||
else:
|
||||
if not args.verbose:
|
||||
print(f"Error: {args.var_name} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user