claw-code/.guardrails/scripts/setup_agents.py

459 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Agent Guardrails Setup Script
Installs pre-committed guardrails configurations for various AI coding platforms.
Usage:
# Full platform install
python scripts/setup_agents.py --install --platform claude,cursor,opencode,windsurf,copilot
# Clone a single skill file (no repo clone needed)
python scripts/setup_agents.py --clone .claude/skills/guardrails-enforcer.json
python scripts/setup_agents.py --clone .claude/skills/guardrails-enforcer.json --target ~/myproject
# Install a single skill by name
python scripts/setup_agents.py --install-skill guardrails-enforcer
python scripts/setup_agents.py --install-skill commit-validator --target ~/myproject --platform claude
# List available skills
python scripts/setup_agents.py --list-skills
# MCP tool also supports per-skill args:
# guardrail_install_skills({ skill: "guardrails-enforcer", platform: "claude" })
"""
import argparse
import json
import os
import shutil
import sys
import urllib.request
import urllib.error
from pathlib import Path
from typing import Optional
SCRIPT_DIR = Path(__file__).parent.resolve()
REPO_ROOT = SCRIPT_DIR.parent
REPO_OWNER = "TheArchitectit"
REPO_NAME = "agent-guardrails-template"
RAW_BASE = f"https://raw.githubusercontent.com/{REPO_OWNER}/{REPO_NAME}/main"
GITHUB_API = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}"
PLATFORM_CONFIGS = {
"claude": {
"source": REPO_ROOT / ".claude",
"description": "Claude Code skills and hooks",
"target_name": ".claude",
},
"cursor": {
"source": REPO_ROOT / ".cursor" / "rules",
"description": "Cursor rules",
"target_name": ".cursor/rules",
},
"opencode": {
"source": REPO_ROOT / ".opencode",
"description": "OpenCode agents and skills",
"target_name": ".opencode",
},
"windsurf": {
"source": REPO_ROOT / ".windsurfrules",
"description": "Windsurf rules",
"target_name": ".windsurfrules",
},
"copilot": {
"source": REPO_ROOT / ".github" / "copilot-instructions.md",
"description": "GitHub Copilot instructions",
"target_name": ".github/copilot-instructions.md",
},
}
# Per-skill registry: skill_name -> (repo_path, target_subdir)
SKILL_REGISTRY = {
# Claude Code skills
"guardrails-enforcer": (".claude/skills/guardrails-enforcer.json", ".claude/skills/"),
"commit-validator": (".claude/skills/commit-validator.json", ".claude/skills/"),
"env-separator": (".claude/skills/env-separator.json", ".claude/skills/"),
"scope-validator": (".claude/skills/scope-validator.json", ".claude/skills/"),
"production-first": (".claude/skills/production-first.json", ".claude/skills/"),
"three-strikes": (".claude/skills/three-strikes.json", ".claude/skills/"),
"error-recovery": (".claude/skills/error-recovery.json", ".claude/skills/"),
# Claude hooks
"pre-commit-hook": (".claude/hooks/pre-commit.sh", ".claude/hooks/"),
"pre-execution-hook": (".claude/hooks/pre-execution.sh", ".claude/hooks/"),
"post-execution-hook": (".claude/hooks/post-execution.sh", ".claude/hooks/"),
# Cursor rules
"cursor-guardrails": (".cursor/rules/guardrails-enforcer.md", ".cursor/rules/"),
"cursor-production-first": (".cursor/rules/production-first.md", ".cursor/rules/"),
"cursor-three-strikes": (".cursor/rules/three-strikes.md", ".cursor/rules/"),
# Windsurf
"windsurf-rules": (".windsurfrules", ".windsurfrules"),
# Copilot
"copilot-instructions": (".github/copilot-instructions.md", ".github/"),
# OpenCode
"opencode-config": (".opencode/oh-my-opencode.jsonc", ".opencode/"),
"opencode-guardrails": (".opencode/skills/guardrails-enforcer/SKILL.md", ".opencode/skills/guardrails-enforcer/"),
"opencode-commit-validator": (".opencode/skills/commit-validator/SKILL.md", ".opencode/skills/commit-validator/"),
# Shared prompts
"four-laws": ("skills/shared-prompts/four-laws.md", "skills/shared-prompts/"),
"halt-conditions": ("skills/shared-prompts/halt-conditions.md", "skills/shared-prompts/"),
"vibe-coding": ("skills/shared-prompts/vibe-coding.md", "skills/shared-prompts/"),
"error-recovery-md": ("skills/shared-prompts/error-recovery.md", "skills/shared-prompts/"),
"three-strikes-md": ("skills/shared-prompts/three-strikes.md", "skills/shared-prompts/"),
"production-first-md": ("skills/shared-prompts/production-first.md", "skills/shared-prompts/"),
"scope-validation": ("skills/shared-prompts/scope-validation.md", "skills/shared-prompts/"),
}
def resolve_target(target_root: Optional[str], skill_name: str) -> tuple[Path, Path]:
"""Return (source_path, target_path) for a skill."""
repo_path, target_subdir = SKILL_REGISTRY[skill_name]
if target_root:
base = Path(target_root).resolve()
else:
base = REPO_ROOT
source = REPO_ROOT / repo_path
target_dir = base / target_subdir
target = target_dir / Path(repo_path).name
return source, target
def ensure_parent_dirs(path: Path) -> None:
"""Ensure parent directories exist."""
parent = path.parent
if parent != path:
parent.mkdir(parents=True, exist_ok=True)
def get_current_branch() -> str:
"""Get the current git branch for the repository."""
try:
import subprocess
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except Exception:
return "main"
def get_default_branch() -> str:
"""Get the default branch name (main or master)."""
try:
import subprocess
result = subprocess.run(
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
# refs/remotes/origin/main -> main
return result.stdout.strip().split("/")[-1]
except Exception:
return "main"
def download_file(url: str, target: Path, dry_run: bool) -> bool:
"""Download a single file from a raw GitHub URL."""
if dry_run:
print(f"[DRY-RUN] Would download: {url}")
print(f"[DRY-RUN] -> {target}")
return True
try:
ensure_parent_dirs(target)
with urllib.request.urlopen(url) as response:
content = response.read()
with open(target, "wb") as f:
f.write(content)
print(f"[OK] Downloaded: {url}")
print(f" -> {target}")
return True
except urllib.error.HTTPError as e:
return None # Signal 404 for branch fallback
except Exception as e:
print(f"[ERROR] Download failed: {e}")
return False
def clone_skill(path: str, target_root: Optional[str], dry_run: bool) -> bool:
"""Clone (download) a single skill file by repo path. Tries default branch, falls back to current branch."""
if target_root:
base = Path(target_root).resolve()
else:
base = Path(".")
# Determine target: strip leading ./ or /
safe_path = path.lstrip("./")
target = base / safe_path
if dry_run:
print(f"[DRY-RUN] Would download: {RAW_BASE}/{path}")
print(f"[DRY-RUN] -> {target}")
return True
# Try default branch first
default_branch = get_default_branch()
url = f"https://raw.githubusercontent.com/{REPO_OWNER}/{REPO_NAME}/{default_branch}/{path}"
result = download_file(url, target, dry_run)
if result is True:
return True
if result is False:
return False # Real error, not 404
# Fall back to current branch (for feature branches not yet on main)
current_branch = get_current_branch()
url = f"https://raw.githubusercontent.com/{REPO_OWNER}/{REPO_NAME}/{current_branch}/{path}"
result = download_file(url, target, dry_run)
if result is True:
print(f"[INFO] Downloaded from feature branch: {current_branch}")
return result if result is not None else False
def clone_skill_by_name(name: str, target_root: Optional[str], dry_run: bool) -> bool:
"""Clone a skill by its registry name."""
if name not in SKILL_REGISTRY:
print(f"[ERROR] Unknown skill: {name}")
print(f"[INFO] Run --list-skills to see available skills.")
return False
repo_path, _ = SKILL_REGISTRY[name]
return clone_skill(repo_path, target_root, dry_run)
def install_skill(name: str, target_root: Optional[str], mode: str, dry_run: bool) -> bool:
"""Install a single skill from repo to target directory."""
if name not in SKILL_REGISTRY:
print(f"[ERROR] Unknown skill: {name}")
print(f"[INFO] Run --list-skills to see available skills.")
return False
source, target = resolve_target(target_root, name)
if dry_run:
action = "symlink" if mode == "symlink" else "copy"
print(f"[DRY-RUN] Would {action}: {source} -> {target}")
return True
if not source.exists():
print(f"[ERROR] Source not found: {source}")
return False
if target.exists():
print(f"[WARN] Target exists, skipping: {target}")
return True
ensure_parent_dirs(target)
try:
if mode == "symlink":
rel_source = os.path.relpath(source, target.parent)
target.symlink_to(rel_source)
print(f"[OK] Symlinked: {target} -> {rel_source}")
else:
shutil.copy2(source, target)
print(f"[OK] Copied: {source} -> {target}")
return True
except Exception as e:
print(f"[ERROR] Failed to install {name}: {e}")
return False
def list_skills() -> None:
"""List all available skills."""
print("Available skills:")
current_platform = None
for name, (repo_path, _) in SKILL_REGISTRY.items():
platform = repo_path.split("/")[0].lstrip(".")
if platform != current_platform:
current_platform = platform
print(f"\n [{platform}]")
print(f" {name:30s} {repo_path}")
def install_platform(target_root: Optional[str], platform: str, mode: str, dry_run: bool) -> bool:
"""Install all configs for a single platform."""
config = PLATFORM_CONFIGS[platform]
source = config["source"]
if target_root:
base = Path(target_root).resolve()
else:
base = REPO_ROOT
target = base / config["target_name"]
if dry_run:
action = "symlink" if mode == "symlink" else "copy"
print(f"[DRY-RUN] Would {action}: {source} -> {target}")
return True
if not source.exists():
print(f"[ERROR] Source not found: {source}")
return False
if target.exists():
print(f"[WARN] Target exists, skipping: {target}")
return True
ensure_parent_dirs(target)
try:
if mode == "symlink":
rel_source = os.path.relpath(source, target.parent)
target.symlink_to(rel_source)
print(f"[OK] Symlinked: {target} -> {rel_source}")
else:
if source.is_dir():
shutil.copytree(source, target, dirs_exist_ok=False)
else:
shutil.copy2(source, target)
print(f"[OK] Copied: {source} -> {target}")
return True
except Exception as e:
print(f"[ERROR] Failed to install {platform}: {e}")
return False
def install_all(target_root: Optional[str], platforms: list[str], mode: str, dry_run: bool) -> bool:
"""Install configs for all specified platforms."""
success = True
for platform in platforms:
if platform not in PLATFORM_CONFIGS:
print(f"[ERROR] Unknown platform: {platform}")
success = False
continue
if not install_platform(target_root, platform, mode, dry_run):
success = False
return success
def validate_sources(platforms: list[str]) -> bool:
"""Validate that all source files exist."""
missing = []
for platform in platforms:
if platform not in PLATFORM_CONFIGS:
missing.append(platform)
continue
source = PLATFORM_CONFIGS[platform]["source"]
if not source.exists():
missing.append(f"{platform} ({source})")
if missing:
print("[ERROR] Missing source files:")
for m in missing:
print(f" {m}")
return False
return True
def main() -> int:
parser = argparse.ArgumentParser(
description="Install agent guardrails for Claude Code, Cursor, OpenCode, Windsurf, and Copilot."
)
parser.add_argument(
"--install",
action="store_true",
help="Install all configs for a platform (or all platforms)",
)
parser.add_argument(
"--clone",
type=str,
metavar="PATH",
help="Clone a single skill file by repo path (e.g. .claude/skills/guardrails-enforcer.json)",
)
parser.add_argument(
"--install-skill",
type=str,
metavar="NAME",
help="Install a single skill by name (e.g. guardrails-enforcer). Use --list-skills to see all.",
)
parser.add_argument(
"--platform",
type=str,
default="all",
help="Comma-separated list of platforms: claude, cursor, opencode, windsurf, copilot (default: all)",
)
parser.add_argument(
"--target",
type=str,
default=None,
help="Target project directory (default: current directory or repo root)",
)
parser.add_argument(
"--mode",
type=str,
choices=["copy", "symlink"],
default="copy",
help="Installation mode: copy or symlink (default: copy)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview what would be installed without making changes",
)
parser.add_argument(
"--list-platforms",
action="store_true",
help="List available platforms and exit",
)
parser.add_argument(
"--list-skills",
action="store_true",
help="List all available skills and exit",
)
args = parser.parse_args()
if args.list_platforms:
print("Available platforms:")
for name, config in PLATFORM_CONFIGS.items():
exists = " [exists]" if config["source"].exists() else " [missing]"
print(f" {name}: {config['description']}{exists}")
return 0
if args.list_skills:
list_skills()
return 0
# Clone a single file by repo path (e.g. --clone .claude/skills/guardrails-enforcer.json)
if args.clone:
ok = clone_skill(args.clone, args.target, args.dry_run)
return 0 if ok else 1
# Install a single skill by name (e.g. --install-skill guardrails-enforcer)
if args.install_skill:
ok = install_skill(args.install_skill, args.target, args.mode, args.dry_run)
return 0 if ok else 1
# Install full platform(s)
if args.install or args.dry_run:
platforms = [p.strip() for p in args.platform.split(",")]
if "all" in platforms:
platforms = list(PLATFORM_CONFIGS.keys())
if not validate_sources(platforms):
return 1
ok = install_all(args.target, platforms, args.mode, args.dry_run)
if args.dry_run:
print("\n[INFO] Dry-run complete.")
else:
print(f"\n[OK] Installed guardrails for: {', '.join(platforms)}")
return 0 if ok else 1
parser.print_help()
print("\nExamples:")
print(" python scripts/setup_agents.py --install --platform claude")
print(" python scripts/setup_agents.py --install-skill guardrails-enforcer")
print(" python scripts/setup_agents.py --clone .claude/skills/guardrails-enforcer.json")
print(" python scripts/setup_agents.py --list-skills")
return 1
if __name__ == "__main__":
sys.exit(main())