This commit is contained in:
29
postgres/.gitignore
vendored
Normal file
29
postgres/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Credentials - NEVER commit actual connection configs
|
||||
connections.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
8
postgres/.skillshare-meta.json
Normal file
8
postgres/.skillshare-meta.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"source": "github.com/sanjay3290/ai-skills/tree/main/skills/postgres",
|
||||
"type": "github-subdir",
|
||||
"installed_at": "2026-01-30T02:29:42.544620946Z",
|
||||
"repo_url": "https://github.com/sanjay3290/ai-skills.git",
|
||||
"subdir": "skills/postgres",
|
||||
"version": "6b0da0b"
|
||||
}
|
||||
77
postgres/README.md
Normal file
77
postgres/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# postgres
|
||||
|
||||
Read-only PostgreSQL query skill. Query multiple databases safely with write protection.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy the example config:
|
||||
```bash
|
||||
cp connections.example.json connections.json
|
||||
```
|
||||
|
||||
2. Add your database credentials:
|
||||
```json
|
||||
{
|
||||
"databases": [
|
||||
{
|
||||
"name": "prod",
|
||||
"description": "Production - users, orders, transactions",
|
||||
"host": "db.example.com",
|
||||
"port": 5432,
|
||||
"database": "app_prod",
|
||||
"user": "readonly",
|
||||
"password": "secret",
|
||||
"sslmode": "require"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. Secure the config:
|
||||
```bash
|
||||
chmod 600 connections.json
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# List configured databases
|
||||
python3 scripts/query.py --list
|
||||
|
||||
# List tables
|
||||
python3 scripts/query.py --db prod --tables
|
||||
|
||||
# Show schema
|
||||
python3 scripts/query.py --db prod --schema
|
||||
|
||||
# Run query
|
||||
python3 scripts/query.py --db prod --query "SELECT * FROM users" --limit 100
|
||||
```
|
||||
|
||||
## Config Fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| name | Yes | - | Database identifier |
|
||||
| description | Yes | - | What data it contains (for auto-selection) |
|
||||
| host | Yes | - | Hostname |
|
||||
| port | No | 5432 | Port |
|
||||
| database | Yes | - | Database name |
|
||||
| user | Yes | - | Username |
|
||||
| password | Yes | - | Password |
|
||||
| sslmode | No | prefer | disable, allow, prefer, require, verify-ca, verify-full |
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **Read-only sessions**: PostgreSQL `readonly=True` mode blocks writes at database level
|
||||
- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH allowed
|
||||
- **Single statement**: No multi-statement queries (prevents `SELECT 1; DROP TABLE`)
|
||||
- **Timeouts**: 30s query timeout, 10s connection timeout
|
||||
- **Memory cap**: Max 10,000 rows per query
|
||||
- **Credential protection**: Passwords sanitized from error messages
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install psycopg2-binary
|
||||
```
|
||||
125
postgres/SKILL.md
Normal file
125
postgres/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: postgres
|
||||
description: "Execute read-only SQL queries against multiple PostgreSQL databases. Use when: (1) querying PostgreSQL databases, (2) exploring database schemas/tables, (3) running SELECT queries for data analysis, (4) checking database contents. Supports multiple database connections with descriptions for intelligent auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety."
|
||||
---
|
||||
|
||||
# PostgreSQL Read-Only Query Skill
|
||||
|
||||
Execute safe, read-only queries against configured PostgreSQL databases.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- psycopg2-binary: `pip install -r requirements.txt`
|
||||
|
||||
## Setup
|
||||
|
||||
Create `connections.json` in the skill directory or `~/.config/claude/postgres-connections.json`.
|
||||
|
||||
**Security**: Set file permissions to `600` since it contains credentials:
|
||||
```bash
|
||||
chmod 600 connections.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"databases": [
|
||||
{
|
||||
"name": "production",
|
||||
"description": "Main app database - users, orders, transactions",
|
||||
"host": "db.example.com",
|
||||
"port": 5432,
|
||||
"database": "app_prod",
|
||||
"user": "readonly_user",
|
||||
"password": "your-password",
|
||||
"sslmode": "require"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Config Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| name | Yes | Identifier for the database (case-insensitive) |
|
||||
| description | Yes | What data this database contains (used for auto-selection) |
|
||||
| host | Yes | Database hostname |
|
||||
| port | No | Port number (default: 5432) |
|
||||
| database | Yes | Database name |
|
||||
| user | Yes | Username |
|
||||
| password | Yes | Password |
|
||||
| sslmode | No | SSL mode: disable, allow, prefer (default), require, verify-ca, verify-full |
|
||||
|
||||
## Usage
|
||||
|
||||
### List configured databases
|
||||
```bash
|
||||
python3 scripts/query.py --list
|
||||
```
|
||||
|
||||
### Query a database
|
||||
```bash
|
||||
python3 scripts/query.py --db production --query "SELECT * FROM users LIMIT 10"
|
||||
```
|
||||
|
||||
### List tables
|
||||
```bash
|
||||
python3 scripts/query.py --db production --tables
|
||||
```
|
||||
|
||||
### Show schema
|
||||
```bash
|
||||
python3 scripts/query.py --db production --schema
|
||||
```
|
||||
|
||||
### Limit results
|
||||
```bash
|
||||
python3 scripts/query.py --db production --query "SELECT * FROM orders" --limit 100
|
||||
```
|
||||
|
||||
## Database Selection
|
||||
|
||||
Match user intent to database `description`:
|
||||
|
||||
| User asks about | Look for description containing |
|
||||
|-----------------|--------------------------------|
|
||||
| users, accounts | users, accounts, customers |
|
||||
| orders, sales | orders, transactions, sales |
|
||||
| analytics, metrics | analytics, metrics, reports |
|
||||
| logs, events | logs, events, audit |
|
||||
|
||||
If unclear, run `--list` and ask user which database.
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **Read-only session**: Connection uses PostgreSQL `readonly=True` mode (primary protection)
|
||||
- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH queries allowed
|
||||
- **Single statement**: Multiple statements per query rejected
|
||||
- **SSL support**: Configurable SSL mode for encrypted connections
|
||||
- **Query timeout**: 30-second statement timeout enforced
|
||||
- **Memory protection**: Max 10,000 rows per query to prevent OOM
|
||||
- **Column width cap**: 100 char max per column for readable output
|
||||
- **Credential sanitization**: Error messages don't leak passwords
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| Config not found | Create `connections.json` in skill directory |
|
||||
| Authentication failed | Check username/password in config |
|
||||
| Connection timeout | Verify host/port, check firewall/VPN |
|
||||
| SSL error | Try `"sslmode": "disable"` for local databases |
|
||||
| Permission warning | Run `chmod 600 connections.json` |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- **0**: Success
|
||||
- **1**: Error (config missing, auth failed, invalid query, database error)
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run `--list` to show available databases
|
||||
2. Match user intent to database description
|
||||
3. Run `--tables` or `--schema` to explore structure
|
||||
4. Execute query with appropriate LIMIT
|
||||
34
postgres/connections.example.json
Normal file
34
postgres/connections.example.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"databases": [
|
||||
{
|
||||
"name": "production",
|
||||
"description": "Main production database - users, orders, transactions, accounts",
|
||||
"host": "prod-db.example.com",
|
||||
"port": 5432,
|
||||
"database": "app_prod",
|
||||
"user": "readonly_user",
|
||||
"password": "your-password-here",
|
||||
"sslmode": "require"
|
||||
},
|
||||
{
|
||||
"name": "analytics",
|
||||
"description": "Analytics warehouse - aggregated metrics, reports, historical data",
|
||||
"host": "analytics-db.example.com",
|
||||
"port": 5432,
|
||||
"database": "analytics",
|
||||
"user": "analyst",
|
||||
"password": "your-password-here",
|
||||
"sslmode": "require"
|
||||
},
|
||||
{
|
||||
"name": "staging",
|
||||
"description": "Staging environment - mirrors production for testing",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"database": "app_staging",
|
||||
"user": "dev",
|
||||
"password": "dev-password",
|
||||
"sslmode": "prefer"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
postgres/requirements.txt
Normal file
1
postgres/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
psycopg2-binary>=2.9,<3.0
|
||||
262
postgres/scripts/query.py
Normal file
262
postgres/scripts/query.py
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Read-only PostgreSQL query executor.
|
||||
Connects to configured databases and executes SELECT queries only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("Error: psycopg2 not installed. Run: pip install psycopg2-binary")
|
||||
sys.exit(1)
|
||||
|
||||
# Constants
|
||||
SCRIPT_DIR = Path(__file__).parent.parent
|
||||
CONFIG_LOCATIONS = [
|
||||
SCRIPT_DIR / "connections.json",
|
||||
Path.home() / ".config" / "claude" / "postgres-connections.json",
|
||||
]
|
||||
MAX_ROWS = 10000
|
||||
MAX_COLUMN_WIDTH = 100
|
||||
QUERY_TIMEOUT_MS = 30000
|
||||
CONNECTION_TIMEOUT_SEC = 10
|
||||
NULL_DISPLAY = "<NULL>"
|
||||
|
||||
|
||||
def is_read_only(query: str) -> bool:
|
||||
"""Basic client-side check. Primary protection is readonly=True session."""
|
||||
query_upper = query.upper().strip()
|
||||
safe_starts = ('SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH', '\\D')
|
||||
return any(query_upper.startswith(cmd) for cmd in safe_starts)
|
||||
|
||||
|
||||
def validate_single_statement(query: str) -> bool:
|
||||
"""Check query contains only one statement."""
|
||||
# Remove trailing semicolon and whitespace, then check for remaining semicolons
|
||||
clean = query.rstrip().rstrip(';')
|
||||
return ';' not in clean
|
||||
|
||||
|
||||
def validate_config_permissions(path: Path) -> None:
|
||||
"""Warn if config file has insecure permissions (Unix only)."""
|
||||
if os.name != 'nt': # Skip on Windows
|
||||
mode = path.stat().st_mode
|
||||
if bool(mode & stat.S_IRWXG) or bool(mode & stat.S_IRWXO):
|
||||
print(f"WARNING: {path} has insecure permissions!")
|
||||
print(f"Config contains credentials. Run: chmod 600 {path}")
|
||||
|
||||
|
||||
def validate_db_config(db: dict) -> None:
|
||||
"""Validate required fields exist in database config."""
|
||||
required = ['name', 'host', 'database', 'user', 'password']
|
||||
missing = [f for f in required if f not in db]
|
||||
if missing:
|
||||
print(f"Error: Database config missing fields: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_config() -> Optional[Path]:
|
||||
"""Find config file in supported locations."""
|
||||
for path in CONFIG_LOCATIONS:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def load_config(config_path: Optional[Path] = None) -> dict:
|
||||
"""Load database connections from JSON config."""
|
||||
path = config_path or find_config()
|
||||
if not path:
|
||||
print("Config not found. Searched:")
|
||||
for loc in CONFIG_LOCATIONS:
|
||||
print(f" - {loc}")
|
||||
print("\nCreate connections.json with format:")
|
||||
print(json.dumps({
|
||||
"databases": [{
|
||||
"name": "mydb",
|
||||
"description": "Description of database contents",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"database": "mydb",
|
||||
"user": "user",
|
||||
"password": "password",
|
||||
"sslmode": "prefer"
|
||||
}]
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
validate_config_permissions(path)
|
||||
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def list_databases(config: dict) -> None:
|
||||
"""List all configured databases."""
|
||||
print("Configured databases:\n")
|
||||
for db in config.get("databases", []):
|
||||
validate_db_config(db)
|
||||
print(f" [{db['name']}]")
|
||||
print(f" Host: {db['host']}:{db.get('port', 5432)}")
|
||||
print(f" Database: {db['database']}")
|
||||
print(f" Description: {db.get('description', 'No description')}")
|
||||
print()
|
||||
|
||||
|
||||
def execute_query(db_config: dict, query: str, limit: Optional[int] = None) -> None:
|
||||
"""Execute a read-only query against the specified database."""
|
||||
if not is_read_only(query):
|
||||
print("Error: Only read-only queries (SELECT, SHOW, EXPLAIN) are allowed.")
|
||||
sys.exit(1)
|
||||
|
||||
if not validate_single_statement(query):
|
||||
print("Error: Multiple statements not allowed. Execute queries separately.")
|
||||
sys.exit(1)
|
||||
|
||||
# Apply limit using regex to avoid false positives from string content
|
||||
if limit and not re.search(r'\bLIMIT\s+\d+', query, re.IGNORECASE):
|
||||
query = f"{query.rstrip(';')} LIMIT {limit}"
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=db_config['host'],
|
||||
port=db_config.get('port', 5432),
|
||||
database=db_config['database'],
|
||||
user=db_config['user'],
|
||||
password=db_config['password'],
|
||||
sslmode=db_config.get('sslmode', 'prefer'),
|
||||
connect_timeout=CONNECTION_TIMEOUT_SEC,
|
||||
options=f'-c statement_timeout={QUERY_TIMEOUT_MS}'
|
||||
)
|
||||
# Primary safety: readonly session prevents any write operations
|
||||
conn.set_session(readonly=True, autocommit=True)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
if cur.description:
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchmany(MAX_ROWS)
|
||||
truncated = len(rows) == MAX_ROWS
|
||||
|
||||
# Calculate column widths with cap
|
||||
widths = [min(len(col), MAX_COLUMN_WIDTH) for col in columns]
|
||||
for row in rows:
|
||||
for i, val in enumerate(row):
|
||||
val_str = str(val) if val is not None else NULL_DISPLAY
|
||||
widths[i] = min(max(widths[i], len(val_str)), MAX_COLUMN_WIDTH)
|
||||
|
||||
# Print header
|
||||
header = " | ".join(col[:MAX_COLUMN_WIDTH].ljust(widths[i]) for i, col in enumerate(columns))
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
# Print rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, val in enumerate(row):
|
||||
val_str = str(val) if val is not None else NULL_DISPLAY
|
||||
if len(val_str) > MAX_COLUMN_WIDTH:
|
||||
val_str = val_str[:MAX_COLUMN_WIDTH-3] + "..."
|
||||
cells.append(val_str.ljust(widths[i]))
|
||||
print(" | ".join(cells))
|
||||
|
||||
msg = f"\n({len(rows)} rows)"
|
||||
if truncated:
|
||||
msg += f" [truncated at {MAX_ROWS}]"
|
||||
print(msg)
|
||||
else:
|
||||
print("Query executed (no result set returned)")
|
||||
|
||||
except psycopg2.Error as e:
|
||||
error_msg = str(e)
|
||||
# Sanitize to avoid leaking credentials
|
||||
if 'password' in error_msg.lower() or 'authentication' in error_msg.lower():
|
||||
error_msg = "Authentication failed. Check credentials in connections.json"
|
||||
print(f"Database error: {error_msg}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_database(config: dict, name: str) -> dict:
|
||||
"""Find database config by name (case-insensitive)."""
|
||||
for db in config.get("databases", []):
|
||||
if db.get('name', '').lower() == name.lower():
|
||||
validate_db_config(db)
|
||||
return db
|
||||
available = [db.get('name', 'unnamed') for db in config.get("databases", [])]
|
||||
print(f"Database '{name}' not found.")
|
||||
print(f"Available: {', '.join(available)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute read-only PostgreSQL queries",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --list
|
||||
%(prog)s --db mydb --tables
|
||||
%(prog)s --db mydb --query "SELECT * FROM users" --limit 100
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--config", "-c", type=Path, help="Path to config JSON")
|
||||
parser.add_argument("--db", "-d", help="Database name to query")
|
||||
parser.add_argument("--query", "-q", help="SQL query to execute")
|
||||
parser.add_argument("--limit", "-l", type=int, help="Limit rows returned")
|
||||
parser.add_argument("--list", action="store_true", help="List configured databases")
|
||||
parser.add_argument("--schema", "-s", action="store_true", help="Show database schema")
|
||||
parser.add_argument("--tables", "-t", action="store_true", help="List tables")
|
||||
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
|
||||
if args.list:
|
||||
list_databases(config)
|
||||
return
|
||||
|
||||
if not args.db:
|
||||
print("Error: --db required. Use --list to see available databases.")
|
||||
sys.exit(1)
|
||||
|
||||
db_config = find_database(config, args.db)
|
||||
|
||||
if args.tables:
|
||||
query = """
|
||||
SELECT table_schema, table_name, table_type
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY table_schema, table_name
|
||||
"""
|
||||
execute_query(db_config, query, args.limit)
|
||||
elif args.schema:
|
||||
query = """
|
||||
SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
||||
"""
|
||||
execute_query(db_config, query, args.limit)
|
||||
elif args.query:
|
||||
execute_query(db_config, args.query, args.limit)
|
||||
else:
|
||||
print("Error: --query, --tables, or --schema required")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user