Move .env into skills/ for easy install

- Added skills/_env_loader.py - shared env loader for all scripts
- Updated 17 scripts to use load_unified_env()
- Updated install-skills.sh to copy .env into skills/
- Updated README with simpler OpenClaw install instructions
- .env in skills/ is gitignored (credentials stay private)
This commit is contained in:
Kunthawat Greethong
2026-03-27 17:49:20 +07:00
parent 4e92ef953b
commit e4d41e3ae5
20 changed files with 219 additions and 212 deletions

View File

@@ -58,27 +58,21 @@ cp -r skills/* .opencode/skills/
## OpenClaw Installation ## OpenClaw Installation
For OpenClaw, you need to install both skills AND credentials: For OpenClaw, just copy the skills folder - it now includes `.env` with all credentials:
```bash ```bash
# Option 1: Local OpenClaw folder # Option 1: Local OpenClaw folder
mkdir -p ~/.openclaw/skills cp -r skills ~/.openclaw/skills
cp -r skills/* ~/.openclaw/skills/
cp .env ~/.openclaw/.env
# Option 2: Remote server SSH mount (e.g. ~/openclaw-vps/) # Option 2: Remote server SSH mount (e.g. ~/openclaw-vps/)
mkdir -p ~/openclaw-vps/.openclaw/skills cp -r skills ~/openclaw-vps/.openclaw/skills
cp -r skills/* ~/openclaw-vps/.openclaw/skills/
cp .env ~/openclaw-vps/.openclaw/.env
# Option 3: rsync for faster sync over SSH # Option 3: rsync for faster sync over SSH
rsync -av skills/ user@remote-server:.openclaw/skills/ rsync -av skills/ user@remote-server:.openclaw/skills/
rsync -av .env user@remote-server:.openclaw/.env
``` ```
**What to copy:** **What to copy:**
- `skills/` - All skill folders - `skills/` - Includes all skills AND `.env` with credentials
- `.env` - Credentials (API keys, tokens)
**Note:** OpenClaw searches for skills in `~/.openclaw/skills` or any `*/.openclaw/skills` folder. **Note:** OpenClaw searches for skills in `~/.openclaw/skills` or any `*/.openclaw/skills` folder.

View File

@@ -151,10 +151,11 @@ setup_unified_env() {
fi fi
} }
# Copy unified .env to global location # Copy unified .env to global location and into skills/
copy_unified_env() { copy_unified_env() {
local source_env="${REPO_ROOT}/.env" local source_env="${REPO_ROOT}/.env"
local target_env="${GLOBAL_DIR}/.env" local target_env="${GLOBAL_DIR}/.env"
local skills_env="${SKILLS_DIR}/.env"
if [ -f "$source_env" ]; then if [ -f "$source_env" ]; then
print_info "Copying unified .env to global location..." print_info "Copying unified .env to global location..."
@@ -162,6 +163,11 @@ copy_unified_env() {
cp "$source_env" "$target_env" cp "$source_env" "$target_env"
chmod 600 "$target_env" chmod 600 "$target_env"
print_success "Created: ${target_env}" print_success "Created: ${target_env}"
print_info "Copying .env into skills/ folder..."
cp "$source_env" "$skills_env"
chmod 600 "$skills_env"
print_success "Created: ${skills_env}"
echo "" echo ""
fi fi
} }
@@ -266,26 +272,7 @@ main() {
copy_unified_env copy_unified_env
# Create skill-specific .env files that reference unified .env # Create skill-specific .env files that reference unified .env
print_info "Creating skill configuration files..." # (No longer needed - .env is in skills/ folder)
for skill in $SKILLS; do
dest="${TARGET}/${skill}"
scripts_dir="${dest}/scripts"
[ -d "$scripts_dir" ] || continue
# Create .env file that tells script where to find unified .env
cat > "${scripts_dir}/.env" << EOF
# AUTO-GENERATED - DO NOT EDIT
# This skill uses the unified .env file
# Location: ${GLOBAL_DIR}/.env
#
# Edit that file instead to update credentials.
# This file is overwritten on each install.
EOF
chmod 600 "${scripts_dir}/.env"
done
print_success "All skills configured" print_success "All skills configured"
echo "" echo ""

14
skills/_env_loader.py Normal file
View File

@@ -0,0 +1,14 @@
import os
from pathlib import Path
from dotenv import load_dotenv
def load_unified_env():
skills_root = Path(__file__).resolve().parent.parent
env_path = skills_root / ".env"
if env_path.exists():
load_dotenv(env_path)
return
legacy = Path.home() / ".config" / "opencode" / ".env"
if legacy.exists():
load_dotenv(legacy)

View File

@@ -6,17 +6,16 @@ from typing import List, Optional
from loguru import logger from loguru import logger
from pandas.tseries.offsets import BusinessDay from pandas.tseries.offsets import BusinessDay
import os import os
from dotenv import load_dotenv
load_dotenv(os.path.expanduser("~/.config/opencode/.env"))
# Fix for Kronos internal imports
import sys import sys
KRONOS_DIR = os.path.join(os.path.dirname(__file__), "predictor") KRONOS_DIR = os.path.join(os.path.dirname(__file__), "predictor")
if KRONOS_DIR not in sys.path: if KRONOS_DIR not in sys.path:
sys.path.append(KRONOS_DIR) sys.path.append(KRONOS_DIR)
from skills._env_loader import load_unified_env
load_unified_env()
import glob import glob
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer

View File

@@ -72,9 +72,9 @@ class ModelCapabilityRegistry:
if __name__ == "__main__": if __name__ == "__main__":
import os import os
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# 测试当前配置的模型 # 测试当前配置的模型
p = os.getenv("LLM_PROVIDER", "minimax") p = os.getenv("LLM_PROVIDER", "minimax")

View File

@@ -2,12 +2,11 @@ import os
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
from agno.models.base import Model from agno.models.base import Model
from loguru import logger from loguru import logger
from dotenv import load_dotenv
from ..llm.factory import get_model from ..llm.factory import get_model
from ..llm.capability import ModelCapabilityRegistry from ..llm.capability import ModelCapabilityRegistry
from skills._env_loader import load_unified_env
# Load environment variables from universal .env load_unified_env()
load_dotenv(os.path.expanduser("~/.config/opencode/.env"))
class ModelRouter: class ModelRouter:

View File

@@ -10,9 +10,9 @@ import random
from loguru import logger from loguru import logger
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# Setup paths # Setup paths
KRONOS_DIR = os.path.dirname(os.path.abspath(__file__)) KRONOS_DIR = os.path.dirname(os.path.abspath(__file__))

View File

@@ -72,9 +72,9 @@ class ModelCapabilityRegistry:
if __name__ == "__main__": if __name__ == "__main__":
import os import os
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# 测试当前配置的模型 # 测试当前配置的模型
p = os.getenv("LLM_PROVIDER", "minimax") p = os.getenv("LLM_PROVIDER", "minimax")

View File

@@ -2,11 +2,11 @@ import os
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
from agno.models.base import Model from agno.models.base import Model
from loguru import logger from loguru import logger
from dotenv import load_dotenv
from ..llm.factory import get_model from ..llm.factory import get_model
from ..llm.capability import ModelCapabilityRegistry from ..llm.capability import ModelCapabilityRegistry
from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
class ModelRouter: class ModelRouter:

View File

@@ -10,9 +10,9 @@ import random
from loguru import logger from loguru import logger
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# Setup paths # Setup paths
KRONOS_DIR = os.path.dirname(os.path.abspath(__file__)) KRONOS_DIR = os.path.dirname(os.path.abspath(__file__))

View File

@@ -72,9 +72,9 @@ class ModelCapabilityRegistry:
if __name__ == "__main__": if __name__ == "__main__":
import os import os
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# 测试当前配置的模型 # 测试当前配置的模型
p = os.getenv("LLM_PROVIDER", "minimax") p = os.getenv("LLM_PROVIDER", "minimax")

View File

@@ -2,11 +2,11 @@ import os
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
from agno.models.base import Model from agno.models.base import Model
from loguru import logger from loguru import logger
from dotenv import load_dotenv
from .factory import get_model from .factory import get_model
from .capability import ModelCapabilityRegistry from .capability import ModelCapabilityRegistry
from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
class ModelRouter: class ModelRouter:

View File

@@ -72,9 +72,9 @@ class ModelCapabilityRegistry:
if __name__ == "__main__": if __name__ == "__main__":
import os import os
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# 测试当前配置的模型 # 测试当前配置的模型
p = os.getenv("LLM_PROVIDER", "minimax") p = os.getenv("LLM_PROVIDER", "minimax")

View File

@@ -2,11 +2,11 @@ import os
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
from agno.models.base import Model from agno.models.base import Model
from loguru import logger from loguru import logger
from dotenv import load_dotenv
from .llm.factory import get_model from .llm.factory import get_model
from utils.llm.capability import ModelCapabilityRegistry from utils.llm.capability import ModelCapabilityRegistry
from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
class ModelRouter: class ModelRouter:

View File

@@ -72,9 +72,9 @@ class ModelCapabilityRegistry:
if __name__ == "__main__": if __name__ == "__main__":
import os import os
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# 测试当前配置的模型 # 测试当前配置的模型
p = os.getenv("LLM_PROVIDER", "minimax") p = os.getenv("LLM_PROVIDER", "minimax")

View File

@@ -2,11 +2,11 @@ import os
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
from agno.models.base import Model from agno.models.base import Model
from loguru import logger from loguru import logger
from dotenv import load_dotenv
from ..llm.factory import get_model from ..llm.factory import get_model
from ..llm.capability import ModelCapabilityRegistry from ..llm.capability import ModelCapabilityRegistry
from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
class ModelRouter: class ModelRouter:

View File

@@ -10,9 +10,9 @@ import random
from loguru import logger from loguru import logger
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.expanduser("~/.config/opencode/.env")) load_unified_env()
# Setup paths # Setup paths
KRONOS_DIR = os.path.dirname(os.path.abspath(__file__)) KRONOS_DIR = os.path.dirname(os.path.abspath(__file__))

View File

@@ -18,9 +18,9 @@ from typing import Dict, List, Optional, Any
import yaml import yaml
# Load environment variables # Load environment variables
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv() load_unified_env()
# Thai language processing # Thai language processing
try: try:

View File

@@ -115,9 +115,9 @@ def ask_analytics_setup():
print("\n 📈 Umami Analytics Setup") print("\n 📈 Umami Analytics Setup")
# Check if Umami credentials are configured # Check if Umami credentials are configured
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.join(os.path.dirname(__file__), "../../../.env")) load_unified_env()
umami_url = os.getenv("UMAMI_URL", "") umami_url = os.getenv("UMAMI_URL", "")
umami_username = os.getenv("UMAMI_USERNAME", "") umami_username = os.getenv("UMAMI_USERNAME", "")
@@ -148,9 +148,9 @@ def ask_analytics_setup():
print("\n Please provide your existing GA4 details:") print("\n Please provide your existing GA4 details:")
# Check unified .env for GA4 credentials # Check unified .env for GA4 credentials
from dotenv import load_dotenv from skills._env_loader import load_unified_env
load_dotenv(os.path.join(os.path.dirname(__file__), "../../../.env")) load_unified_env()
ga4_property_id = os.getenv("GA4_PROPERTY_ID", "") ga4_property_id = os.getenv("GA4_PROPERTY_ID", "")
ga4_credentials_path = os.getenv("GA4_CREDENTIALS_PATH", "") ga4_credentials_path = os.getenv("GA4_CREDENTIALS_PATH", "")

View File

@@ -17,20 +17,24 @@ import argparse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Optional, List from typing import Dict, Optional, List
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from skills._env_loader import load_unified_env
# Load credentials from unified .env load_unified_env()
load_dotenv(os.path.expanduser('~/.config/opencode/.env'))
load_dotenv() # Also load local .env for development
class UmamiClient: class UmamiClient:
"""Umami Analytics API client with username/password auth""" """Umami Analytics API client with username/password auth"""
def __init__(self, umami_url: str = None, username: str = None, password: str = None, token: str = None): def __init__(
self,
umami_url: str = None,
username: str = None,
password: str = None,
token: str = None,
):
""" """
Initialize Umami client Initialize Umami client
Args: Args:
umami_url: Umami instance URL (loaded from .env if not provided) umami_url: Umami instance URL (loaded from .env if not provided)
username: Umami username/email (loaded from .env if not provided) username: Umami username/email (loaded from .env if not provided)
@@ -38,94 +42,88 @@ class UmamiClient:
token: Bearer token (optional, if already have) token: Bearer token (optional, if already have)
""" """
# Load from environment if not provided # Load from environment if not provided
self.umami_url = umami_url or os.getenv('UMAMI_URL', '') self.umami_url = umami_url or os.getenv("UMAMI_URL", "")
self.username = username or os.getenv('UMAMI_USERNAME', '') self.username = username or os.getenv("UMAMI_USERNAME", "")
self.password = password or os.getenv('UMAMI_PASSWORD', '') self.password = password or os.getenv("UMAMI_PASSWORD", "")
self.token = token self.token = token
self.user_id = None self.user_id = None
# Auto-login if credentials provided # Auto-login if credentials provided
if self.username and self.password and not token: if self.username and self.password and not token:
self.login() self.login()
def login(self) -> Dict: def login(self) -> Dict:
"""Login to Umami and get bearer token""" """Login to Umami and get bearer token"""
try: try:
url = f"{self.umami_url}/api/auth/login" url = f"{self.umami_url}/api/auth/login"
data = { data = {"username": self.username, "password": self.password}
'username': self.username,
'password': self.password
}
response = requests.post(url, json=data) response = requests.post(url, json=data)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
if 'token' in result: if "token" in result:
self.token = result['token'] self.token = result["token"]
self.user_id = result.get('user', {}).get('id') self.user_id = result.get("user", {}).get("id")
return { return {
'success': True, "success": True,
'token': self.token, "token": self.token,
'user_id': self.user_id, "user_id": self.user_id,
'username': result.get('user', {}).get('username') "username": result.get("user", {}).get("username"),
} }
else: else:
return {'success': False, 'error': 'No token in response'} return {"success": False, "error": "No token in response"}
except Exception as e: except Exception as e:
return {'success': False, 'error': str(e)} return {"success": False, "error": str(e)}
def _get_headers(self) -> Dict: def _get_headers(self) -> Dict:
"""Get request headers with auth""" """Get request headers with auth"""
if not self.token: if not self.token:
if self.username and self.password: if self.username and self.password:
self.login() self.login()
return { return {
'Authorization': f'Bearer {self.token}', "Authorization": f"Bearer {self.token}",
'Content-Type': 'application/json', "Content-Type": "application/json",
'Accept': 'application/json' "Accept": "application/json",
} }
def create_website(self, name: str, domain: str) -> Dict: def create_website(self, name: str, domain: str) -> Dict:
"""Create new Umami website""" """Create new Umami website"""
try: try:
url = f"{self.umami_url}/api/websites" url = f"{self.umami_url}/api/websites"
data = { data = {"name": name, "domain": domain}
'name': name,
'domain': domain
}
response = requests.post(url, json=data, headers=self._get_headers()) response = requests.post(url, json=data, headers=self._get_headers())
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
return { return {
'success': True, "success": True,
'website_id': result.get('id'), "website_id": result.get("id"),
'name': result.get('name'), "name": result.get("name"),
'domain': result.get('domain'), "domain": result.get("domain"),
'created_at': result.get('createdAt'), "created_at": result.get("createdAt"),
'tracking_url': f"{self.umami_url}/script.js", "tracking_url": f"{self.umami_url}/script.js",
'tracking_script': self._get_tracking_script(result.get('id')) "tracking_script": self._get_tracking_script(result.get("id")),
} }
except Exception as e: except Exception as e:
return {'success': False, 'error': str(e)} return {"success": False, "error": str(e)}
def get_website_by_domain(self, domain: str) -> Optional[Dict]: def get_website_by_domain(self, domain: str) -> Optional[Dict]:
"""Find website by domain""" """Find website by domain"""
try: try:
websites = self.list_websites() websites = self.list_websites()
for site in websites: for site in websites:
if domain in site.get('domain', ''): if domain in site.get("domain", ""):
return site return site
return None return None
except: except:
return None return None
def list_websites(self) -> List[Dict]: def list_websites(self) -> List[Dict]:
"""Get all websites""" """Get all websites"""
try: try:
@@ -133,141 +131,157 @@ class UmamiClient:
response = requests.get(url, headers=self._get_headers()) response = requests.get(url, headers=self._get_headers())
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
# Handle both array and paginated response # Handle both array and paginated response
if isinstance(result, list): if isinstance(result, list):
return result return result
elif 'data' in result: elif "data" in result:
return result['data'] return result["data"]
else: else:
return [] return []
except Exception as e: except Exception as e:
print(f"Error listing websites: {e}") print(f"Error listing websites: {e}")
return [] return []
def get_stats(self, website_id: str, days: int = 30) -> Dict: def get_stats(self, website_id: str, days: int = 30) -> Dict:
"""Get website statistics""" """Get website statistics"""
try: try:
end_date = datetime.now() end_date = datetime.now()
start_date = end_date - timedelta(days=days) start_date = end_date - timedelta(days=days)
url = f"{self.umami_url}/api/websites/{website_id}/stats" url = f"{self.umami_url}/api/websites/{website_id}/stats"
params = { params = {
'startAt': int(start_date.timestamp() * 1000), "startAt": int(start_date.timestamp() * 1000),
'endAt': int(end_date.timestamp() * 1000) "endAt": int(end_date.timestamp() * 1000),
} }
response = requests.get(url, headers=self._get_headers(), params=params) response = requests.get(url, headers=self._get_headers(), params=params)
response.raise_for_status() response.raise_for_status()
stats = response.json() stats = response.json()
return { return {
'success': True, "success": True,
'website_id': website_id, "website_id": website_id,
'period': f'last_{days}_days', "period": f"last_{days}_days",
'pageviews': stats.get('pageviews', 0), "pageviews": stats.get("pageviews", 0),
'uniques': stats.get('uniques', 0), "uniques": stats.get("uniques", 0),
'bounces': stats.get('bounces', 0), "bounces": stats.get("bounces", 0),
'totaltime': stats.get('totaltime', 0), "totaltime": stats.get("totaltime", 0),
'avg_session_duration': stats.get('totaltime', 0) / max(stats.get('visits', 1), 1), "avg_session_duration": stats.get("totaltime", 0)
'bounce_rate': stats.get('bounces', 0) / max(stats.get('visits', 1), 1) * 100 / max(stats.get("visits", 1), 1),
"bounce_rate": stats.get("bounces", 0)
/ max(stats.get("visits", 1), 1)
* 100,
} }
except Exception as e: except Exception as e:
return {'success': False, 'error': str(e)} return {"success": False, "error": str(e)}
def _get_tracking_script(self, website_id: str) -> str: def _get_tracking_script(self, website_id: str) -> str:
"""Generate tracking script HTML""" """Generate tracking script HTML"""
return f'<script defer src="{self.umami_url}/script.js" data-website-id="{website_id}"></script>' return f'<script defer src="{self.umami_url}/script.js" data-website-id="{website_id}"></script>'
def add_tracking_to_astro(self, website_repo: str, website_id: str) -> Dict: def add_tracking_to_astro(self, website_repo: str, website_id: str) -> Dict:
"""Add Umami tracking to Astro website""" """Add Umami tracking to Astro website"""
try: try:
tracking_script = self._get_tracking_script(website_id) tracking_script = self._get_tracking_script(website_id)
# Find Astro layout file # Find Astro layout file
layout_paths = [ layout_paths = [
os.path.join(website_repo, 'src/layouts/Layout.astro'), os.path.join(website_repo, "src/layouts/Layout.astro"),
os.path.join(website_repo, 'src/layouts/BaseHead.astro'), os.path.join(website_repo, "src/layouts/BaseHead.astro"),
os.path.join(website_repo, 'src/pages/_document.tsx'), os.path.join(website_repo, "src/pages/_document.tsx"),
os.path.join(website_repo, 'src/app.html') os.path.join(website_repo, "src/app.html"),
] ]
layout_file = None layout_file = None
for path in layout_paths: for path in layout_paths:
if os.path.exists(path): if os.path.exists(path):
layout_file = path layout_file = path
break break
if not layout_file: if not layout_file:
layouts_dir = os.path.join(website_repo, 'src/layouts') layouts_dir = os.path.join(website_repo, "src/layouts")
if os.path.exists(layouts_dir): if os.path.exists(layouts_dir):
for f in os.listdir(layouts_dir): for f in os.listdir(layouts_dir):
if f.endswith('.astro'): if f.endswith(".astro"):
layout_file = os.path.join(layouts_dir, f) layout_file = os.path.join(layouts_dir, f)
break break
if not layout_file: if not layout_file:
return {'success': False, 'error': 'No Astro layout file found'} return {"success": False, "error": "No Astro layout file found"}
with open(layout_file, 'r', encoding='utf-8') as f: with open(layout_file, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
if '</head>' in content: if "</head>" in content:
content = content.replace('</head>', f' {tracking_script}\n </head>') content = content.replace(
"</head>", f" {tracking_script}\n </head>"
)
else: else:
content += f'\n{tracking_script}\n' content += f"\n{tracking_script}\n"
with open(layout_file, 'w', encoding='utf-8') as f: with open(layout_file, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
return { return {"success": True, "layout_file": layout_file, "tracking_added": True}
'success': True,
'layout_file': layout_file,
'tracking_added': True
}
except Exception as e: except Exception as e:
return {'success': False, 'error': str(e)} return {"success": False, "error": str(e)}
def main(): def main():
"""Main CLI entry point""" """Main CLI entry point"""
parser = argparse.ArgumentParser(description='Umami Analytics Client') parser = argparse.ArgumentParser(description="Umami Analytics Client")
parser.add_argument('--action', required=True, parser.add_argument(
choices=['create-website', 'get-tracking', 'add-tracking', 'get-stats', 'list-websites']) "--action",
parser.add_argument('--umami-url', help='Umami instance URL (or set UMAMI_URL in .env)') required=True,
parser.add_argument('--username', help='Umami username (or set UMAMI_USERNAME in .env)') choices=[
parser.add_argument('--password', help='Umami password (or set UMAMI_PASSWORD in .env)') "create-website",
parser.add_argument('--website-name', help='Website name (for create)') "get-tracking",
parser.add_argument('--website-domain', help='Website domain (for create/find)') "add-tracking",
parser.add_argument('--website-id', help='Website ID (for stats)') "get-stats",
parser.add_argument('--website-repo', help='Path to website repo (for add-tracking)') "list-websites",
parser.add_argument('--days', type=int, default=30, help='Days for stats') ],
)
parser.add_argument(
"--umami-url", help="Umami instance URL (or set UMAMI_URL in .env)"
)
parser.add_argument(
"--username", help="Umami username (or set UMAMI_USERNAME in .env)"
)
parser.add_argument(
"--password", help="Umami password (or set UMAMI_PASSWORD in .env)"
)
parser.add_argument("--website-name", help="Website name (for create)")
parser.add_argument("--website-domain", help="Website domain (for create/find)")
parser.add_argument("--website-id", help="Website ID (for stats)")
parser.add_argument(
"--website-repo", help="Path to website repo (for add-tracking)"
)
parser.add_argument("--days", type=int, default=30, help="Days for stats")
args = parser.parse_args() args = parser.parse_args()
print(f"\n📊 Umami Analytics Client") print(f"\n📊 Umami Analytics Client")
print(f"URL: {args.umami_url or os.getenv('UMAMI_URL', 'Not set')}\n") print(f"URL: {args.umami_url or os.getenv('UMAMI_URL', 'Not set')}\n")
# Initialize client (loads credentials from .env automatically) # Initialize client (loads credentials from .env automatically)
client = UmamiClient( client = UmamiClient(
umami_url=args.umami_url, umami_url=args.umami_url, username=args.username, password=args.password
username=args.username,
password=args.password
) )
if args.action == 'create-website': if args.action == "create-website":
if not args.website_name or not args.website_domain: if not args.website_name or not args.website_domain:
print("Error: --website-name and --website-domain required") print("Error: --website-name and --website-domain required")
return return
print(f"Creating website: {args.website_name} ({args.website_domain})") print(f"Creating website: {args.website_name} ({args.website_domain})")
result = client.create_website(args.website_name, args.website_domain) result = client.create_website(args.website_name, args.website_domain)
if result['success']: if result["success"]:
print(f"\n✅ Website created!") print(f"\n✅ Website created!")
print(f" ID: {result['website_id']}") print(f" ID: {result['website_id']}")
print(f" Name: {result['name']}") print(f" Name: {result['name']}")
@@ -276,39 +290,39 @@ def main():
print(f"\nScript:\n{result['tracking_script']}") print(f"\nScript:\n{result['tracking_script']}")
else: else:
print(f"\n❌ Failed: {result['error']}") print(f"\n❌ Failed: {result['error']}")
elif args.action == 'get-tracking': elif args.action == "get-tracking":
if not args.website_id: if not args.website_id:
print("Error: --website-id required") print("Error: --website-id required")
return return
script = client._get_tracking_script(args.website_id) script = client._get_tracking_script(args.website_id)
print(f"\nTracking script for {args.website_id}:") print(f"\nTracking script for {args.website_id}:")
print(script) print(script)
elif args.action == 'add-tracking': elif args.action == "add-tracking":
if not args.website_id or not args.website_repo: if not args.website_id or not args.website_repo:
print("Error: --website-id and --website-repo required") print("Error: --website-id and --website-repo required")
return return
print(f"Adding tracking to: {args.website_repo}") print(f"Adding tracking to: {args.website_repo}")
result = client.add_tracking_to_astro(args.website_repo, args.website_id) result = client.add_tracking_to_astro(args.website_repo, args.website_id)
if result['success']: if result["success"]:
print(f"\n✅ Tracking added!") print(f"\n✅ Tracking added!")
print(f" Layout: {result['layout_file']}") print(f" Layout: {result['layout_file']}")
else: else:
print(f"\n❌ Failed: {result['error']}") print(f"\n❌ Failed: {result['error']}")
elif args.action == 'get-stats': elif args.action == "get-stats":
if not args.website_id: if not args.website_id:
print("Error: --website-id required") print("Error: --website-id required")
return return
print(f"Getting stats for last {args.days} days...") print(f"Getting stats for last {args.days} days...")
stats = client.get_stats(args.website_id, args.days) stats = client.get_stats(args.website_id, args.days)
if stats['success']: if stats["success"]:
print(f"\n📊 Analytics ({stats['period']}):") print(f"\n📊 Analytics ({stats['period']}):")
print(f" Pageviews: {stats['pageviews']:,}") print(f" Pageviews: {stats['pageviews']:,}")
print(f" Unique visitors: {stats['uniques']:,}") print(f" Unique visitors: {stats['uniques']:,}")
@@ -317,15 +331,15 @@ def main():
print(f" Avg session: {stats['avg_session_duration']:.1f}s") print(f" Avg session: {stats['avg_session_duration']:.1f}s")
else: else:
print(f"\n❌ Failed: {stats['error']}") print(f"\n❌ Failed: {stats['error']}")
elif args.action == 'list-websites': elif args.action == "list-websites":
print("Listing websites...") print("Listing websites...")
websites = client.list_websites() websites = client.list_websites()
print(f"\nFound {len(websites)} websites:") print(f"\nFound {len(websites)} websites:")
for site in websites: for site in websites:
print(f"{site.get('name')} - {site.get('domain')}") print(f"{site.get('name')} - {site.get('domain')}")
if __name__ == '__main__': if __name__ == "__main__":
main() main()